The Library button, the view's header (search box or playlist name) and the seek row now share one band under the transport boxes, instead of the seek row hanging alone with a header strip below it. The now-playing panel caps at 560px and the gaps between controls share the rest evenly; more room under the menu bar. Long playlist names shrink up to 2pt, then elide. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
454 lines
18 KiB
Python
454 lines
18 KiB
Python
import re
|
||
from collections import Counter
|
||
|
||
from PyQt6.QtWidgets import (
|
||
QWidget, QVBoxLayout, QHBoxLayout, QListWidget, QSplitter, QLineEdit,
|
||
QStyledItemDelegate, QStyleOptionViewItem, QStyle, QApplication,
|
||
)
|
||
from PyQt6.QtCore import Qt, QTimer, QRectF, QPersistentModelIndex, pyqtSignal
|
||
|
||
from lintunes.gui.table_settings import TableSettingsMixin
|
||
from lintunes.gui.track_table import TrackTableView
|
||
from lintunes.perf import timed
|
||
|
||
|
||
_ARTICLE_RE = re.compile(r"^the\s+", re.IGNORECASE)
|
||
|
||
# Pause this long after the last keystroke before re-filtering, so typing a
|
||
# query doesn't scan the whole library on every keypress.
|
||
_SEARCH_DEBOUNCE_MS = 250
|
||
|
||
# Every track field the search box matches against (substring, case-folded).
|
||
_SEARCH_FIELDS = ("name", "artist", "album_artist", "album", "genre",
|
||
"composer", "grouping", "comments", "year", "kind")
|
||
|
||
|
||
def _matches_search(track, tokens) -> bool:
|
||
"""True when every token appears (case-folded substring) in some searched
|
||
field of the track. An empty token list matches everything."""
|
||
haystack = " ".join(str(getattr(track, f) or "")
|
||
for f in _SEARCH_FIELDS).casefold()
|
||
return all(token.casefold() in haystack for token in tokens)
|
||
|
||
# Editing one of these reorganizes the genre/artist/album browser, so the
|
||
# lists must be repopulated and the table re-filtered (a plain row repaint
|
||
# would leave the renamed value at its old spot and filtering by the old name).
|
||
_BROWSER_FIELDS = {"genre", "artist", "album_artist", "album"}
|
||
|
||
|
||
def _artist_sort_key(name: str) -> str:
|
||
"""Sort artists ignoring a leading 'The' (and case): 'The Beatles' files
|
||
next to 'Beat Happening'."""
|
||
return _ARTICLE_RE.sub("", name).casefold()
|
||
|
||
|
||
def _canonical_values(values) -> list[str]:
|
||
"""Collapse case variants of the same string. For each case-insensitive
|
||
group, return the spelling used by the most tracks (ties: more capital
|
||
letters, then alphabetical)."""
|
||
groups: dict[str, Counter] = {}
|
||
for value in values:
|
||
groups.setdefault(value.casefold(), Counter())[value] += 1
|
||
result = []
|
||
for counter in groups.values():
|
||
best = sorted(
|
||
counter.items(),
|
||
key=lambda kv: (-kv[1], -sum(c.isupper() for c in kv[0]), kv[0]),
|
||
)[0][0]
|
||
result.append(best)
|
||
return result
|
||
|
||
|
||
class _CassetteListDelegate(QStyledItemDelegate):
|
||
"""Paints a cassette button at the left of every value row (friend mode's
|
||
artist and album columns); the "All" row gets none."""
|
||
|
||
def __init__(self, view):
|
||
super().__init__(view)
|
||
self._view = view
|
||
|
||
def paint(self, painter, option, index):
|
||
if index.row() == 0 or self._view.cassette_state is None:
|
||
super().paint(painter, option, index)
|
||
return
|
||
from lintunes.gui import cassette_art
|
||
side = option.rect.height()
|
||
opt = QStyleOptionViewItem(option)
|
||
self.initStyleOption(opt, index)
|
||
opt.rect = option.rect.adjusted(side + 2, 0, 0, 0)
|
||
style = opt.widget.style() if opt.widget else QApplication.style()
|
||
# Selection background across the whole row, text after the button.
|
||
full = QStyleOptionViewItem(option)
|
||
self.initStyleOption(full, index)
|
||
full.text = ""
|
||
style.drawControl(QStyle.ControlElement.CE_ItemViewItem, full,
|
||
painter, full.widget)
|
||
style.drawControl(QStyle.ControlElement.CE_ItemViewItem, opt,
|
||
painter, opt.widget)
|
||
state = self._view.cassette_state(index.data())
|
||
rect = cassette_art.button_rect(QRectF(option.rect.left(), option.rect.top(),
|
||
side, side))
|
||
cassette_art.paint_cassette_button(
|
||
painter, rect, state, self._view.is_cassette_hovered(index),
|
||
option.palette)
|
||
|
||
|
||
class _BrowserList(QListWidget):
|
||
"""One column of the genre/artist/album browser."""
|
||
|
||
# Friend mode: the value whose cassette button was clicked.
|
||
cassette_clicked = pyqtSignal(str)
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.setMinimumWidth(120)
|
||
self.setUniformItemSizes(True)
|
||
# Callable[[str], "off"|"on"|"partial"] while in friend mode.
|
||
self.cassette_state = None
|
||
self._hover: QPersistentModelIndex | None = None
|
||
|
||
def enable_cassette(self, state_for):
|
||
self.cassette_state = state_for
|
||
if state_for is not None:
|
||
self.setItemDelegate(_CassetteListDelegate(self))
|
||
self.setMouseTracking(True)
|
||
self.viewport().update()
|
||
|
||
def _button_hit(self, pos) -> bool:
|
||
index = self.indexAt(pos)
|
||
return (self.cassette_state is not None and index.isValid()
|
||
and index.row() > 0
|
||
and pos.x() - self.visualRect(index).left()
|
||
< self.visualRect(index).height() + 2)
|
||
|
||
def is_cassette_hovered(self, index) -> bool:
|
||
return (self._hover is not None and self._hover.isValid()
|
||
and self._hover.row() == index.row())
|
||
|
||
def mouseMoveEvent(self, event):
|
||
pos = event.position().toPoint()
|
||
new = QPersistentModelIndex(self.indexAt(pos)) if self._button_hit(pos) else None
|
||
if new != self._hover:
|
||
self._hover = new
|
||
self.viewport().update()
|
||
super().mouseMoveEvent(event)
|
||
|
||
def leaveEvent(self, event):
|
||
if self._hover is not None:
|
||
self._hover = None
|
||
self.viewport().update()
|
||
super().leaveEvent(event)
|
||
|
||
def mousePressEvent(self, event):
|
||
pos = event.position().toPoint()
|
||
if event.button() == Qt.MouseButton.LeftButton and self._button_hit(pos):
|
||
self.cassette_clicked.emit(self.indexAt(pos).data())
|
||
self.viewport().update()
|
||
return # the button, not a navigation
|
||
super().mousePressEvent(event)
|
||
|
||
def set_values(self, label: str, values: list[str]):
|
||
current = self.selected_value()
|
||
self.blockSignals(True)
|
||
self.clear()
|
||
self.addItem(f"All ({len(values)} {label})")
|
||
self.addItems(values)
|
||
# Keep the previous selection if it survived the refilter
|
||
row = 0
|
||
if current:
|
||
matches = self.findItems(current, Qt.MatchFlag.MatchExactly)
|
||
if matches:
|
||
row = self.row(matches[0])
|
||
self.setCurrentRow(row)
|
||
self.blockSignals(False)
|
||
|
||
def selected_value(self) -> str:
|
||
item = self.currentItem()
|
||
if item is None or self.row(item) == 0:
|
||
return ""
|
||
return item.text()
|
||
|
||
|
||
# The search box fills the header row's slot up to this width.
|
||
SEARCH_MAX_WIDTH = 320
|
||
|
||
|
||
class LibraryView(TableSettingsMixin, QWidget):
|
||
"""All-tracks view with the iTunes-style 3-column browser (Ctrl+B)."""
|
||
|
||
play_requested = pyqtSignal(list, int)
|
||
info_requested = pyqtSignal(list)
|
||
show_in_playlist_requested = pyqtSignal(int, str) # track_id, playlist pid
|
||
|
||
def __init__(self, manager, parent=None):
|
||
super().__init__(parent)
|
||
self._manager = manager
|
||
self._cassette = None # (state_of, toggle) in friend mode
|
||
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
layout.setSpacing(0)
|
||
|
||
# Header strip hosting the search box. Not in this view's layout:
|
||
# MainWindow shows the current view's ``header`` in the transport
|
||
# bar's header row, right of the Library button, where a playlist's
|
||
# name goes (TransportBar.set_view_header).
|
||
self._top_strip = QWidget(self)
|
||
self._top_strip.hide()
|
||
self.header = self._top_strip
|
||
self._top_strip.header_owner = self
|
||
|
||
# Search: a debounced filter box that narrows the track list and the
|
||
# browser columns together.
|
||
self._search_text = ""
|
||
self._search = QLineEdit()
|
||
self._search.setPlaceholderText("search")
|
||
self._search.setClearButtonEnabled(True)
|
||
self._search.setMaximumWidth(SEARCH_MAX_WIDTH)
|
||
strip_row = QHBoxLayout(self._top_strip)
|
||
strip_row.setContentsMargins(0, 2, 0, 2)
|
||
strip_row.addWidget(self._search, 1)
|
||
strip_row.addStretch(0)
|
||
|
||
self._search_timer = QTimer(self)
|
||
self._search_timer.setSingleShot(True)
|
||
self._search_timer.setInterval(_SEARCH_DEBOUNCE_MS)
|
||
self._search_timer.timeout.connect(self._apply_search)
|
||
self._search.textChanged.connect(lambda _t: self._search_timer.start())
|
||
|
||
self._browser = QSplitter(Qt.Orientation.Horizontal)
|
||
self._genre_list = _BrowserList()
|
||
self._artist_list = _BrowserList()
|
||
self._album_list = _BrowserList()
|
||
for lst in (self._genre_list, self._artist_list, self._album_list):
|
||
self._browser.addWidget(lst)
|
||
|
||
self.table = TrackTableView(playlist_mode=False)
|
||
|
||
# Vertical splitter so the browser/track-list boundary is draggable
|
||
self._vsplit = QSplitter(Qt.Orientation.Vertical)
|
||
self._vsplit.addWidget(self._browser)
|
||
self._vsplit.addWidget(self.table)
|
||
self._vsplit.setStretchFactor(1, 1)
|
||
self._vsplit.setCollapsible(1, False)
|
||
self._vsplit.setSizes([200, 600])
|
||
layout.addWidget(self._vsplit)
|
||
|
||
self._genre_list.currentItemChanged.connect(self._on_genre_changed)
|
||
self._artist_list.currentItemChanged.connect(self._on_artist_changed)
|
||
self._album_list.currentItemChanged.connect(self._on_album_changed)
|
||
|
||
self.table.playlists_for_track = manager.playlists_containing
|
||
self.table.play_requested.connect(self.play_requested)
|
||
self.table.info_requested.connect(self.info_requested)
|
||
self.table.show_in_playlist_requested.connect(
|
||
self.show_in_playlist_requested)
|
||
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)
|
||
# Coalesces browser rebuilds: a multi-track edit emits
|
||
# track_fields_edited once per track, and rebuilding the whole
|
||
# genre/artist/album cascade per signal is O(edited × library).
|
||
self._browser_rebuild_timer = QTimer(self)
|
||
self._browser_rebuild_timer.setSingleShot(True)
|
||
self._browser_rebuild_timer.setInterval(0)
|
||
self._browser_rebuild_timer.timeout.connect(self._rebuild_browser)
|
||
|
||
self.reload()
|
||
|
||
# ---- friend mode (cassette) ----
|
||
|
||
def enable_cassette(self, state_of, toggle):
|
||
"""Friend mode: cassette buttons on artists, albums and tracks.
|
||
``state_of(track ids)`` -> "off"|"on"|"partial"; ``toggle(track ids)``
|
||
turns them all on, or all off when they already are. Passing None
|
||
turns it off."""
|
||
self._cassette = (state_of, toggle) if state_of else None
|
||
if state_of is None:
|
||
self.table.cassette_hooks = None
|
||
for lst in (self._artist_list, self._album_list):
|
||
lst.enable_cassette(None)
|
||
return
|
||
self.table.cassette_hooks = (lambda tid: state_of([tid]),
|
||
lambda tid: self._toggle([tid]))
|
||
self._artist_list.enable_cassette(
|
||
lambda v: state_of(self.group_track_ids("artist", v)))
|
||
self._album_list.enable_cassette(
|
||
lambda v: state_of(self.group_track_ids("album", v)))
|
||
if not getattr(self, "_cassette_wired", False):
|
||
self._cassette_wired = True
|
||
self._artist_list.cassette_clicked.connect(
|
||
lambda v: self._toggle(self.group_track_ids("artist", v)))
|
||
self._album_list.cassette_clicked.connect(
|
||
lambda v: self._toggle(self.group_track_ids("album", v)))
|
||
|
||
def _toggle(self, ids):
|
||
if self._cassette is not None and ids:
|
||
self._cassette[1](ids)
|
||
self.refresh_cassette()
|
||
|
||
def refresh_cassette(self):
|
||
for widget in (self._artist_list, self._album_list, self.table):
|
||
widget.viewport().update()
|
||
|
||
def group_track_ids(self, kind: str, value: str) -> list[int]:
|
||
"""The tracks a browser row stands for, under the current filters —
|
||
what cassetting that artist or album asks for."""
|
||
value = value.casefold()
|
||
if kind == "artist":
|
||
return [t.track_id for t in self._filtered_tracks(up_to="genre")
|
||
if (t.album_artist or t.artist).casefold() == value]
|
||
return [t.track_id for t in self._filtered_tracks(up_to="artist")
|
||
if t.album.casefold() == value]
|
||
|
||
def set_header_height(self, height: int):
|
||
# 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 (TableSettingsMixin hooks) ----
|
||
|
||
@property
|
||
def _settings(self):
|
||
return self._manager.library.library_settings
|
||
|
||
def _current_settings(self):
|
||
return self._settings
|
||
|
||
def _mark_settings_dirty(self):
|
||
self._manager.mark_library_settings_dirty()
|
||
|
||
# ---- browser ----
|
||
|
||
def toggle_browser(self):
|
||
self._browser.setVisible(not self._browser.isVisible())
|
||
|
||
def reveal_track(self, track_id: int):
|
||
"""Scroll to and select a track. If a search or the browser filters
|
||
hide it, clear them to the full library first so the song is always
|
||
reachable."""
|
||
if self.table.reveal_track(track_id):
|
||
return
|
||
self._search.blockSignals(True)
|
||
self._search.clear()
|
||
self._search.blockSignals(False)
|
||
self._search_text = ""
|
||
self._search_tracks = self._all_tracks
|
||
for lst in (self._genre_list, self._artist_list, self._album_list):
|
||
lst.blockSignals(True)
|
||
lst.setCurrentRow(0) # "All"
|
||
lst.blockSignals(False)
|
||
self._populate_genres()
|
||
self._populate_artists()
|
||
self._populate_albums()
|
||
self._update_track_table()
|
||
self.table.reveal_track(track_id)
|
||
|
||
def reload(self):
|
||
"""Rebuild everything from the library (after import or track add)."""
|
||
self._all_tracks = list(self._manager.library.tracks.values())
|
||
self._search_tracks = self._compute_search_tracks()
|
||
self.table.apply_settings(self._settings)
|
||
self._populate_genres()
|
||
self._populate_artists()
|
||
self._populate_albums()
|
||
self._update_track_table()
|
||
|
||
# ---- search ----
|
||
|
||
def _compute_search_tracks(self) -> list:
|
||
"""The subset of the library matching the current search (all tokens
|
||
must appear, as case-folded substrings, in some searched field)."""
|
||
if not self._search_text:
|
||
return self._all_tracks
|
||
tokens = self._search_text.split()
|
||
return [t for t in self._all_tracks if _matches_search(t, tokens)]
|
||
|
||
def _apply_search(self):
|
||
self._search_text = self._search.text().strip().casefold()
|
||
self._search_tracks = self._compute_search_tracks()
|
||
# Rebuild the whole cascade so the genre/artist/album columns reflect
|
||
# only what the search matched.
|
||
self._populate_genres()
|
||
self._populate_artists()
|
||
self._populate_albums()
|
||
self._update_track_table()
|
||
|
||
def _filtered_tracks(self, up_to: str) -> list:
|
||
# Compare casefolded: the browser shows one canonical spelling per
|
||
# group, but tracks may be tagged with any case variant. The cascade
|
||
# starts from the search-matched set so the browser stays in sync.
|
||
tracks = self._search_tracks
|
||
genre = self._genre_list.selected_value().casefold()
|
||
if genre:
|
||
tracks = [t for t in tracks if t.genre.casefold() == genre]
|
||
if up_to in ("artist", "album"):
|
||
artist = self._artist_list.selected_value().casefold()
|
||
if artist:
|
||
tracks = [t for t in tracks
|
||
if (t.album_artist or t.artist).casefold() == artist]
|
||
if up_to == "album":
|
||
album = self._album_list.selected_value().casefold()
|
||
if album:
|
||
tracks = [t for t in tracks if t.album.casefold() == album]
|
||
return tracks
|
||
|
||
def _populate_genres(self):
|
||
genres = _canonical_values(t.genre for t in self._search_tracks if t.genre)
|
||
self._genre_list.set_values("Genres", sorted(genres, key=str.casefold))
|
||
|
||
def _populate_artists(self):
|
||
tracks = self._filtered_tracks(up_to="genre")
|
||
artists = _canonical_values((t.album_artist or t.artist) for t in tracks
|
||
if t.album_artist or t.artist)
|
||
self._artist_list.set_values("Artists",
|
||
sorted(artists, key=_artist_sort_key))
|
||
|
||
def _populate_albums(self):
|
||
tracks = self._filtered_tracks(up_to="artist")
|
||
albums = _canonical_values(t.album for t in tracks if t.album)
|
||
self._album_list.set_values("Albums", sorted(albums, key=str.casefold))
|
||
|
||
def _update_track_table(self):
|
||
self.table.set_tracks(self._filtered_tracks(up_to="album"))
|
||
|
||
def _on_genre_changed(self):
|
||
self._populate_artists()
|
||
self._populate_albums()
|
||
self._update_track_table()
|
||
|
||
def _on_artist_changed(self):
|
||
self._populate_albums()
|
||
self._update_track_table()
|
||
|
||
def _on_album_changed(self):
|
||
self._update_track_table()
|
||
|
||
def _on_track_fields_edited(self, track_id: int, changed_fields: list):
|
||
"""A Get-Info edit landed. If it touched a field the browser groups by,
|
||
rebuild the lists and re-filter so the renamed value sorts to its new
|
||
spot and clicking it still finds its tracks (the plain row repaint from
|
||
track_updated leaves the browser stale). Non-browser edits (name, bpm…)
|
||
are handled by the proxy's live re-sort, so they need nothing here.
|
||
|
||
The rebuild is deferred to a 0 ms single-shot timer so an N-track edit
|
||
(which fires this once per track) rebuilds the browser once, not N
|
||
times."""
|
||
if _BROWSER_FIELDS.intersection(changed_fields):
|
||
self._browser_rebuild_timer.start()
|
||
|
||
def _rebuild_browser(self):
|
||
with timed("library browser rebuild (%d tracks)",
|
||
len(self._all_tracks)):
|
||
self._populate_genres()
|
||
self._populate_artists()
|
||
self._populate_albums()
|
||
self._update_track_table()
|