Files
lintunes/lintunes/gui/track_table.py
T
trav 5f917eb68c Round 17: task batch + cruft sweep (ratings, art download, MPRIS/exit/BT fixes)
Features:
- Rating hover dots: hovering a rating cell shows 5 clickable slots
  (RatingDelegate); click slot k sets k stars, clicking the current count
  clears. Undoable, library-only (ratings never rewrite music files).
- Search bar moved into the Library header strip, right of the Library
  button, so the tracklist top aligns with the playlist tree.
- Right-click "Download Album Art…": iTunes Search API (no key), off-thread
  fetch, preview/confirm dialog with Next Result, embeds via write_artwork
  + size refresh, invalidates the MPRIS art cache.

Fixes:
- MPRIS media keys: PropertiesChanged sent invalidated_properties as "av"
  instead of "as", so gsd-media-keys dropped it and never MRU-bumped
  lintunes (why the play/pause key kept waking stale players). Now an
  explicit QDBusArgument string array; loopback-verified sa{sv}as.
- Exit segfault: ordered Player.shutdown() (stop, clear source, detach
  buffer/audio outputs) from closeEvent/aboutToQuit; scripted run exits 0.
- BT zero-volume after pause/resume: volume re-applied on resume, device
  swap, and BufferedMedia (needs verify on the affected machine).

Cruft sweep:
- Tag writes filtered to EDITABLE_FIELDS; failures logged + surfaced in
  the status bar (was a swallowed print).
- O(n²) import fixed (location index + cached max track id); O(1)
  refresh/reveal via TrackTableModel row index.
- lastfm: scrobble-queue thread lock, login prefs write marshalled to the
  GUI thread, JSON/raise_for_status order fixed.
- Shared read_json/write_json; TableSettingsMixin dedupes view settings.

TASKS.md rewritten as a resumable board; tests in tests/test_round17.py
(251 total pass).
2026-07-02 11:36:11 -04:00

1005 lines
39 KiB
Python

