Files
lintunes/lintunes/gui/library_view.py
T
travandClaude Opus 5.5 43d7b91aa6 v0.31.3: header row moves up beside the seek bar; knolled top row
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>
2026-09-28 22:43:21 -07:00

454 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()