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).
This commit is contained in:
2026-07-02 11:36:11 -04:00
parent 3f94fb9854
commit 5f917eb68c
18 changed files with 1203 additions and 197 deletions
+105
View File
@@ -0,0 +1,105 @@
"""Confirmation dialog for downloaded album art.
Shows one iTunes Search result at a time (300px preview + "Artist — Album"),
with Next Result cycling through the other matches. Nothing is written until
the user accepts; the caller then embeds ``selected_image``/``selected_mime``.
"""
from PyQt6.QtWidgets import (
QDialog, QDialogButtonBox, QLabel, QPushButton, QVBoxLayout,
)
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QPixmap
from lintunes.art_search import AlbumArtFetcher
PREVIEW_PX = 300
class AlbumArtDialog(QDialog):
def __init__(self, candidates, track_count: int, parent=None):
super().__init__(parent)
self.setWindowTitle("Download Album Art")
self._candidates = candidates
self._index = 0
self._fetcher = AlbumArtFetcher()
self._fetcher.image_finished.connect(self._on_image)
self.selected_image: bytes | None = None
self.selected_mime = "image/jpeg"
self._image: bytes | None = None
self._mime = "image/jpeg"
layout = QVBoxLayout(self)
self._preview = QLabel()
self._preview.setFixedSize(PREVIEW_PX, PREVIEW_PX)
self._preview.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._preview.setStyleSheet("border: 1px solid palette(mid);")
layout.addWidget(self._preview, alignment=Qt.AlignmentFlag.AlignHCenter)
self._title = QLabel()
self._title.setAlignment(Qt.AlignmentFlag.AlignHCenter)
self._title.setWordWrap(True)
layout.addWidget(self._title)
noun = "track" if track_count == 1 else "tracks"
layout.addWidget(QLabel(f"The artwork will be embedded in "
f"{track_count} {noun}."),
alignment=Qt.AlignmentFlag.AlignHCenter)
buttons = QDialogButtonBox()
self._use_btn = QPushButton("Use This Artwork")
self._use_btn.setDefault(True)
buttons.addButton(self._use_btn, QDialogButtonBox.ButtonRole.AcceptRole)
self._next_btn = QPushButton("Next Result")
self._next_btn.setEnabled(len(candidates) > 1)
buttons.addButton(self._next_btn, QDialogButtonBox.ButtonRole.ActionRole)
buttons.addButton(QDialogButtonBox.StandardButton.Cancel)
buttons.accepted.connect(self._accept_current)
buttons.rejected.connect(self.reject)
self._next_btn.clicked.connect(self._next_candidate)
layout.addWidget(buttons)
self._load_current()
# ---- candidate cycling ----
def _load_current(self):
candidate = self._candidates[self._index]
n = len(self._candidates)
counter = f" ({self._index + 1}/{n})" if n > 1 else ""
self._title.setText(
f"<b>{candidate.artist} — {candidate.album}</b>{counter}")
self._image = None
self._use_btn.setEnabled(False)
self._preview.setText("Loading…")
self._preview.setPixmap(QPixmap())
self._fetcher.fetch(candidate)
def _next_candidate(self):
self._index = (self._index + 1) % len(self._candidates)
self._load_current()
def _on_image(self, result: dict):
# Ignore a stale download finishing after Next Result moved on.
if result.get("candidate") is not self._candidates[self._index]:
if "candidate" in result:
return
if "error" in result:
self._preview.setText(f"Couldn't load image:\n{result['error']}")
return
pixmap = QPixmap()
if not pixmap.loadFromData(result["image"]):
self._preview.setText("Couldn't decode image")
return
self._image = result["image"]
self._mime = result["mime"]
self._preview.setPixmap(pixmap.scaled(
self._preview.size(), Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation))
self._use_btn.setEnabled(True)
def _accept_current(self):
if self._image is None:
return
self.selected_image = self._image
self.selected_mime = self._mime
self.accept()
+21 -23
View File
@@ -6,6 +6,7 @@ from PyQt6.QtWidgets import (
)
from PyQt6.QtCore import Qt, QTimer, pyqtSignal
from lintunes.gui.table_settings import TableSettingsMixin
from lintunes.gui.track_table import TrackTableView
@@ -86,7 +87,7 @@ class _BrowserList(QListWidget):
return item.text()
class LibraryView(QWidget):
class LibraryView(TableSettingsMixin, QWidget):
"""All-tracks view with the iTunes-style 3-column browser (Ctrl+B)."""
play_requested = pyqtSignal(list, int)
@@ -101,23 +102,24 @@ class LibraryView(QWidget):
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# Empty strip matching the sidebar's Library button / playlist name
# Header strip matching the sidebar's Library button / playlist name
# band, so the browser+tracklist top aligns with the playlist tree.
# It hosts the search box, which thereby sits directly right of the
# Library button across the splitter.
self._top_strip = QWidget()
layout.addWidget(self._top_strip)
# Search row: a debounced filter box occupying ~1/3 of the width, top
# Search: a debounced filter box occupying ~1/3 of the width, top
# right (iTunes-style). It narrows the track list and the browser
# columns together.
self._search_text = ""
self._search = QLineEdit()
self._search.setPlaceholderText("search")
self._search.setClearButtonEnabled(True)
search_row = QHBoxLayout()
search_row.setContentsMargins(4, 2, 4, 2)
search_row.addStretch(2)
search_row.addWidget(self._search, 1)
layout.addLayout(search_row)
strip_row = QHBoxLayout(self._top_strip)
strip_row.setContentsMargins(4, 2, 4, 2)
strip_row.addStretch(2)
strip_row.addWidget(self._search, 1)
self._search_timer = QTimer(self)
self._search_timer.setSingleShot(True)
@@ -152,9 +154,11 @@ class LibraryView(QWidget):
self.table.info_requested.connect(self.info_requested)
self.table.show_in_playlist_requested.connect(
self.show_in_playlist_requested)
self.table.sort_changed.connect(self._on_sort_changed)
self.table.columns_changed.connect(self._on_columns_changed)
self.table.column_width_changed.connect(self._on_width_changed)
self._wire_table_settings()
# Undoable; rating isn't a file tag, so this only touches the library.
self.table.rating_edited.connect(
lambda tid, rating: manager.edit_track_fields(
tid, {"rating": rating}))
manager.track_updated.connect(self.table.model_.refresh_track)
manager.track_fields_edited.connect(self._on_track_fields_edited)
@@ -165,25 +169,19 @@ class LibraryView(QWidget):
# Kept in sync with the sidebar Library button and the playlist view's
# name strip so the tracklist tops line up across views.
self._top_strip.setFixedHeight(height)
# Keep the search box inside the strip at small UI scales.
self._search.setMaximumHeight(max(16, height - 4))
# ---- settings persistence ----
# ---- settings persistence (TableSettingsMixin hooks) ----
@property
def _settings(self):
return self._manager.library.library_settings
def _on_sort_changed(self, field, ascending):
self._settings.sort_column = field
self._settings.sort_ascending = ascending
self._manager.mark_library_settings_dirty()
def _current_settings(self):
return self._settings
def _on_columns_changed(self, columns):
self._settings.visible_columns = columns
self.table.apply_settings(self._settings)
self._manager.mark_library_settings_dirty()
def _on_width_changed(self, field, width):
self._settings.column_widths[field] = width
def _mark_settings_dirty(self):
self._manager.mark_library_settings_dirty()
# ---- browser ----
+95 -1
View File
@@ -8,10 +8,12 @@ from PyQt6.QtWidgets import (
from PyQt6.QtCore import Qt, QEvent, QTimer
from PyQt6.QtGui import QAction, QKeySequence
from lintunes import theme
from lintunes import mpris, tagging, theme
from lintunes.art_search import AlbumArtFetcher
from lintunes.inhibit import SleepInhibitor
from lintunes.player import Player
from lintunes.importers import file_importer
from lintunes.gui.album_art_dialog import AlbumArtDialog
from lintunes.gui.sidebar import SidebarPanel
from lintunes.gui.library_view import LibraryView
from lintunes.gui.playlist_view import PlaylistView
@@ -86,6 +88,7 @@ class MainWindow(QMainWindow):
view.info_requested.connect(self._show_info)
view.show_in_playlist_requested.connect(self._show_track_in_playlist)
view.table.tracks_changed.connect(self._update_totals)
view.table.download_art_requested.connect(self._download_album_art)
self._playlist_view.files_dropped.connect(self._import_files_to_playlist)
self.player.error_occurred.connect(
lambda msg: QMessageBox.warning(self, "Can't play track", msg))
@@ -94,6 +97,9 @@ class MainWindow(QMainWindow):
self.player.playing_changed.connect(self._update_now_playing_icon)
self.player.playing_changed.connect(self._on_playing_changed)
manager.track_updated.connect(self._on_track_meta_updated)
manager.tag_write_failed.connect(
lambda name, err: self.statusBar().showMessage(
f"Couldn't write tags to “{name}”: {err}", 8000))
manager.library_reloaded.connect(self._on_library_reloaded)
manager.conflict_resolved.connect(self._on_conflict_resolved)
prefs.changed.connect(self._on_prefs_changed)
@@ -309,6 +315,93 @@ class MainWindow(QMainWindow):
def _import_files_to_playlist(self, paths, pid):
self.import_files([Path(p) for p in paths], pid)
# ---- album art download ----
def _download_album_art(self, track_ids: list[int]):
"""Fetch cover art from the iTunes Search API for the selection,
grouped per album, each behind its own confirmation dialog."""
groups: dict[tuple, dict] = {}
skipped = 0
for tid in track_ids:
track = self._manager.library.tracks.get(tid)
if track is None:
continue
artist = track.album_artist or track.artist
if not artist or not track.album:
skipped += 1
continue
key = (artist.casefold(), track.album.casefold())
group = groups.setdefault(
key, {"artist": artist, "album": track.album, "tracks": []})
group["tracks"].append(track)
if skipped:
self.statusBar().showMessage(
f"Skipped {skipped} track(s) with no artist/album to search by",
6000)
self._art_groups = list(groups.values())
self._art_next_group()
def _art_next_group(self):
if not self._art_groups:
return
group = self._art_groups.pop(0)
self.statusBar().showMessage(
f"Searching artwork for {group['artist']} — {group['album']}…")
# Kept as an attribute so the fetcher (and its thread's signal source)
# outlives this method.
self._art_fetcher = AlbumArtFetcher()
self._art_fetcher.search_finished.connect(
lambda result, g=group: self._on_art_search(g, result))
self._art_fetcher.search(group["artist"], group["album"])
def _on_art_search(self, group, result):
if "error" in result:
self.statusBar().showMessage(
f"Artwork search failed: {result['error']}", 6000)
elif not result["candidates"]:
self.statusBar().showMessage(
f"No artwork found for {group['artist']} — {group['album']}",
6000)
else:
self.statusBar().clearMessage()
dialog = AlbumArtDialog(result["candidates"],
len(group["tracks"]), self)
if dialog.exec() and dialog.selected_image:
self._embed_album_art(group["tracks"], dialog.selected_image,
dialog.selected_mime)
self._art_next_group()
def _embed_album_art(self, tracks, image: bytes, mime: str):
"""Embed confirmed artwork into each track's file. Like the Info
dialog, the artwork write itself is not undoable; the size refresh is
an in-memory update (size isn't a tag field)."""
failures = []
done = 0
for track in tracks:
if not track.location:
continue
try:
tagging.write_artwork(track.location, image, mime)
except Exception as e:
failures.append(f"{track.name}: {e}")
continue
done += 1
mpris.invalidate_artwork(track)
try:
size = Path(track.location).stat().st_size
except OSError:
continue
self._manager.update_track_fields(track.track_id, {"size": size})
if failures:
QMessageBox.warning(
self, "Download Album Art",
"Couldn't embed artwork in some files:\n\n"
+ "\n".join(failures[:10]))
elif done:
noun = "track" if done == 1 else "tracks"
self.statusBar().showMessage(
f"Artwork embedded in {done} {noun}", 5000)
# ---- relocating a moved/renamed file ----
def _existing_ancestor(self, path: str) -> str:
@@ -433,5 +526,6 @@ class MainWindow(QMainWindow):
def closeEvent(self, event):
self._inhibitor.release()
self.player.shutdown()
self._manager.flush()
super().closeEvent(event)
+11 -22
View File
@@ -1,11 +1,12 @@
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QApplication
from PyQt6.QtCore import pyqtSignal
from lintunes.gui.table_settings import TableSettingsMixin
from lintunes.gui.track_table import TrackTableView, parse_tracks_mime
from lintunes.gui.playlist_ops import add_tracks_with_dup_check
class PlaylistView(QWidget):
class PlaylistView(TableSettingsMixin, QWidget):
play_requested = pyqtSignal(list, int)
info_requested = pyqtSignal(list)
files_dropped = pyqtSignal(list, str) # paths, playlist pid
@@ -33,15 +34,17 @@ class PlaylistView(QWidget):
self.table.info_requested.connect(self.info_requested)
self.table.show_in_playlist_requested.connect(
self.show_in_playlist_requested)
self.table.sort_changed.connect(self._on_sort_changed)
self.table.columns_changed.connect(self._on_columns_changed)
self.table.column_width_changed.connect(self._on_width_changed)
self._wire_table_settings()
self.table.reorder_requested.connect(self._on_reorder)
self.table.tracks_dropped.connect(self._on_tracks_dropped)
self.table.files_dropped.connect(self._on_files_dropped)
self.table.remove_requested.connect(self._on_remove)
self.table.cut_requested.connect(self._on_cut)
self.table.paste_requested.connect(self._on_paste)
# Undoable; rating isn't a file tag, so this only touches the library.
self.table.rating_edited.connect(
lambda tid, rating: manager.edit_track_fields(
tid, {"rating": rating}))
manager.playlist_content_changed.connect(self._on_content_changed)
manager.track_updated.connect(self.table.model_.refresh_track)
@@ -93,26 +96,12 @@ class PlaylistView(QWidget):
else:
self._reload_tracks()
# ---- settings persistence ----
# ---- settings persistence (TableSettingsMixin hooks) ----
def _on_sort_changed(self, field, ascending):
if self._playlist is None:
return
self._playlist.settings.sort_column = field
self._playlist.settings.sort_ascending = ascending
self._manager.mark_playlist_settings_dirty(self._playlist.persistent_id)
def _current_settings(self):
return self._playlist.settings if self._playlist else None
def _on_columns_changed(self, columns):
if self._playlist is None:
return
self._playlist.settings.visible_columns = columns
self.table.apply_settings(self._playlist.settings)
self._manager.mark_playlist_settings_dirty(self._playlist.persistent_id)
def _on_width_changed(self, field, width):
if self._playlist is None:
return
self._playlist.settings.column_widths[field] = width
def _mark_settings_dirty(self):
self._manager.mark_playlist_settings_dirty(self._playlist.persistent_id)
# ---- content edits ----
+38
View File
@@ -0,0 +1,38 @@
"""Shared persistence of per-view table settings (sort, columns, widths).
LibraryView and PlaylistView both mirror TrackTableView's header signals into
a PlaylistSettings and mark it dirty; only where the settings live differs.
Subclasses implement ``_current_settings()`` (None when nothing is shown, e.g.
the playlist view before a playlist is opened) and ``_mark_settings_dirty()``.
"""
class TableSettingsMixin:
def _wire_table_settings(self):
"""Connect the table's header signals; call once self.table exists."""
self.table.sort_changed.connect(self._on_sort_changed)
self.table.columns_changed.connect(self._on_columns_changed)
self.table.column_width_changed.connect(self._on_width_changed)
def _on_sort_changed(self, field, ascending):
settings = self._current_settings()
if settings is None:
return
settings.sort_column = field
settings.sort_ascending = ascending
self._mark_settings_dirty()
def _on_columns_changed(self, columns):
settings = self._current_settings()
if settings is None:
return
settings.visible_columns = columns
self.table.apply_settings(settings)
self._mark_settings_dirty()
def _on_width_changed(self, field, width):
settings = self._current_settings()
if settings is None:
return
settings.column_widths[field] = width
self._mark_settings_dirty()
+161 -18
View File
@@ -3,10 +3,12 @@ import os
from PyQt6.QtWidgets import (
QTableView, QAbstractItemView, QMenu, QApplication, QProxyStyle, QStyle,
QStyledItemDelegate, QStyleOptionViewItem,
)
from PyQt6.QtCore import (
Qt, QAbstractTableModel, QModelIndex, QSortFilterProxyModel, pyqtSignal,
QMimeData, QUrl, QPoint, QPointF, QRectF, QMetaType, QTimer,
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,
@@ -117,6 +119,82 @@ def _format_rating(rating: int) -> str:
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 ""
@@ -216,6 +294,7 @@ 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
@@ -246,6 +325,11 @@ class TrackTableModel(QAbstractTableModel):
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:
@@ -255,11 +339,14 @@ class TrackTableModel(QAbstractTableModel):
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, track in enumerate(self._tracks):
if track.track_id == track_id:
self.dataChanged.emit(self.index(row, 0),
self.index(row, self.columnCount() - 1))
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
@@ -337,6 +424,8 @@ class TrackTableView(QTableView):
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)
@@ -377,6 +466,12 @@ class TrackTableView(QTableView):
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.
@@ -428,17 +523,15 @@ class TrackTableView(QTableView):
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
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)
@@ -584,11 +677,12 @@ class TrackTableView(QTableView):
paste_action = menu.addAction("Paste\tCtrl+V")
locations = self.selected_locations()
reveal_action = copy_path_action = None
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
@@ -622,11 +716,60 @@ class TrackTableView(QTableView):
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):