import json
import os
from PyQt6.QtWidgets import (
QTableView, QAbstractItemView, QMenu, QApplication, QProxyStyle, QStyle,
QStyledItemDelegate, QStyleOptionViewItem,
)
from PyQt6.QtCore import (
Qt, QAbstractTableModel, QModelIndex, QPersistentModelIndex,
QSortFilterProxyModel, pyqtSignal,
QMimeData, QUrl, QPoint, QPointF, QRect, QRectF, QMetaType, QTimer,
)
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
# While dragging a track within this many pixels of the list's top/bottom edge,
# the list auto-scrolls so off-screen rows can be reached.
AUTOSCROLL_MARGIN = 28
class ClickToJumpScrollStyle(QProxyStyle):
"""Makes a left-click on the scrollbar trough jump the thumb straight to
that spot (absolute position) instead of paging toward it a step at a time.
Wraps the default app style and only overrides the one hint."""
def styleHint(self, hint, option=None, widget=None, returnData=None):
if hint == QStyle.StyleHint.SH_ScrollBar_LeftClickAbsolutePosition:
return 1
return super().styleHint(hint, option, widget, returnData)
def autoscroll_direction(y: int, height: int, accepted: bool,
margin: int = AUTOSCROLL_MARGIN) -> int:
"""-1 to scroll up, +1 down, 0 to stop: based on how close the drag is to
the top/bottom edge. Only scrolls over a valid drop target (accepted)."""
if not accepted:
return 0
if y < margin:
return -1
if y > height - margin:
return 1
return 0
# 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)
# ---- rating column interaction (hover dots + click to rate) ----
RATING_LEFT_PAD = 2 # px before the first of the five fixed rating slots
def rating_slot_width(fm: QFontMetrics) -> int:
"""Width of one rating slot, from the view's font so the painted glyphs
and the click hit-testing stay aligned at any UI scale."""
return max(fm.horizontalAdvance("★"), fm.horizontalAdvance("•")) + 4
def rating_slot_at(x: int, slot_w: int) -> int:
"""Which slot (1..5) an x offset within the rating cell lands on."""
if slot_w <= 0:
return 1
return max(1, min(5, (x - RATING_LEFT_PAD) // slot_w + 1))
def rating_from_click(current: int, slot: int) -> int:
"""New 0-100 rating for a click on slot 1..5. Clicking the slot equal to
the current star count clears the rating (iTunes behavior — and the only
way to un-rate); anything else sets slot*20."""
new = slot * 20
return 0 if new == current else new
class RatingDelegate(QStyledItemDelegate):
"""Paints the rating column as five fixed slots: ★ for each rated star
and, on the hovered cell, • for each empty slot — so the whole 1-5 range
is clickable at a glance. Installed table-wide; every other column is
painted by the default delegate."""
def __init__(self, view):
super().__init__(view)
self._view = view
def paint(self, painter, option, index):
model = self._view.model_
if model.fields[index.column()] != "rating":
super().paint(painter, option, index)
return
opt = QStyleOptionViewItem(option)
self.initStyleOption(opt, index)
opt.text = "" # base item = themed background/selection only
style = opt.widget.style() if opt.widget else QApplication.style()
style.drawControl(QStyle.ControlElement.CE_ItemViewItem, opt,
painter, opt.widget)
track = model.track_at(self._view.proxy.mapToSource(index).row())
stars = max(0, min(5, (track.rating or 0) // 20))
hovered = self._view.is_rating_hovered(index)
if not stars and not hovered:
return
if opt.state & QStyle.StateFlag.State_Selected:
base = opt.palette.highlightedText().color()
else:
base = opt.palette.text().color()
dot = QColor(base)
dot.setAlpha(110)
slot_w = rating_slot_width(QFontMetrics(opt.font))
painter.save()
painter.setFont(opt.font)
for slot in range(1, 6):
cell = QRect(opt.rect.left() + RATING_LEFT_PAD + (slot - 1) * slot_w,
opt.rect.top(), slot_w, opt.rect.height())
if slot <= stars:
painter.setPen(base)
painter.drawText(cell, Qt.AlignmentFlag.AlignCenter, "★")
elif hovered:
painter.setPen(dot)
painter.drawText(cell, Qt.AlignmentFlag.AlignCenter, "•")
painter.restore()
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) -> QMimeData:
mime = QMimeData()
payload = {"track_ids": track_ids, "source_playlist": source_playlist,
"rows": rows or []}
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._rows_by_id: dict[int, list[int]] = {}
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)
# A track can appear multiple times in a playlist, so map each id to
# all of its rows.
self._rows_by_id = {}
for row, track in enumerate(self._tracks):
self._rows_by_id.setdefault(track.track_id, []).append(row)
self.endResetModel()
def track_at(self, row: int) -> Track:
return self._tracks[row]
@property
def tracks(self) -> list[Track]:
return self._tracks
def row_for_id(self, track_id: int) -> int | None:
rows = self._rows_by_id.get(track_id)
return rows[0] if rows else None
def refresh_track(self, track_id: int):
for row in self._rows_by_id.get(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
cut_requested = pyqtSignal(list) # source rows to remove now
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
rating_edited = pyqtSignal(int, int) # track_id, new rating 0-100
download_art_requested = pyqtSignal(list) # selected track ids
def __init__(self, parent=None, playlist_mode=False):
super().__init__(parent)
self._playlist_mode = playlist_mode
self._source_playlist_id = ""
# False for smart playlists: blocks manual add/remove/reorder/cut/paste.
self._content_editable = True
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)
# Rating column: hover shows the five clickable slots, click rates.
self.setItemDelegate(RatingDelegate(self))
self.setMouseTracking(True)
self.viewport().setMouseTracking(True)
self._hover_rating_index: QPersistentModelIndex | None = None
# 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
# Edge auto-scroll during a drag (Qt's built-in autoscroll never runs
# because our dragMoveEvent accepts the event itself).
self._autoscroll_dir = 0
self._autoscroll_pos: QPoint | None = None
self._autoscroll_timer = QTimer(self)
self._autoscroll_timer.setInterval(40)
self._autoscroll_timer.timeout.connect(self._autoscroll_tick)
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 set_content_editable(self, editable: bool):
"""Smart playlists pass False: dropping, reordering, cut/paste and
remove are all disabled (membership is derived from criteria)."""
self._content_editable = editable
self.setAcceptDrops(editable)
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."""
src_row = self.model_.row_for_id(track_id)
if src_row is None:
return False
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
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):
ids = self.selected_track_ids()
if not ids:
return
QApplication.clipboard().setMimeData(make_tracks_mime(
ids, self._source_playlist_id, self.selected_source_rows()))
def cut_selection(self):
"""Copy the selection, then — in a playlist — remove it immediately so
it visibly leaves; a paste re-inserts it at the target. From the
library there's nothing to remove, so this is just a copy."""
ids = self.selected_track_ids()
if not ids:
return
rows = self.selected_source_rows()
QApplication.clipboard().setMimeData(
make_tracks_mime(ids, self._source_playlist_id, rows))
if self._playlist_mode and self._source_playlist_id and self._content_editable:
self.cut_requested.emit(rows)
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 self._content_editable
and parse_tracks_mime(QApplication.clipboard().mimeData())):
paste_action = menu.addAction("Paste\tCtrl+V")
locations = self.selected_locations()
reveal_action = copy_path_action = download_art_action = None
if locations:
menu.addSeparator()
reveal_action = menu.addAction("Reveal in File Browser")
copy_path_action = menu.addAction("Copy File Path")
download_art_action = menu.addAction("Download Album Art…")
# "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 and self._content_editable:
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 download_art_action is not None and chosen is download_art_action:
self.download_art_requested.emit(self.selected_track_ids())
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())
# ---- rating hover / click ----
def _is_rating_index(self, index) -> bool:
return (index.isValid()
and self.model_.fields[index.column()] == "rating")
def is_rating_hovered(self, index) -> bool:
hover = self._hover_rating_index
return (hover is not None and hover.isValid()
and hover.row() == index.row()
and hover.column() == index.column())
def _set_rating_hover(self, index):
"""Track the hovered rating cell, repainting the old and new cells."""
new = QPersistentModelIndex(index) if index is not None else None
old = self._hover_rating_index
if new == old:
return
self._hover_rating_index = new
for persistent in (old, new):
if persistent is not None and persistent.isValid():
self.viewport().update(self.visualRect(
self.proxy.index(persistent.row(), persistent.column())))
def mouseMoveEvent(self, event):
index = self.indexAt(event.position().toPoint())
self._set_rating_hover(index if self._is_rating_index(index) else None)
super().mouseMoveEvent(event)
def leaveEvent(self, event):
self._set_rating_hover(None)
super().leaveEvent(event)
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
pos = event.position().toPoint()
index = self.indexAt(pos)
if self._is_rating_index(index):
track = self.model_.track_at(self.proxy.mapToSource(index).row())
slot = rating_slot_at(
pos.x() - self.visualRect(index).left(),
rating_slot_width(QFontMetrics(self.font())))
self.rating_edited.emit(
track.track_id, rating_from_click(track.rating or 0, slot))
return # consume: a rating click never selects or starts a drag
super().mousePressEvent(event)
# ---- 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._content_editable
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):
if not self._content_editable: # smart playlist: no drops
event.ignore()
self._set_drop_indicator(None)
return
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
pos = event.position().toPoint()
if accepted:
event.acceptProposedAction()
self._set_drop_indicator(self._indicator_y(pos))
else:
event.ignore()
self._set_drop_indicator(None)
self._update_autoscroll(pos, accepted)
def dragLeaveEvent(self, event):
self._set_drop_indicator(None)
self._stop_autoscroll()
super().dragLeaveEvent(event)
def dropEvent(self, event):
if not self._content_editable: # smart playlist: no drops
return
self._set_drop_indicator(None)
self._stop_autoscroll()
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()
# ---- drag auto-scroll ----
def _update_autoscroll(self, pos: QPoint, accepted: bool):
"""Start/stop edge auto-scroll based on the drag's position."""
self._autoscroll_pos = pos
self._autoscroll_dir = autoscroll_direction(
pos.y(), self.viewport().height(), accepted)
if self._autoscroll_dir and not self._autoscroll_timer.isActive():
self._autoscroll_timer.start()
elif not self._autoscroll_dir and self._autoscroll_timer.isActive():
self._autoscroll_timer.stop()
def _autoscroll_tick(self):
bar = self.verticalScrollBar()
before = bar.value()
bar.setValue(before + self._autoscroll_dir)
if bar.value() == before: # reached the top/bottom — nothing more to do
self._autoscroll_timer.stop()
return
# The cursor may be held still while content scrolls under it, so
# refresh the drop line for the rows now beneath it.
if self._autoscroll_pos is not None:
self._set_drop_indicator(self._indicator_y(self._autoscroll_pos))
def _stop_autoscroll(self):
self._autoscroll_dir = 0
self._autoscroll_pos = None
self._autoscroll_timer.stop()
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)