Cut (Ctrl-X) marks the selection on the clipboard with a `cut` flag; a paste of a cut from a real playlist is a *move* (single-undo across playlists via move_tracks_between_playlists; same-playlist reorders), while a copy — or a cut from the library, which has nothing to remove — just adds. Paste now inserts *above the selected track* rather than appending. Cut/Paste added to the track context menu; parse_tracks_mime now tolerates a None/empty clipboard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
774 lines
30 KiB
Python
774 lines
30 KiB
Python
import json
|
|
import os
|
|
|
|
from PyQt6.QtWidgets import (
|
|
QTableView, QAbstractItemView, QMenu, QApplication,
|
|
)
|
|
from PyQt6.QtCore import (
|
|
Qt, QAbstractTableModel, QModelIndex, QSortFilterProxyModel, pyqtSignal,
|
|
QMimeData, QUrl, QPoint, QPointF, QRectF, QMetaType,
|
|
)
|
|
from PyQt6.QtGui import (
|
|
QDrag, QKeySequence, QShortcut, QPixmap, QPainter, QPen, QPolygonF, QColor,
|
|
QFontMetrics, QDesktopServices, QCursor,
|
|
)
|
|
from PyQt6.QtDBus import (
|
|
QDBusConnection, QDBusInterface, QDBusMessage, QDBusArgument,
|
|
)
|
|
|
|
from lintunes.models import Track
|
|
from lintunes.gui import drag_ghost
|
|
|
|
|
|
TRACKS_MIME = "application/x-lintunes-tracks"
|
|
SORT_ROLE = Qt.ItemDataRole.UserRole
|
|
|
|
# Manual play-order pseudo-column (playlist views only, always first)
|
|
INDEX_FIELD = "#"
|
|
|
|
# Column definitions: (field_name, display_name, default_width)
|
|
ALL_COLUMNS = [
|
|
("name", "Name", 250),
|
|
("artist", "Artist", 180),
|
|
("album", "Album", 180),
|
|
("album_artist", "Album Artist", 180),
|
|
("genre", "Genre", 120),
|
|
("total_time", "Time", 60),
|
|
("year", "Year", 50),
|
|
("track_number", "Track #", 60),
|
|
("disc_number", "Disc", 40),
|
|
("play_count", "Plays", 50),
|
|
("skip_count", "Skips", 50),
|
|
("rating", "Rating", 80),
|
|
("date_added", "Date Added", 140),
|
|
("date_modified", "Date Modified", 140),
|
|
("play_date_utc", "Last Played", 140),
|
|
("skip_date", "Last Skipped", 140),
|
|
("bit_rate", "Bit Rate", 60),
|
|
("sample_rate", "Sample Rate", 80),
|
|
("composer", "Composer", 150),
|
|
("grouping", "Grouping", 120),
|
|
("comments", "Comments", 150),
|
|
("bpm", "BPM", 50),
|
|
("kind", "Kind", 120),
|
|
("size", "Size", 70),
|
|
]
|
|
|
|
COLUMN_MAP = {col[0]: col for col in ALL_COLUMNS}
|
|
|
|
|
|
def format_time(ms: int) -> str:
|
|
if ms <= 0:
|
|
return ""
|
|
total_secs = round(ms / 1000)
|
|
mins, secs = divmod(total_secs, 60)
|
|
if mins >= 60:
|
|
hours, mins = divmod(mins, 60)
|
|
return f"{hours}:{mins:02d}:{secs:02d}"
|
|
return f"{mins}:{secs:02d}"
|
|
|
|
|
|
def format_total_time(ms: int) -> str:
|
|
"""Trimmed DD:HH:MM:SS: drop empty leading units, keep at least M:SS.
|
|
|
|
e.g. 14:21, 9:47:33, 24:18:42:07."""
|
|
total_secs = round(max(0, ms) / 1000)
|
|
days, rem = divmod(total_secs, 86400)
|
|
hours, rem = divmod(rem, 3600)
|
|
mins, secs = divmod(rem, 60)
|
|
parts = [days, hours, mins, secs]
|
|
while len(parts) > 2 and parts[0] == 0:
|
|
parts.pop(0)
|
|
return ":".join(str(p) if i == 0 else f"{p:02d}"
|
|
for i, p in enumerate(parts))
|
|
|
|
|
|
def _format_rating(rating: int) -> str:
|
|
if rating <= 0:
|
|
return ""
|
|
return "★" * (rating // 20)
|
|
|
|
|
|
def _format_size(size: int) -> str:
|
|
if size <= 0:
|
|
return ""
|
|
mb = size / (1024 * 1024)
|
|
return f"{mb:.1f} MB"
|
|
|
|
|
|
def _format_date(iso_str: str) -> str:
|
|
return iso_str[:10] if iso_str else ""
|
|
|
|
|
|
# Now-playing speaker pixmaps, cached per (playing, color)
|
|
_speaker_cache: dict[tuple[bool, str], QPixmap] = {}
|
|
|
|
|
|
def speaker_pixmap(playing: bool, color: QColor) -> QPixmap:
|
|
"""16px speaker silhouette; with sound waves when playing."""
|
|
key = (playing, color.name())
|
|
cached = _speaker_cache.get(key)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
from PyQt6.QtCore import QPointF, QRectF
|
|
# 19px wide (not 16) so the outer wave arc, which reaches ~x=17.7 with
|
|
# its pen, isn't clipped on the right. Height stays 16.
|
|
pixmap = QPixmap(19, 16)
|
|
pixmap.fill(Qt.GlobalColor.transparent)
|
|
painter = QPainter(pixmap)
|
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
painter.setPen(Qt.PenStyle.NoPen)
|
|
painter.setBrush(color)
|
|
painter.drawPolygon(QPolygonF([
|
|
QPointF(1, 5.5), QPointF(4.5, 5.5), QPointF(8.5, 1.5),
|
|
QPointF(8.5, 14.5), QPointF(4.5, 10.5), QPointF(1, 10.5),
|
|
]))
|
|
if playing:
|
|
pen = QPen(color, 1.4)
|
|
painter.setPen(pen)
|
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
|
painter.drawArc(QRectF(9.5, 5.5, 4, 5), -60 * 16, 120 * 16)
|
|
painter.drawArc(QRectF(10.5, 3.0, 6.5, 10), -60 * 16, 120 * 16)
|
|
painter.end()
|
|
_speaker_cache[key] = pixmap
|
|
return pixmap
|
|
|
|
|
|
def make_tracks_mime(track_ids: list[int], source_playlist: str = "",
|
|
rows: list[int] | None = None, cut: bool = False) -> QMimeData:
|
|
mime = QMimeData()
|
|
payload = {"track_ids": track_ids, "source_playlist": source_playlist,
|
|
"rows": rows or [], "cut": cut}
|
|
mime.setData(TRACKS_MIME, json.dumps(payload).encode())
|
|
return mime
|
|
|
|
|
|
def parse_tracks_mime(mime: QMimeData) -> dict | None:
|
|
if mime is None or not mime.hasFormat(TRACKS_MIME):
|
|
return None
|
|
try:
|
|
return json.loads(bytes(mime.data(TRACKS_MIME)).decode())
|
|
except (ValueError, UnicodeDecodeError):
|
|
return None
|
|
|
|
|
|
def reveal_paths(paths: list[str]) -> None:
|
|
"""Select the given files in the desktop file manager.
|
|
|
|
Uses the freedesktop org.freedesktop.FileManager1 interface (Nautilus,
|
|
Dolphin, Nemo, …), which highlights the files in their folder. Falls back
|
|
to just opening the containing folder if that service is unavailable.
|
|
"""
|
|
if not paths:
|
|
return
|
|
bus = QDBusConnection.sessionBus()
|
|
if bus.isConnected():
|
|
# The URIs must be marshalled as a D-Bus string array ("as"); a plain
|
|
# Python list goes over as "av" and Nautilus rejects it (InvalidArgs).
|
|
uris = QDBusArgument()
|
|
uris.beginArray(QMetaType(QMetaType.Type.QString.value).id())
|
|
for p in paths:
|
|
uris.add(QUrl.fromLocalFile(p).toString())
|
|
uris.endArray()
|
|
iface = QDBusInterface(
|
|
"org.freedesktop.FileManager1",
|
|
"/org/freedesktop/FileManager1",
|
|
"org.freedesktop.FileManager1",
|
|
bus,
|
|
)
|
|
reply = iface.call("ShowItems", uris, "")
|
|
if reply.type() != QDBusMessage.MessageType.ErrorMessage:
|
|
return
|
|
# No FileManager1 service (or it errored): open the folder instead.
|
|
QDesktopServices.openUrl(QUrl.fromLocalFile(os.path.dirname(paths[0])))
|
|
|
|
|
|
class TrackTableModel(QAbstractTableModel):
|
|
def __init__(self, parent=None, with_index_column=False):
|
|
super().__init__(parent)
|
|
self._tracks: list[Track] = []
|
|
self._fields: list[str] = []
|
|
self._with_index = with_index_column
|
|
self._now_playing_id: int | None = None
|
|
self._now_playing_active = False
|
|
self.set_visible_columns(["name", "artist", "album"])
|
|
|
|
def set_now_playing(self, track_id: int | None, playing: bool):
|
|
old_id = self._now_playing_id
|
|
self._now_playing_id = track_id
|
|
self._now_playing_active = playing
|
|
for refresh_id in {old_id, track_id} - {None}:
|
|
self.refresh_track(refresh_id)
|
|
|
|
@property
|
|
def fields(self) -> list[str]:
|
|
return self._fields
|
|
|
|
def set_visible_columns(self, columns: list[str]):
|
|
self.beginResetModel()
|
|
fields = [c for c in columns if c in COLUMN_MAP]
|
|
if not fields:
|
|
fields = ["name"]
|
|
if self._with_index:
|
|
fields = [INDEX_FIELD] + fields
|
|
self._fields = fields
|
|
self.endResetModel()
|
|
|
|
def set_tracks(self, tracks: list[Track]):
|
|
self.beginResetModel()
|
|
self._tracks = list(tracks)
|
|
self.endResetModel()
|
|
|
|
def track_at(self, row: int) -> Track:
|
|
return self._tracks[row]
|
|
|
|
@property
|
|
def tracks(self) -> list[Track]:
|
|
return self._tracks
|
|
|
|
def refresh_track(self, track_id: int):
|
|
for row, track in enumerate(self._tracks):
|
|
if track.track_id == track_id:
|
|
self.dataChanged.emit(self.index(row, 0),
|
|
self.index(row, self.columnCount() - 1))
|
|
|
|
def flags(self, index):
|
|
# ItemIsDragEnabled is what lets the view initiate track drags
|
|
return (Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable
|
|
| Qt.ItemFlag.ItemIsDragEnabled)
|
|
|
|
def rowCount(self, parent=QModelIndex()):
|
|
return 0 if parent.isValid() else len(self._tracks)
|
|
|
|
def columnCount(self, parent=QModelIndex()):
|
|
return 0 if parent.isValid() else len(self._fields)
|
|
|
|
def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
|
|
if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole:
|
|
field = self._fields[section]
|
|
if field == INDEX_FIELD:
|
|
return "#"
|
|
return COLUMN_MAP[field][1]
|
|
return None
|
|
|
|
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
|
|
if not index.isValid():
|
|
return None
|
|
field = self._fields[index.column()]
|
|
track = self._tracks[index.row()]
|
|
|
|
if field == INDEX_FIELD:
|
|
if role == Qt.ItemDataRole.DisplayRole or role == SORT_ROLE:
|
|
return index.row() + 1
|
|
return None
|
|
|
|
if (role == Qt.ItemDataRole.DecorationRole and field == "name"
|
|
and track.track_id == self._now_playing_id):
|
|
from PyQt6.QtWidgets import QApplication
|
|
color = QApplication.palette().text().color()
|
|
return speaker_pixmap(self._now_playing_active, color)
|
|
|
|
value = getattr(track, field, "")
|
|
if role == SORT_ROLE:
|
|
if isinstance(value, str):
|
|
return value.lower()
|
|
return value if value is not None else 0
|
|
if role == Qt.ItemDataRole.DisplayRole:
|
|
if field == "total_time":
|
|
return format_time(value)
|
|
if field == "rating":
|
|
return _format_rating(value)
|
|
if field == "size":
|
|
return _format_size(value)
|
|
if field in ("date_added", "play_date_utc", "skip_date", "date_modified"):
|
|
return _format_date(value or "")
|
|
if isinstance(value, int) and value == 0:
|
|
return ""
|
|
return str(value) if value else ""
|
|
return None
|
|
|
|
|
|
class TrackTableView(QTableView):
|
|
"""Shared track list for the library and playlist views.
|
|
|
|
Owners connect to the signals and apply changes through LibraryManager;
|
|
the view itself never mutates the library.
|
|
"""
|
|
|
|
play_requested = pyqtSignal(list, int) # ids in view order, start index
|
|
sort_changed = pyqtSignal(str, bool) # field, ascending
|
|
columns_changed = pyqtSignal(list) # visible field names
|
|
column_width_changed = pyqtSignal(str, int)
|
|
reorder_requested = pyqtSignal(list, int) # manual rows, dest manual row
|
|
tracks_dropped = pyqtSignal(list, object) # track ids, insert row or None
|
|
files_dropped = pyqtSignal(list, object) # paths, insert row or None
|
|
remove_requested = pyqtSignal(list) # manual rows
|
|
paste_requested = pyqtSignal()
|
|
info_requested = pyqtSignal(list) # selected track ids
|
|
tracks_changed = pyqtSignal() # displayed track set changed
|
|
show_in_playlist_requested = pyqtSignal(int, str) # track_id, playlist pid
|
|
|
|
def __init__(self, parent=None, playlist_mode=False):
|
|
super().__init__(parent)
|
|
self._playlist_mode = playlist_mode
|
|
self._source_playlist_id = ""
|
|
|
|
self.model_ = TrackTableModel(self, with_index_column=playlist_mode)
|
|
self.proxy = QSortFilterProxyModel(self)
|
|
self.proxy.setSourceModel(self.model_)
|
|
self.proxy.setSortRole(SORT_ROLE)
|
|
self.setModel(self.proxy)
|
|
|
|
self.setAlternatingRowColors(True)
|
|
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
|
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
|
self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
|
self.setSortingEnabled(True)
|
|
self.setShowGrid(False)
|
|
self.verticalHeader().setVisible(False)
|
|
self.verticalHeader().setDefaultSectionSize(22)
|
|
self.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
|
self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerItem)
|
|
|
|
header = self.horizontalHeader()
|
|
header.setSectionsMovable(True)
|
|
header.setStretchLastSection(True)
|
|
# Don't bold header labels when a row/cell becomes current (playback
|
|
# sets the current index, which would otherwise bold every section).
|
|
header.setHighlightSections(False)
|
|
header.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
header.customContextMenuRequested.connect(self._show_header_menu)
|
|
header.sortIndicatorChanged.connect(self._on_sort_indicator)
|
|
header.sectionResized.connect(self._on_section_resized)
|
|
|
|
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
self.customContextMenuRequested.connect(self._show_context_menu)
|
|
self.doubleClicked.connect(self._on_double_click)
|
|
|
|
# Set by the owning view (which holds the manager) so the right-click
|
|
# menu can list the playlists a track belongs to without the view
|
|
# itself reaching into the library.
|
|
self.playlists_for_track = None # Callable[[int], list[tuple[str,str]]]
|
|
|
|
# Drag and drop (default indicator off: we paint a glowing line)
|
|
self.setDragEnabled(True)
|
|
self.setAcceptDrops(True)
|
|
self.setDropIndicatorShown(False)
|
|
self._drop_indicator_y: int | None = None
|
|
|
|
QShortcut(QKeySequence.StandardKey.Copy, self,
|
|
context=Qt.ShortcutContext.WidgetShortcut,
|
|
activated=self.copy_selection)
|
|
QShortcut(QKeySequence.StandardKey.Cut, self,
|
|
context=Qt.ShortcutContext.WidgetShortcut,
|
|
activated=self.cut_selection)
|
|
QShortcut(QKeySequence.StandardKey.Paste, self,
|
|
context=Qt.ShortcutContext.WidgetShortcut,
|
|
activated=self.paste_requested)
|
|
|
|
self._suppress_signals = False
|
|
|
|
# ---- configuration ----
|
|
|
|
def set_source_playlist(self, pid: str):
|
|
self._source_playlist_id = pid
|
|
|
|
def context_id(self) -> str:
|
|
"""Identifies what this table is currently showing, so the now-playing
|
|
icon can be scoped to the context playback started from."""
|
|
if self._playlist_mode:
|
|
return f"playlist:{self._source_playlist_id}"
|
|
return "library"
|
|
|
|
def reveal_track(self, track_id: int) -> bool:
|
|
"""Scroll to and select the row for ``track_id``; False if not shown."""
|
|
for src_row, track in enumerate(self.model_.tracks):
|
|
if track.track_id == track_id:
|
|
proxy_index = self.proxy.mapFromSource(
|
|
self.model_.index(src_row, 0))
|
|
self.setCurrentIndex(proxy_index)
|
|
self.selectRow(proxy_index.row())
|
|
self.scrollTo(
|
|
proxy_index,
|
|
QAbstractItemView.ScrollHint.PositionAtCenter)
|
|
return True
|
|
return False
|
|
|
|
def set_tracks(self, tracks: list[Track]):
|
|
self.model_.set_tracks(tracks)
|
|
self.tracks_changed.emit()
|
|
|
|
def total_stats(self) -> tuple[int, int]:
|
|
"""(track count, summed total_time in ms) of the displayed tracks."""
|
|
tracks = self.model_.tracks
|
|
return len(tracks), sum(t.total_time for t in tracks)
|
|
|
|
def apply_settings(self, settings):
|
|
"""Apply a PlaylistSettings: columns, widths, sort."""
|
|
self._suppress_signals = True
|
|
try:
|
|
self.model_.set_visible_columns(settings.visible_columns)
|
|
self._apply_default_widths()
|
|
for field, width in settings.column_widths.items():
|
|
if field in self.model_.fields:
|
|
self.setColumnWidth(self.model_.fields.index(field), width)
|
|
sort_field = settings.sort_column
|
|
if sort_field not in self.model_.fields:
|
|
sort_field = self.model_.fields[0]
|
|
order = (Qt.SortOrder.AscendingOrder if settings.sort_ascending
|
|
else Qt.SortOrder.DescendingOrder)
|
|
self.sortByColumn(self.model_.fields.index(sort_field), order)
|
|
finally:
|
|
self._suppress_signals = False
|
|
|
|
def _apply_default_widths(self):
|
|
for i, field in enumerate(self.model_.fields):
|
|
if field == INDEX_FIELD:
|
|
self.setColumnWidth(i, 50)
|
|
else:
|
|
self.setColumnWidth(i, COLUMN_MAP[field][2])
|
|
|
|
# ---- selection / ordering helpers ----
|
|
|
|
def view_order_track_ids(self) -> list[int]:
|
|
ids = []
|
|
for proxy_row in range(self.proxy.rowCount()):
|
|
source_row = self.proxy.mapToSource(self.proxy.index(proxy_row, 0)).row()
|
|
ids.append(self.model_.track_at(source_row).track_id)
|
|
return ids
|
|
|
|
def selected_source_rows(self) -> list[int]:
|
|
rows = {self.proxy.mapToSource(idx).row()
|
|
for idx in self.selectionModel().selectedRows()}
|
|
return sorted(rows)
|
|
|
|
def selected_track_ids(self) -> list[int]:
|
|
return [self.model_.track_at(r).track_id for r in self.selected_source_rows()]
|
|
|
|
def selected_locations(self) -> list[str]:
|
|
"""File paths of the selected tracks, skipping any without a location."""
|
|
locs = [self.model_.track_at(r).location
|
|
for r in self.selected_source_rows()]
|
|
return [loc for loc in locs if loc]
|
|
|
|
def is_manual_sort(self) -> bool:
|
|
header = self.horizontalHeader()
|
|
return (self._playlist_mode
|
|
and self.model_.fields
|
|
and self.model_.fields[header.sortIndicatorSection()] == INDEX_FIELD
|
|
and header.sortIndicatorOrder() == Qt.SortOrder.AscendingOrder)
|
|
|
|
# ---- copy / paste ----
|
|
|
|
def copy_selection(self):
|
|
self._put_on_clipboard(cut=False)
|
|
|
|
def cut_selection(self):
|
|
"""Mark the selection for a move: a later paste removes them from this
|
|
source (a real playlist) and inserts them at the paste target."""
|
|
self._put_on_clipboard(cut=True)
|
|
|
|
def _put_on_clipboard(self, cut: bool):
|
|
ids = self.selected_track_ids()
|
|
if not ids:
|
|
return
|
|
mime = make_tracks_mime(ids, self._source_playlist_id,
|
|
self.selected_source_rows(), cut=cut)
|
|
QApplication.clipboard().setMimeData(mime)
|
|
|
|
def paste_anchor_row(self):
|
|
"""Source-model row to paste *above* — the first selected track — or
|
|
None to append when nothing is selected."""
|
|
rows = self.selected_source_rows()
|
|
return rows[0] if rows else None
|
|
|
|
# ---- header interactions ----
|
|
|
|
def _on_sort_indicator(self, section, order):
|
|
if self._suppress_signals or not self.model_.fields:
|
|
return
|
|
field = self.model_.fields[section]
|
|
self.sort_changed.emit(field, order == Qt.SortOrder.AscendingOrder)
|
|
|
|
def _on_section_resized(self, section, _old, new_width):
|
|
if self._suppress_signals or section >= len(self.model_.fields):
|
|
return
|
|
field = self.model_.fields[section]
|
|
if field != INDEX_FIELD:
|
|
self.column_width_changed.emit(field, new_width)
|
|
|
|
def _show_header_menu(self, pos):
|
|
menu = QMenu(self)
|
|
visible = [f for f in self.model_.fields if f != INDEX_FIELD]
|
|
for field, label, _w in ALL_COLUMNS:
|
|
action = menu.addAction(label)
|
|
action.setCheckable(True)
|
|
action.setChecked(field in visible)
|
|
action.setData(field)
|
|
chosen = menu.exec(self.horizontalHeader().mapToGlobal(pos))
|
|
if chosen is None:
|
|
return
|
|
field = chosen.data()
|
|
if chosen.isChecked():
|
|
new_visible = visible + [field]
|
|
# Preserve canonical column ordering
|
|
new_visible = [f for f, _l, _w in ALL_COLUMNS if f in new_visible]
|
|
else:
|
|
new_visible = [f for f in visible if f != field]
|
|
if not new_visible:
|
|
return
|
|
self.columns_changed.emit(new_visible)
|
|
|
|
# ---- context menu ----
|
|
|
|
def _show_context_menu(self, pos):
|
|
if not self.selected_track_ids():
|
|
return
|
|
menu = QMenu(self)
|
|
info_action = menu.addAction("Get Info\tCtrl+I")
|
|
copy_action = menu.addAction("Copy\tCtrl+C")
|
|
cut_action = menu.addAction("Cut\tCtrl+X")
|
|
paste_action = None
|
|
if (self._playlist_mode
|
|
and parse_tracks_mime(QApplication.clipboard().mimeData())):
|
|
paste_action = menu.addAction("Paste\tCtrl+V")
|
|
|
|
locations = self.selected_locations()
|
|
reveal_action = copy_path_action = None
|
|
if locations:
|
|
menu.addSeparator()
|
|
reveal_action = menu.addAction("Reveal in File Browser")
|
|
copy_path_action = menu.addAction("Copy File Path")
|
|
|
|
# "Show in Playlist" — only for a single track, listing the regular
|
|
# playlists it belongs to. Hovering reveals the submenu; choosing an
|
|
# entry jumps to that playlist and highlights the first instance.
|
|
show_in_menu = None
|
|
selected = self.selected_track_ids()
|
|
if len(selected) == 1 and self.playlists_for_track is not None:
|
|
entries = self.playlists_for_track(selected[0])
|
|
if entries:
|
|
menu.addSeparator()
|
|
show_in_menu = menu.addMenu("Show in Playlist")
|
|
for pid, name in entries:
|
|
show_in_menu.addAction(name).setData(pid)
|
|
|
|
remove_action = None
|
|
if self._playlist_mode:
|
|
menu.addSeparator()
|
|
remove_action = menu.addAction("Remove from Playlist\tDel")
|
|
chosen = menu.exec(self.viewport().mapToGlobal(pos))
|
|
if chosen is None:
|
|
return
|
|
if chosen is info_action:
|
|
self.info_requested.emit(self.selected_track_ids())
|
|
elif chosen is copy_action:
|
|
self.copy_selection()
|
|
elif chosen is cut_action:
|
|
self.cut_selection()
|
|
elif paste_action is not None and chosen is paste_action:
|
|
self.paste_requested.emit()
|
|
elif chosen is reveal_action:
|
|
reveal_paths(locations)
|
|
elif chosen is copy_path_action:
|
|
QApplication.clipboard().setText("\n".join(locations))
|
|
elif show_in_menu is not None and chosen in show_in_menu.actions():
|
|
self.show_in_playlist_requested.emit(selected[0], chosen.data())
|
|
elif remove_action is not None and chosen is remove_action:
|
|
self.remove_requested.emit(self.selected_source_rows())
|
|
|
|
# ---- playback ----
|
|
|
|
def _on_double_click(self, index):
|
|
ids = self.view_order_track_ids()
|
|
if ids:
|
|
self.play_requested.emit(ids, index.row())
|
|
|
|
# ---- keyboard ----
|
|
|
|
def keyPressEvent(self, event):
|
|
if (event.key() in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace)
|
|
and self._playlist_mode and self.selected_source_rows()):
|
|
self.remove_requested.emit(self.selected_source_rows())
|
|
return
|
|
if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
|
|
current = self.currentIndex()
|
|
if current.isValid():
|
|
self._on_double_click(current)
|
|
return
|
|
super().keyPressEvent(event)
|
|
|
|
# ---- drag and drop ----
|
|
|
|
def startDrag(self, supported_actions):
|
|
ids = self.selected_track_ids()
|
|
if not ids:
|
|
return
|
|
drag = QDrag(self)
|
|
drag.setMimeData(make_tracks_mime(ids, self._source_playlist_id,
|
|
self.selected_source_rows()))
|
|
# Mutter/Wayland won't render QDrag.setPixmap, so we drive our own
|
|
# cursor-following overlay (drag_ghost) instead.
|
|
pixmap, hotspot = self._drag_pixmap(ids)
|
|
drag_ghost.begin(self.window(), pixmap, hotspot, QCursor.pos())
|
|
try:
|
|
drag.exec(Qt.DropAction.CopyAction | Qt.DropAction.MoveAction)
|
|
finally:
|
|
drag_ghost.end()
|
|
|
|
def _drag_pixmap(self, ids: list[int]) -> tuple[QPixmap, QPoint]:
|
|
"""A song-file icon plus a rounded chip naming the dragged track(s).
|
|
|
|
Returns the composed pixmap and the hotspot (the icon's centre) so the
|
|
icon sits directly under the cursor with the name chip trailing right.
|
|
"""
|
|
name = self.model_.track_at(self.selected_source_rows()[0]).name
|
|
text = name or "(untitled)"
|
|
if len(ids) > 1:
|
|
text += f" +{len(ids) - 1} more"
|
|
metrics = QFontMetrics(self.font())
|
|
text = metrics.elidedText(text, Qt.TextElideMode.ElideRight, 260)
|
|
pad_x = 10
|
|
chip_w = metrics.horizontalAdvance(text) + 2 * pad_x
|
|
h = metrics.height() + 10
|
|
icon_size = h
|
|
gap = 6
|
|
w = icon_size + gap + chip_w
|
|
ratio = self.devicePixelRatioF()
|
|
pixmap = QPixmap(int(w * ratio), int(h * ratio))
|
|
pixmap.setDevicePixelRatio(ratio)
|
|
pixmap.fill(Qt.GlobalColor.transparent)
|
|
painter = QPainter(pixmap)
|
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
|
|
painter.drawPixmap(0, 0, drag_ghost.song_icon(icon_size, ratio))
|
|
chip_x = icon_size + gap
|
|
background = QColor(self.palette().highlight().color())
|
|
background.setAlpha(230)
|
|
painter.setPen(Qt.PenStyle.NoPen)
|
|
painter.setBrush(background)
|
|
painter.drawRoundedRect(QRectF(chip_x, 0, chip_w, h), h / 2, h / 2)
|
|
painter.setPen(self.palette().highlightedText().color())
|
|
painter.setFont(self.font())
|
|
painter.drawText(QRectF(chip_x + pad_x, 0, chip_w - 2 * pad_x, h),
|
|
Qt.AlignmentFlag.AlignVCenter, text)
|
|
painter.end()
|
|
return pixmap, QPoint(icon_size // 2, h // 2)
|
|
|
|
def dragEnterEvent(self, event):
|
|
if event.mimeData().hasFormat(TRACKS_MIME) or event.mimeData().hasUrls():
|
|
event.acceptProposedAction()
|
|
else:
|
|
event.ignore()
|
|
|
|
def dragMoveEvent(self, event):
|
|
drag_ghost.move(self.viewport().mapToGlobal(event.position().toPoint()))
|
|
mime = event.mimeData()
|
|
accepted = False
|
|
if mime.hasUrls():
|
|
accepted = True
|
|
else:
|
|
payload = parse_tracks_mime(mime)
|
|
if payload is None:
|
|
accepted = False
|
|
elif (self._playlist_mode and self._source_playlist_id
|
|
and payload.get("source_playlist") == self._source_playlist_id):
|
|
# reorder only allowed in manual order
|
|
accepted = self.is_manual_sort()
|
|
else:
|
|
# library view is not a drop target for tracks
|
|
accepted = self._playlist_mode
|
|
if accepted:
|
|
event.acceptProposedAction()
|
|
self._set_drop_indicator(
|
|
self._indicator_y(event.position().toPoint()))
|
|
else:
|
|
event.ignore()
|
|
self._set_drop_indicator(None)
|
|
|
|
def dragLeaveEvent(self, event):
|
|
self._set_drop_indicator(None)
|
|
super().dragLeaveEvent(event)
|
|
|
|
def dropEvent(self, event):
|
|
self._set_drop_indicator(None)
|
|
mime = event.mimeData()
|
|
drop_row = self._drop_row(event.position().toPoint())
|
|
if mime.hasUrls():
|
|
paths = [u.toLocalFile() for u in mime.urls() if u.isLocalFile()]
|
|
if paths:
|
|
self.files_dropped.emit(paths, drop_row)
|
|
event.acceptProposedAction()
|
|
return
|
|
payload = parse_tracks_mime(mime)
|
|
if payload is None or not self._playlist_mode:
|
|
return
|
|
same_playlist = (self._source_playlist_id
|
|
and payload.get("source_playlist") == self._source_playlist_id)
|
|
if same_playlist:
|
|
if self.is_manual_sort():
|
|
dest = drop_row if drop_row is not None else self.model_.rowCount()
|
|
self.reorder_requested.emit(payload.get("rows", []), dest)
|
|
event.acceptProposedAction()
|
|
else:
|
|
self.tracks_dropped.emit(payload.get("track_ids", []), drop_row)
|
|
event.acceptProposedAction()
|
|
|
|
def _drop_row(self, pos) -> int | None:
|
|
"""Manual-order row to insert before, or None to append.
|
|
|
|
Only meaningful when the view order equals manual order; otherwise
|
|
callers should append.
|
|
"""
|
|
index = self.indexAt(pos)
|
|
if not index.isValid():
|
|
return None
|
|
if not self.is_manual_sort():
|
|
return None
|
|
rect = self.visualRect(index)
|
|
row = index.row()
|
|
if pos.y() > rect.center().y():
|
|
row += 1
|
|
return row
|
|
|
|
# ---- drop indicator ----
|
|
|
|
def _indicator_y(self, pos) -> int:
|
|
"""Viewport y for the insertion line matching where the drop lands."""
|
|
count = self.proxy.rowCount()
|
|
if count == 0:
|
|
return 0
|
|
row = self._drop_row(pos)
|
|
if row is None: # drop appends
|
|
row = count
|
|
if row < count:
|
|
return self.visualRect(self.proxy.index(row, 0)).top()
|
|
return self.visualRect(self.proxy.index(count - 1, 0)).bottom() + 1
|
|
|
|
def _set_drop_indicator(self, y: int | None):
|
|
if y != self._drop_indicator_y:
|
|
self._drop_indicator_y = y
|
|
self.viewport().update()
|
|
|
|
def paintEvent(self, event):
|
|
super().paintEvent(event)
|
|
if self._drop_indicator_y is None:
|
|
return
|
|
y = max(1, min(self._drop_indicator_y, self.viewport().height() - 2))
|
|
right = self.viewport().width() - 4
|
|
painter = QPainter(self.viewport())
|
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
color = QColor(self.palette().highlight().color())
|
|
glow = QColor(color)
|
|
glow.setAlpha(70)
|
|
painter.setPen(QPen(glow, 6, Qt.PenStyle.SolidLine,
|
|
Qt.PenCapStyle.RoundCap))
|
|
painter.drawLine(4, y, right, y)
|
|
painter.setPen(QPen(color, 2))
|
|
painter.drawLine(4, y, right, y)
|
|
painter.setPen(Qt.PenStyle.NoPen)
|
|
painter.setBrush(color)
|
|
painter.drawEllipse(QPointF(4, y), 3, 3)
|
|
painter.drawEllipse(QPointF(right, y), 3, 3)
|