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:
@@ -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()
|
||||
@@ -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 ----
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user