v0.29.0: Cassette friend mode — browse a friend's library, cassette songs

- A ▾ cut from the right end of the Library button (shown once there's a
  friend) opens a popup of friends. Choosing one enters friend mode: the
  button becomes "<Name>'s Library" in light purple, the sidebar lists
  their shared playlists with a UFO follow toggle on each, and the content
  area is their library — browse only, nothing plays or edits.
- A cassette column on the far left and cassette buttons on every artist
  and album: pressed grey = send me this, medium grey on hover to let go.
  An artist cassettes all its albums and tracks (a snapshot).
- Bottom bar: Hide tracks I have (exact matches: artist, album, title,
  length within 2 s); close matches show light grey. Size of what's
  cassetted against free space: orange when Syncthing would stall at its
  free-space floor, red when it won't fit.
- Save and Close writes requests.json; Cancel discards; the ▾ or quitting
  with changes asks Save / Don't Save / Cancel.
This commit is contained in:
2026-09-28 21:27:06 -07:00
parent 662217a7b7
commit 8d2af17c70
15 changed files with 1565 additions and 14 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.28.0"
__version__ = "0.29.0"
+97
View File
@@ -0,0 +1,97 @@
"""A friend's shared library, read from their folder, for browsing.
``load_friend_library`` turns their ``library.json`` / ``playlists.json``
(written by their ``share.py``) into ``Track`` objects and a simple playlist
list — **their** track ids, never mixed into mine. The files may be missing
(nothing synced yet), half-synced or garbled: every failure is an empty
library, never a crash.
``FriendSource`` is the small slice of ``LibraryManager`` that ``LibraryView``
reads, over that library and read-only: edits are dropped, settings aren't
saved anywhere, and there are no playlists of mine to "show in".
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from PyQt6.QtCore import QObject, pyqtSignal
from lintunes.cassette import publish, share
from lintunes.models import Track
from lintunes.models.library import Library
from lintunes.models.playlist import PlaylistSettings
CASSETTE_FIELD = "cassette"
FRIEND_COLUMNS = [CASSETTE_FIELD, "name", "total_time", "artist", "album",
"genre", "year"]
@dataclass
class FriendPlaylist:
pid: str
name: str
track_ids: list = field(default_factory=list)
smart: bool = False
@dataclass
class FriendLibrary:
name: str = ""
tracks: dict = field(default_factory=dict) # their id -> Track
playlists: list = field(default_factory=list) # FriendPlaylist, by name
def playlist(self, pid: str) -> FriendPlaylist | None:
return next((p for p in self.playlists if p.pid == pid), None)
def load_friend_library(in_dir: Path, fallback_name: str = "") -> FriendLibrary:
intro = publish.read_intro(in_dir)
result = FriendLibrary(name=str(intro.get("name") or fallback_name))
data = publish.read_json(in_dir / share.LIBRARY_FILE, {})
for entry in (data.get("tracks", []) if isinstance(data, dict) else []):
if not isinstance(entry, dict) or not isinstance(entry.get("id"), int):
continue
fields = {k: v for k, v in entry.items() if k in share.SHARED_FIELDS}
try:
track = Track.from_dict(fields)
except TypeError:
continue
track.track_id = entry["id"]
result.tracks[track.track_id] = track
data = publish.read_json(in_dir / share.PLAYLISTS_FILE, {})
for entry in (data.get("playlists", []) if isinstance(data, dict) else []):
if not isinstance(entry, dict) or not entry.get("id"):
continue
ids = [t for t in entry.get("track_ids", [])
if isinstance(t, int) and t in result.tracks]
result.playlists.append(FriendPlaylist(
pid=str(entry["id"]), name=str(entry.get("name") or "Untitled"),
track_ids=ids, smart=bool(entry.get("smart"))))
result.playlists.sort(key=lambda p: p.name.casefold())
return result
class FriendSource(QObject):
"""What LibraryView needs from a manager, over a friend's library."""
track_updated = pyqtSignal(int)
track_fields_edited = pyqtSignal(int, list)
def __init__(self, tracks: dict, parent=None):
super().__init__(parent)
self.library = Library(tracks=dict(tracks))
self.library.library_settings = PlaylistSettings(
visible_columns=list(FRIEND_COLUMNS), sort_column="artist")
def set_tracks(self, tracks: dict):
self.library.tracks = dict(tracks)
def playlists_containing(self, track_id):
return []
def edit_track_fields(self, *args, **kwargs):
pass # read-only
def mark_library_settings_dirty(self):
pass # column choices in friend mode live for the visit only
+95
View File
@@ -0,0 +1,95 @@
"""Do I already have this song? Best effort — the user is the final judge.
* **exact**: normalized artist, album and title all match and the durations
are within ~2 s. Friend mode can hide these ("Hide tracks I have").
* **fuzzy**: normalized artist and title match (a different album, a
different length). Shown light grey, never hidden: a live take or a
remaster is worth a second look, not a silent skip.
Normalizing folds case and accents, turns punctuation into spaces, drops a
featured-artist credit ("feat.", "ft.", "featuring", "with" in brackets) and a
leading "The", so "The Beatles — Hey Jude (Remastered)" and "beatles - hey
jude" land together for the fuzzy check. The artist key prefers album artist,
the same spelling the browser files a song under.
"""
from __future__ import annotations
import re
import unicodedata
EXACT = "exact"
FUZZY = "fuzzy"
DURATION_SLACK_MS = 2000
_FEAT_BRACKET = re.compile(
r"[\(\[]\s*(feat\.?|ft\.?|featuring|with)\s[^\)\]]*[\)\]]", re.IGNORECASE)
_FEAT_TAIL = re.compile(r"\s+(feat\.?|ft\.?|featuring)\s+.*$", re.IGNORECASE)
_EDITION = re.compile(
r"[\(\[][^\)\]]*\b(remaster(ed)?|deluxe|edition|version|mono|stereo|"
r"bonus track)\b[^\)\]]*[\)\]]", re.IGNORECASE)
_LEADING_THE = re.compile(r"^the\s+")
_PUNCT = re.compile(r"[^\w\s]", re.UNICODE)
_SPACE = re.compile(r"\s+")
def normalize(text: str) -> str:
text = text or ""
text = _FEAT_BRACKET.sub(" ", text)
text = _FEAT_TAIL.sub("", text)
text = _EDITION.sub(" ", text)
text = unicodedata.normalize("NFKD", text)
text = "".join(c for c in text if not unicodedata.combining(c))
text = text.casefold().replace("&", " and ")
text = _PUNCT.sub(" ", text)
text = _SPACE.sub(" ", text).strip()
return _LEADING_THE.sub("", text)
def _artist(track) -> str:
return getattr(track, "album_artist", "") or getattr(track, "artist", "")
def _keys(track) -> tuple[str, str, str]:
"""(artist, album, title), normalized. A track's own artist is tried too
(see LibraryIndex), since one side may have an album artist and the
other not."""
return (normalize(_artist(track)), normalize(getattr(track, "album", "")),
normalize(getattr(track, "name", "")))
class LibraryIndex:
"""My library, indexed once per friend-mode visit."""
def __init__(self, tracks):
self._exact: dict[tuple, list[int]] = {}
self._fuzzy: set[tuple] = set()
for track in tracks:
title = normalize(track.name)
album = normalize(track.album)
for artist in self._artists(track):
self._exact.setdefault((artist, album, title), []).append(
track.total_time or 0)
self._fuzzy.add((artist, title))
@staticmethod
def _artists(track) -> set[str]:
names = {normalize(track.artist), normalize(track.album_artist)}
names.discard("")
return names or {""}
def classify(self, track) -> str | None:
"""EXACT, FUZZY or None for a friend's track."""
title = normalize(track.name)
if not title:
return None
album = normalize(track.album)
duration = track.total_time or 0
fuzzy = False
for artist in self._artists(track):
for mine in self._exact.get((artist, album, title), ()):
if not duration or not mine or \
abs(mine - duration) <= DURATION_SLACK_MS:
return EXACT
if (artist, title) in self._fuzzy:
fuzzy = True
return FUZZY if fuzzy else None
+28
View File
@@ -0,0 +1,28 @@
"""``requests.json``: the friend's track ids I want sent, in my folder to them.
Rewritten only on Save and Close in friend mode and when pruning (a delivery
arrived, or they stopped offering something) — never as you click.
"""
from __future__ import annotations
from pathlib import Path
from lintunes.cassette import publish
REQUESTS_FILE = "requests.json"
def write_requests(out: Path, track_ids) -> bool:
publish.prepare_out_dir(out)
return publish.write_json_if_changed(out / REQUESTS_FILE, {
"format": publish.FORMAT_VERSION,
"tracks": sorted({int(t) for t in track_ids}),
})
def read_requests(folder: Path) -> set[int]:
"""The ids a requests.json asks for; empty when missing or garbled."""
data = publish.read_json(folder / REQUESTS_FILE, {})
if not isinstance(data, dict):
return set()
return {t for t in data.get("tracks", []) if isinstance(t, int)}
+17
View File
@@ -237,6 +237,23 @@ class CassetteService(QObject):
changed.append(token)
return changed
# ---- requests ----
def save_requests(self, token: str, cassetted, followed, requested):
"""Friend mode's Save and Close: remember what I cassetted and which
playlists I follow, and tell the friend (``requests.json`` in my
folder to them) which of their track ids to send."""
from lintunes.cassette import requests as req
with self._lock:
friend = self._state.friends.get(token)
if friend is None:
return
friend.cassetted = sorted(int(t) for t in cassetted)
friend.followed = sorted(followed)
self._save()
req.write_requests(out_dir(self.root, token), requested)
self.changed.emit()
# ---- user actions ----
def create_invite(self) -> str:
+49
View File
@@ -0,0 +1,49 @@
"""Will the songs I cassetted fit?
Two disks matter, and they may be the same one:
* the **music** disk, where imported songs end up — if they don't fit there,
they don't fit (**full**, red);
* the disk holding the cassette folders, where Syncthing lands each delivery
first — Syncthing stops syncing a folder once its disk drops below the
folder's ``minDiskFree`` (1 % by default), so getting close to that is
**tight** (orange): the songs would fit, but syncing would stall.
"""
from __future__ import annotations
import shutil
from pathlib import Path
OK = "ok"
TIGHT = "tight"
FULL = "full"
def verdict(need: int, music_free: int, cassette_free: int,
floor_bytes: int) -> str:
if need > music_free:
return FULL
if cassette_free - need < floor_bytes:
return TIGHT
return OK
def disk_usage(path: Path):
"""shutil.disk_usage of the nearest existing ancestor, or None."""
path = Path(path)
for candidate in (path, *path.parents):
if candidate.exists():
try:
return shutil.disk_usage(candidate)
except OSError:
return None
return None
def human(size: int) -> str:
size = float(max(0, size))
for unit in ("B", "KB", "MB", "GB", "TB"):
if size < 1000 or unit == "TB":
return f"{size:.0f} {unit}" if unit in ("B", "KB") else f"{size:.1f} {unit}"
size /= 1000
return f"{size:.1f} TB"
+144
View File
@@ -0,0 +1,144 @@
"""Cassette's two little pictures, painted (no image files to ship or theme).
* The **cassette button**: a small embossed square with a tape glyph. Raised
when off; pressed in and grey when on ("I want this"); a medium grey on
hover over a pressed one, to say a click would let it go. Functionally
it's a checkbox — the tape is reserved for asking friends for songs.
* The **UFO**: a friend's playlist. Light purple when followed, a grey
outline when not.
Plus friend mode's light purple, shared by everything that says "you're in
someone else's library".
"""
from __future__ import annotations
from PyQt6.QtCore import QPointF, QRectF, Qt
from PyQt6.QtGui import (
QBrush, QColor, QIcon, QLinearGradient, QPainter, QPainterPath, QPen,
QPixmap,
)
FRIEND_PURPLE = QColor("#cdb4f2") # button tint in friend mode
FRIEND_PURPLE_DARK = QColor("#9b76d6") # its border / pressed shade
UFO_PURPLE = QColor("#b38ee8")
OFF, ON, PARTIAL = "off", "on", "partial"
ON_GREY = QColor("#9c9c9c")
ON_HOVER_GREY = QColor("#bdbdbd")
def friend_button_qss(selector: str = "QPushButton", padding: str = "3px 12px") -> str:
"""Light purple, embossed like every other button."""
light = FRIEND_PURPLE.lighter(106).name()
dark = FRIEND_PURPLE.darker(104).name()
return (f"{selector} {{ background: qlineargradient(x1:0, y1:0, x2:0, y2:1,"
f" stop:0 {light}, stop:0.5 {FRIEND_PURPLE.name()}, stop:1 {dark});"
f" color: black; border: 1px solid {FRIEND_PURPLE_DARK.name()};"
f" border-radius: 4px; padding: {padding}; min-height: 0; }}"
f"{selector}:pressed {{ background: {FRIEND_PURPLE_DARK.name()}; }}")
def button_rect(cell: QRectF, margin: float = 2.0) -> QRectF:
"""The square the button occupies inside a cell (left-aligned)."""
side = max(8.0, min(cell.height(), cell.width()) - 2 * margin)
return QRectF(cell.left() + margin, cell.top() + (cell.height() - side) / 2,
side, side)
def paint_cassette_button(painter: QPainter, rect: QRectF, state: str,
hovered: bool, palette) -> None:
painter.save()
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
base = palette.button().color()
mid = palette.mid().color()
box = QRectF(rect).adjusted(0.5, 0.5, -0.5, -0.5)
radius = max(2.0, box.width() * 0.18)
gradient = QLinearGradient(box.topLeft(), box.bottomLeft())
if state == OFF:
top = base.lighter(112 if hovered else 106)
gradient.setColorAt(0, top)
gradient.setColorAt(1, base.darker(106))
else:
fill = ON_HOVER_GREY if hovered else ON_GREY
if state == PARTIAL:
fill = QColor(fill).lighter(118)
gradient.setColorAt(0, fill.darker(108)) # pressed in: lit from below
gradient.setColorAt(1, fill.lighter(106))
painter.setBrush(QBrush(gradient))
painter.setPen(QPen(mid, 1))
painter.drawRoundedRect(box, radius, radius)
ink = QColor(palette.text().color())
if state != OFF:
ink = QColor("#303030")
ink.setAlpha(200 if state != OFF else 150)
_paint_tape(painter, box, ink, half=(state == PARTIAL))
painter.restore()
def _paint_tape(painter: QPainter, box: QRectF, ink: QColor, half=False):
"""A cassette: body, label window, two reels."""
w, h = box.width(), box.height()
body = QRectF(box.left() + w * 0.16, box.top() + h * 0.28,
w * 0.68, h * 0.46)
pen = QPen(ink, max(1.0, w / 16))
painter.setPen(pen)
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRoundedRect(body, body.height() * 0.15, body.height() * 0.15)
reel_r = body.height() * 0.17
cy = body.top() + body.height() * 0.45
for fx in (0.3, 0.7):
painter.drawEllipse(QPointF(body.left() + body.width() * fx, cy),
reel_r, reel_r)
# the bottom trapezoid where the tape shows
path = QPainterPath()
path.moveTo(body.left() + body.width() * 0.22, body.bottom())
path.lineTo(body.left() + body.width() * 0.3, body.bottom() - body.height() * 0.22)
path.lineTo(body.left() + body.width() * 0.7, body.bottom() - body.height() * 0.22)
path.lineTo(body.left() + body.width() * 0.78, body.bottom())
painter.drawPath(path)
if half:
painter.drawLine(QPointF(body.left() + body.width() * 0.4, cy),
QPointF(body.left() + body.width() * 0.6, cy))
def paint_ufo(painter: QPainter, rect: QRectF, color: QColor,
filled: bool = True) -> None:
painter.save()
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
w, h = rect.width(), rect.height()
pen = QPen(color.darker(135) if filled else color, max(1.0, w / 14))
painter.setPen(pen)
dome = QRectF(rect.left() + w * 0.32, rect.top() + h * 0.2,
w * 0.36, h * 0.4)
saucer = QRectF(rect.left() + w * 0.06, rect.top() + h * 0.38,
w * 0.88, h * 0.3)
if filled:
painter.setBrush(color.lighter(125))
else:
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawChord(dome, 0, 180 * 16)
painter.setBrush(color if filled else Qt.BrushStyle.NoBrush)
painter.drawEllipse(saucer)
# three little lights
painter.setBrush(color.lighter(160) if filled else Qt.BrushStyle.NoBrush)
r = w * 0.045
for fx in (0.3, 0.5, 0.7):
painter.drawEllipse(QPointF(rect.left() + w * fx,
saucer.center().y() + saucer.height() * 0.1),
r, r)
painter.restore()
def ufo_icon(size: int = 32, followed: bool = True,
color: QColor | None = None) -> QIcon:
pixmap = QPixmap(size, size)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
paint_ufo(painter, QRectF(1, 1, size - 2, size - 2),
color or (UFO_PURPLE if followed else QColor("#8a8a8a")),
filled=followed)
painter.end()
return QIcon(pixmap)
+62 -1
View File
@@ -9,7 +9,7 @@ from __future__ import annotations
from PyQt6.QtCore import QObject, QTimer
from PyQt6.QtGui import QAction
from PyQt6.QtWidgets import QDialog, QMessageBox
from PyQt6.QtWidgets import QDialog, QMenu, QMessageBox
from lintunes.cassette import host
from lintunes.cassette import syncthing_api as st
@@ -36,6 +36,7 @@ class CassetteUi(QObject):
self._actions: list[QAction] = []
self._service_factory = service_factory
self.service = None
self.friend_mode = None # FriendMode while browsing a friend
# ---- the service (host machine only) ----
@@ -51,6 +52,7 @@ class CassetteUi(QObject):
else:
from lintunes.cassette.service import CassetteService
self.service = CassetteService(parent=self)
self.service.changed.connect(self._refresh_selector)
self.service.notice.connect(self._on_notice)
self.service.step_failed.connect(self._on_step_failed)
self.service.friends_added.connect(self._republish_timer.start)
@@ -84,6 +86,65 @@ class CassetteUi(QObject):
self._on_step_failed("Updating what your friends can see",
str(exc))
# ---- the ▾ library selector / friend mode ----
def _refresh_selector(self):
"""The ▾ shows once there's a friend to choose."""
has_friends = self.service is not None and bool(self.service.state().friends)
sidebar = getattr(self._window, "sidebar", None)
if sidebar is not None and sidebar.friend_button.isHidden() == has_friends:
sidebar.set_friend_selector_visible(has_friends)
def show_friend_menu(self):
"""The ▾ was clicked. With unsaved friend-mode changes, settle those
first; then offer my library and each friend by display name."""
if self.service is None:
return
if self.friend_mode is not None and self.friend_mode.is_dirty():
if not self.friend_mode.confirm_leave():
return
sidebar = self._window.sidebar
menu = QMenu(self._window)
mine = menu.addAction("My Library")
mine.setEnabled(self.friend_mode is not None and self.friend_mode.active)
menu.addSeparator()
friends = sorted(self.service.state().friends.values(),
key=lambda f: f.name.casefold())
by_action = {}
for friend in friends:
action = menu.addAction(friend.name)
by_action[action] = friend.token
button = sidebar.friend_button
chosen = menu.exec(button.mapToGlobal(button.rect().bottomLeft()))
if chosen is None:
return
if chosen is mine:
self.leave_friend_mode()
elif chosen in by_action:
self.enter_friend_mode(by_action[chosen])
def enter_friend_mode(self, token: str):
from lintunes.gui.friend_mode import FriendMode
if self.friend_mode is not None and self.friend_mode.active:
if not self.friend_mode.confirm_leave():
return
music_dir = None
if self._manager is not None:
music_dir = self._manager.organize_root()
self.friend_mode = FriendMode(
self._window, self.service, self._manager.library,
music_dir or self.service.root)
self.friend_mode.enter(token)
def leave_friend_mode(self):
if self.friend_mode is not None and self.friend_mode.active:
self.friend_mode.confirm_leave()
def confirm_quit(self) -> bool:
if self.friend_mode is None or not self.friend_mode.active:
return True
return self.friend_mode.confirm_leave()
def shutdown(self):
if self.service is not None:
self.service.stop()
+358
View File
@@ -0,0 +1,358 @@
"""Friend mode: browsing a friend's shared library and cassetting songs.
Entered from the ▾ beside Library. The Library button becomes "<Name>'s
Library" in light purple, the sidebar lists their shared playlists (a UFO on
each is the follow toggle), and the content area shows their library as if
it were LinTunes on their machine — except **nothing plays** (the audio isn't
here) and nothing edits. A cassette column on the far left, and cassette
buttons on every artist and album, say "send me this".
Nothing is saved as you go. **Save and Close** writes my requests file and
returns to my library; **Cancel** drops the changes. Leaving any other way
with unsaved changes (the ▾ again, quitting) asks "Save changes?".
The bottom bar carries the controls for the visit: **Hide tracks I have**
(exact matches disappear; fuzzy ones are always shown, in light grey), how
much the cassetted songs weigh against free space, and the two purple
buttons.
"""
from __future__ import annotations
from PyQt6.QtCore import QObject, Qt, pyqtSignal
from PyQt6.QtGui import QColor
from PyQt6.QtWidgets import (
QCheckBox, QHBoxLayout, QLabel, QMessageBox, QPushButton, QVBoxLayout,
QWidget,
)
from lintunes.cassette import matching, space
from lintunes.cassette import syncthing_api as st
from lintunes.cassette.friend_library import (
FRIEND_COLUMNS, FriendSource, load_friend_library,
)
from lintunes.cassette.state import in_dir
from lintunes.gui import cassette_art
from lintunes.gui.cassette_art import OFF, ON, PARTIAL
from lintunes.models.playlist import PlaylistSettings
FUZZY_GREY = QColor("#9a9a9a")
TIGHT_ORANGE = "#d9822b"
FULL_RED = "#d0342c"
SAVE = "save"
DISCARD = "discard"
STAY = "stay"
def ask_save_changes(parent) -> str:
"""Plain words, three answers."""
box = QMessageBox(parent)
box.setWindowTitle("Save changes?")
box.setText("Save changes?")
box.setInformativeText("You changed which songs and playlists you want "
"from your friend.")
save = box.addButton("Save", QMessageBox.ButtonRole.AcceptRole)
discard = box.addButton("Don't Save", QMessageBox.ButtonRole.DestructiveRole)
box.addButton(QMessageBox.StandardButton.Cancel)
box.setDefaultButton(save)
box.exec()
clicked = box.clickedButton()
if clicked is save:
return SAVE
if clicked is discard:
return DISCARD
return STAY
class FriendPlaylistPane(QWidget):
"""One of the friend's playlists, in its own order, read-only."""
def __init__(self, parent=None):
super().__init__(parent)
from lintunes.gui.track_table import TrackTableView
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self.title = QLabel()
font = self.title.font()
font.setBold(True)
self.title.setFont(font)
self.title.setContentsMargins(8, 2, 8, 2)
layout.addWidget(self.title)
self.table = TrackTableView(playlist_mode=True)
self.table.set_read_only(True)
self.table.set_content_editable(False)
self.table.apply_settings(PlaylistSettings(
visible_columns=list(FRIEND_COLUMNS), sort_column="#"))
layout.addWidget(self.table)
def show_playlist(self, name: str, tracks: list):
self.title.setText(name)
self.table.set_tracks(tracks)
class FriendMode(QObject):
"""One visit to one friend's library."""
left = pyqtSignal() # back in my own library
def __init__(self, window, service, my_library, music_dir, parent=None):
super().__init__(parent or window)
self._window = window
self._service = service
self._my_library = my_library
self._music_dir = music_dir
self.token: str | None = None
self._widgets: list[QWidget] = []
self._view = None
self._pane = None
self._floor: tuple[float, str] | None = None
@property
def active(self) -> bool:
return self.token is not None
# ---- entering / leaving ----
def enter(self, token: str):
from lintunes.gui.library_view import LibraryView
state = self._service.state()
friend = state.friends[token]
self.token = token
self._friend_name = friend.name
self.library = load_friend_library(in_dir(self._service.root, token),
friend.name)
self.cassetted = {t for t in friend.cassetted if t in self.library.tracks}
self.followed = {p for p in friend.followed
if self.library.playlist(p) is not None}
self._saved = (set(self.cassetted), set(self.followed))
index = matching.LibraryIndex(self._my_library.tracks.values())
self.matches = {tid: index.classify(track)
for tid, track in self.library.tracks.items()}
self._min_disk_free(friend.in_folder)
self._source = FriendSource(self.library.tracks, self)
self._view = LibraryView(self._source)
self._view.table.set_read_only(True)
self._view.enable_cassette(self.state_of, self.toggle)
self._view.table.model_.foreground_for = self._foreground
self._pane = FriendPlaylistPane()
self._pane.table.cassette_hooks = (lambda tid: self.state_of([tid]),
lambda tid: self._toggle_in_pane(tid))
self._pane.table.model_.foreground_for = self._foreground
content = self._window.content_stack
content.addWidget(self._view)
content.addWidget(self._pane)
content.setCurrentWidget(self._view)
sidebar = self._window.sidebar
sidebar.show_friend(f"{self.library.name}'s Library",
self.library.playlists, self.followed)
sidebar.friend_library_selected.connect(self._show_library)
sidebar.friend_playlist_selected.connect(self._show_playlist)
sidebar.follow_toggled.connect(self._toggle_follow)
self._build_bar()
self._refresh_bar()
def _leave(self):
sidebar = self._window.sidebar
for signal, slot in ((sidebar.friend_library_selected, self._show_library),
(sidebar.friend_playlist_selected, self._show_playlist),
(sidebar.follow_toggled, self._toggle_follow)):
try:
signal.disconnect(slot)
except TypeError:
pass
sidebar.show_mine()
bar = self._window.statusBar()
for widget in self._widgets:
bar.removeWidget(widget)
widget.deleteLater()
self._widgets = []
content = self._window.content_stack
for widget in (self._view, self._pane):
if widget is not None:
content.removeWidget(widget)
widget.deleteLater()
self._view = self._pane = None
self.token = None
self._window.show_my_library()
self.left.emit()
# ---- the bottom bar ----
def _build_bar(self):
bar = self._window.statusBar()
self.hide_have = QCheckBox("Hide tracks I have")
self.hide_have.setToolTip(
"Hide songs that match one of yours exactly (artist, album, title "
"and length). Close matches stay, in grey.")
self.hide_have.toggled.connect(self._apply_hide)
self.size_label = QLabel()
self.save_button = QPushButton("Save and Close")
self.cancel_button = QPushButton("Cancel")
for button in (self.save_button, self.cancel_button):
# Compact: they live in the status bar.
button.setStyleSheet(cassette_art.friend_button_qss(padding="1px 10px"))
self.save_button.clicked.connect(self.save_and_close)
self.cancel_button.clicked.connect(self.cancel)
holder = QWidget()
row = QHBoxLayout(holder)
row.setContentsMargins(0, 0, 4, 0)
row.setSpacing(8)
for widget in (self.hide_have, self.size_label, self.save_button,
self.cancel_button):
row.addWidget(widget)
bar.addPermanentWidget(holder)
self._widgets = [holder]
def _min_disk_free(self, folder_id: str):
try:
client = self._service.client()
self._floor = client.min_disk_free(folder_id)
except st.SyncthingError:
self._floor = (1.0, "%") # Syncthing's default
def space_state(self) -> tuple[str, int, int]:
"""(verdict, bytes needed, bytes free on the music disk)."""
need = sum(self.library.tracks[t].size for t in self.wanted_ids()
if t in self.library.tracks)
music = space.disk_usage(self._music_dir)
cassette = space.disk_usage(self._service.root)
music_free = music.free if music else 0
if cassette is None:
return space.OK, need, music_free
value, unit = self._floor or (1.0, "%")
floor = st.disk_floor_bytes(value, unit, cassette.total)
return (space.verdict(need, music_free, cassette.free, floor),
need, music_free)
def _refresh_bar(self):
verdict, need, free = self.space_state()
text = f"Cassetted {space.human(need)} · {space.human(free)} free"
color = {space.TIGHT: TIGHT_ORANGE, space.FULL: FULL_RED}.get(verdict)
self.size_label.setText(text)
self.size_label.setStyleSheet(f"color: {color}; font-weight: bold;"
if color else "")
self.size_label.setToolTip({
space.TIGHT: "These would fit, but they'd leave so little room "
"that Syncthing stops syncing.",
space.FULL: "There isn't room for these songs.",
}.get(verdict, ""))
# ---- views ----
def _show_library(self):
self._window.content_stack.setCurrentWidget(self._view)
def _show_playlist(self, pid: str):
playlist = self.library.playlist(pid)
if playlist is None:
return
tracks = [self.library.tracks[t] for t in playlist.track_ids
if t in self.library.tracks
and not self._hidden(t)]
self._pane.show_playlist(playlist.name, tracks)
self._window.content_stack.setCurrentWidget(self._pane)
def _hidden(self, tid: int) -> bool:
return self.hide_have.isChecked() and self.matches.get(tid) == matching.EXACT
def _apply_hide(self, on: bool):
tracks = {tid: t for tid, t in self.library.tracks.items()
if not self._hidden(tid)}
self._source.set_tracks(tracks)
self._view.reload()
current = self._window.sidebar.friend_list.currentItem()
if current is not None and self._window.content_stack.currentWidget() is self._pane:
self._show_playlist(current.data(Qt.ItemDataRole.UserRole))
def _foreground(self, track):
if self.matches.get(track.track_id) == matching.FUZZY:
return FUZZY_GREY
return None
# ---- cassetting ----
def state_of(self, ids) -> str:
if not ids:
return OFF
hits = sum(1 for t in ids if t in self.cassetted)
if hits == 0:
return OFF
return ON if hits == len(ids) else PARTIAL
def toggle(self, ids):
"""All on → all off; otherwise all on (so a partly-cassetted artist
becomes fully cassetted — the snapshot of what's there now)."""
ids = [t for t in ids if t in self.library.tracks]
if ids and all(t in self.cassetted for t in ids):
self.cassetted.difference_update(ids)
else:
self.cassetted.update(ids)
self._refresh_bar()
def _toggle_in_pane(self, tid: int):
self.toggle([tid])
if self._view is not None:
self._view.refresh_cassette()
def _toggle_follow(self, pid: str):
on = pid not in self.followed
(self.followed.add if on else self.followed.discard)(pid)
self._window.sidebar.friend_list.set_followed(pid, on)
self._refresh_bar()
def wanted_ids(self) -> set[int]:
"""What to ask for: cassetted songs plus every song of a followed
playlist."""
wanted = set(self.cassetted)
for pid in self.followed:
playlist = self.library.playlist(pid)
if playlist is not None:
wanted.update(playlist.track_ids)
return wanted
# ---- saving ----
def is_dirty(self) -> bool:
return self.active and (self.cassetted, self.followed) != self._saved
def save_and_close(self) -> bool:
verdict, need, free = self.space_state()
if verdict != space.OK:
if verdict == space.FULL:
text = (f"These songs need {space.human(need)}, but only "
f"{space.human(free)} is free where your music is.")
else:
text = ("These songs would leave so little free space that "
"Syncthing stops syncing until you make room.")
answer = QMessageBox.warning(
self._window, "Not Enough Space", text + "\n\nSave anyway?",
QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Cancel)
if answer != QMessageBox.StandardButton.Save:
return False
self._service.save_requests(self.token, self.cassetted, self.followed,
self.wanted_ids())
self._leave()
return True
def cancel(self):
self._leave()
def confirm_leave(self) -> bool:
"""Before leaving by any other door: True when it's fine to go (saved,
discarded, or nothing to save); False to stay."""
if not self.active:
return True
if not self.is_dirty():
self._leave()
return True
answer = ask_save_changes(self._window)
if answer == SAVE:
return self.save_and_close()
if answer == DISCARD:
self._leave()
return True
return False
+128 -1
View File
@@ -3,8 +3,9 @@ from collections import Counter
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QListWidget, QSplitter, QLineEdit,
QStyledItemDelegate, QStyleOptionViewItem, QStyle, QApplication,
)
from PyQt6.QtCore import Qt, QTimer, pyqtSignal
from PyQt6.QtCore import Qt, QTimer, QRectF, QPersistentModelIndex, pyqtSignal
from lintunes.gui.table_settings import TableSettingsMixin
from lintunes.gui.track_table import TrackTableView
@@ -58,13 +59,93 @@ def _canonical_values(values) -> list[str]:
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()
@@ -98,6 +179,7 @@ class LibraryView(TableSettingsMixin, QWidget):
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)
@@ -173,6 +255,51 @@ class LibraryView(TableSettingsMixin, QWidget):
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.
+19
View File
@@ -209,6 +209,8 @@ class MainWindow(QMainWindow):
# Wiring
self._transport.play_clicked.connect(self.play_pause)
self._sidebar.library_selected.connect(self._show_library)
self._sidebar.friend_selector_clicked.connect(
self._cassette.show_friend_menu)
self._sidebar.playlist_selected.connect(self._show_playlist)
self._sidebar.tree.export_requested.connect(self._export_playlist)
self._sidebar.art.download_art_requested.connect(
@@ -392,6 +394,19 @@ class MainWindow(QMainWindow):
def cassette(self) -> CassetteUi:
return self._cassette
# What friend mode swaps in and out.
@property
def content_stack(self):
return self._content
@property
def sidebar(self):
return self._sidebar
def show_my_library(self):
self._sidebar.reflect_library()
self._show_library()
def _add_action(self, menu, text, shortcut, slot):
action = QAction(text, self)
if shortcut:
@@ -1684,6 +1699,10 @@ class MainWindow(QMainWindow):
self._inhibitor.release()
def closeEvent(self, event):
# Unsaved cassette choices in friend mode: Save / Don't Save / Cancel.
if not self._cassette.confirm_quit():
event.ignore()
return
# Quitting mid-transfer abandons a partial file on the device (it
# self-heals next sync, but silently) — make it a deliberate choice.
# A link import has nothing half-written on a device to warn about:
+145 -6
View File
@@ -1,6 +1,7 @@
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QPushButton, QTreeWidget, QTreeWidgetItem, QMenu,
QInputDialog, QMessageBox, QAbstractItemView, QLineEdit, QApplication,
QHBoxLayout, QListWidget, QListWidgetItem, QStackedWidget,
)
from PyQt6.QtCore import Qt, QTimer, QPoint, pyqtSignal
from PyQt6.QtGui import QBrush, QPainter, QPalette, QPixmap
@@ -29,6 +30,13 @@ class SidebarPanel(QWidget):
library_selected = pyqtSignal()
playlist_selected = pyqtSignal(str)
# Friend sharing: the ▾ beside Library was clicked (show the friends
# popup), and in friend mode their library / one of their playlists was
# chosen, or a playlist's UFO (follow) toggled.
friend_selector_clicked = pyqtSignal()
friend_library_selected = pyqtSignal()
friend_playlist_selected = pyqtSignal(str)
follow_toggled = pyqtSignal(str)
def __init__(self, manager, player, parent=None):
super().__init__(parent)
@@ -36,27 +44,77 @@ class SidebarPanel(QWidget):
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self.friend_mode = False
top = QWidget()
top_row = QHBoxLayout(top)
top_row.setContentsMargins(0, 0, 0, 0)
top_row.setSpacing(0)
self.library_button = QPushButton("Library")
self.library_button.setCheckable(True)
self.library_button.setChecked(True)
# The library selector: a small square cut from the button's right
# end, flush against it. Shown once there is a friend to choose.
self.friend_button = QPushButton("▾")
self.friend_button.setToolTip("Browse a friend's library")
self.friend_button.setVisible(False)
top_row.addWidget(self.library_button, stretch=1)
top_row.addWidget(self.friend_button)
self.refresh_style()
layout.addWidget(self.library_button)
layout.addWidget(top)
self._lists = QStackedWidget()
self.tree = PlaylistTree(manager)
layout.addWidget(self.tree, stretch=1)
self._lists.addWidget(self.tree)
self.friend_list = FriendPlaylistList()
self._lists.addWidget(self.friend_list)
layout.addWidget(self._lists, stretch=1)
self.art = SidebarArt(player)
layout.addWidget(self.art)
self.library_button.clicked.connect(self._on_library_clicked)
self.tree.playlist_selected.connect(self._on_playlist_selected)
self.friend_button.clicked.connect(self.friend_selector_clicked)
self.friend_list.playlist_selected.connect(self._on_friend_playlist)
self.friend_list.follow_toggled.connect(self.follow_toggled)
def _on_library_clicked(self):
self.library_button.setChecked(True)
if self.friend_mode:
self.friend_list.clearSelection()
self.friend_list.setCurrentItem(None)
self.friend_library_selected.emit()
return
self.tree.clearSelection()
self.tree.setCurrentItem(None)
self.library_selected.emit()
def _on_friend_playlist(self, pid: str):
self.library_button.setChecked(False)
self.friend_playlist_selected.emit(pid)
# ---- friend mode ----
def set_friend_selector_visible(self, visible: bool):
self.friend_button.setVisible(visible)
self.refresh_style()
def show_friend(self, library_name: str, playlists, followed: set):
"""Friend mode: the Library button becomes their library (purple) and
the tree their shared playlists, each with a UFO follow toggle."""
self.friend_mode = True
self.library_button.setText(library_name)
self.friend_list.set_playlists(playlists, followed)
self._lists.setCurrentWidget(self.friend_list)
self.library_button.setChecked(True)
self.refresh_style()
def show_mine(self):
self.friend_mode = False
self.library_button.setText("Library")
self._lists.setCurrentWidget(self.tree)
self.refresh_style()
def _on_playlist_selected(self, pid: str):
self.library_button.setChecked(False)
self.playlist_selected.emit(pid)
@@ -79,21 +137,102 @@ class SidebarPanel(QWidget):
def apply_metrics(self, header_strip: int):
self.library_button.setFixedHeight(header_strip)
self.friend_button.setFixedHeight(header_strip)
# Square: the button's own height, minus its vertical margins.
self.friend_button.setFixedWidth(max(16, header_strip - 4))
def refresh_style(self):
"""(Re)build the Library button's look from the live palette, so its
emboss follows theme changes like every other button's."""
emboss follows theme changes like every other button's. With the
friend selector showing, the two share a border and have square
corners where they meet; in friend mode both turn light purple."""
from lintunes.gui.cassette_art import FRIEND_PURPLE, FRIEND_PURPLE_DARK
palette = QApplication.palette()
base = palette.color(QPalette.ColorRole.AlternateBase)
lit = palette.color(QPalette.ColorRole.Highlight)
split = not self.friend_button.isHidden()
border = "palette(mid)"
if self.friend_mode:
base = lit = FRIEND_PURPLE
border = FRIEND_PURPLE_DARK.name()
checked_text = "black" if self.friend_mode else "palette(highlighted-text)"
joint = ("border-top-right-radius: 0; border-bottom-right-radius: 0;"
" margin: 2px 0 2px 8px;" if split else
"margin: 2px 4px 2px 8px;")
self.library_button.setStyleSheet(
"QPushButton { text-align: left; padding-left: 10px;"
" border: 1px solid palette(mid); border-radius: 4px;"
" margin: 2px 4px 2px 8px;"
f" border: 1px solid {border}; border-radius: 4px; {joint}"
f" background: {theme.emboss_gradient(base)}; }}"
"QPushButton:checked {"
f" background: {theme.emboss_gradient(lit, pressed=True)};"
" color: palette(highlighted-text); }")
f" color: {checked_text}; }}")
self.friend_button.setStyleSheet(
"QPushButton { padding: 0;"
f" border: 1px solid {border}; border-left: none;"
" border-radius: 4px; border-top-left-radius: 0;"
" border-bottom-left-radius: 0; margin: 2px 4px 2px 0;"
f" background: {theme.emboss_gradient(base)}; }}"
"QPushButton:pressed {"
f" background: {theme.emboss_gradient(base, pressed=True)}; }}")
class FriendPlaylistList(QListWidget):
"""A friend's shared playlists in friend mode. The UFO at each row's left
is the follow toggle: purple when followed, a grey outline when not."""
playlist_selected = pyqtSignal(str)
follow_toggled = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
self.setUniformItemSizes(True)
self._followed: set[str] = set()
self.currentItemChanged.connect(self._on_current)
def set_playlists(self, playlists, followed: set):
self._followed = set(followed)
self.blockSignals(True)
self.clear()
for playlist in playlists:
item = QListWidgetItem(playlist.name)
item.setData(Qt.ItemDataRole.UserRole, playlist.pid)
self.addItem(item)
self.blockSignals(False)
self._refresh_icons()
def set_followed(self, pid: str, on: bool):
(self._followed.add if on else self._followed.discard)(pid)
self._refresh_icons()
def _refresh_icons(self):
from lintunes.gui.cassette_art import ufo_icon
on, off = ufo_icon(followed=True), ufo_icon(followed=False)
for row in range(self.count()):
item = self.item(row)
followed = item.data(Qt.ItemDataRole.UserRole) in self._followed
item.setIcon(on if followed else off)
item.setToolTip("Following — plays from your sidebar. Click the "
"UFO to stop." if followed else
"Click the UFO to follow this playlist.")
def _icon_hit(self, pos) -> bool:
index = self.indexAt(pos)
if not index.isValid():
return False
rect = self.visualRect(index)
return pos.x() - rect.left() < self.iconSize().width() + 8
def mousePressEvent(self, event):
pos = event.position().toPoint()
if event.button() == Qt.MouseButton.LeftButton and self._icon_hit(pos):
pid = self.indexAt(pos).data(Qt.ItemDataRole.UserRole)
self.follow_toggled.emit(pid)
return
super().mousePressEvent(event)
def _on_current(self, current, _previous):
if current is not None:
self.playlist_selected.emit(current.data(Qt.ItemDataRole.UserRole))
class SidebarArt(QWidget):
+112 -5
View File
@@ -85,6 +85,13 @@ ALL_COLUMNS = [
COLUMN_MAP = {col[0]: col for col in ALL_COLUMNS}
# Friend sharing's "ask for this song" column. Registered so a view can show
# it, but not in ALL_COLUMNS: only views that turn on ``cassette_hooks`` offer
# it in the header menu, and it always sits first.
CASSETTE_FIELD = "cassette"
CASSETTE_COLUMN = (CASSETTE_FIELD, "", 30)
COLUMN_MAP[CASSETTE_FIELD] = CASSETTE_COLUMN
def format_time(ms: int) -> str:
if ms <= 0:
@@ -156,7 +163,11 @@ class RatingDelegate(QStyledItemDelegate):
def paint(self, painter, option, index):
model = self._view.model_
if model.fields[index.column()] != "rating":
field = model.fields[index.column()]
if field == CASSETTE_FIELD:
self._paint_cassette(painter, option, index)
return
if field != "rating":
super().paint(painter, option, index)
return
opt = QStyleOptionViewItem(option)
@@ -194,6 +205,23 @@ class RatingDelegate(QStyledItemDelegate):
painter.restore()
def _paint_cassette(self, painter, option, index):
from lintunes.gui import cassette_art
opt = QStyleOptionViewItem(option)
self.initStyleOption(opt, index)
opt.text = ""
style = opt.widget.style() if opt.widget else QApplication.style()
style.drawControl(QStyle.ControlElement.CE_ItemViewItem, opt,
painter, opt.widget)
state = self._view.cassette_state_at(index)
if state is None:
return
rect = cassette_art.button_rect(QRectF(opt.rect))
cassette_art.paint_cassette_button(
painter, rect, state, self._view.is_cassette_hovered(index),
opt.palette)
def _format_size(size: int) -> str:
if size <= 0:
return ""
@@ -339,6 +367,9 @@ class TrackTableModel(QAbstractTableModel):
self._with_index = with_index_column
self._now_playing_id: int | None = None
self._now_playing_active = False
# Optional Callable[[Track], QColor | None]: a per-row text color
# (friend mode greys songs I probably already have).
self.foreground_for = None
self.set_visible_columns(["name", "artist", "album"])
def set_now_playing(self, track_id: int | None, playing: bool):
@@ -472,6 +503,10 @@ class TrackTableModel(QAbstractTableModel):
if role == Qt.ItemDataRole.DisplayRole:
return index.row() + 1
return None
if field == CASSETTE_FIELD:
return None # painted by the delegate
if role == Qt.ItemDataRole.ForegroundRole and self.foreground_for:
return self.foreground_for(track)
if (role == Qt.ItemDataRole.DecorationRole and field == "name"
and track.track_id == self._now_playing_id):
@@ -569,6 +604,14 @@ class TrackTableView(QTableView):
# itself reaching into the library.
self.playlists_for_track = None # Callable[[int], list[tuple[str,str]]]
# Friend sharing. read_only: someone else's songs — no playing, no
# editing, no dragging into my playlists (their ids aren't mine).
# cassette_hooks: (state_for(track id) -> "off"|"on"|None,
# toggled(track id)) — turns the cassette column on.
self.read_only = False
self.cassette_hooks = None
self._hover_cassette_index: QPersistentModelIndex | None = None
# Drag and drop (default indicator off: we paint a glowing line)
self.setDragEnabled(True)
self.setAcceptDrops(True)
@@ -600,11 +643,43 @@ class TrackTableView(QTableView):
def set_source_playlist(self, pid: str):
self._source_playlist_id = pid
def set_read_only(self, read_only: bool):
self.read_only = read_only
self.setDragEnabled(not read_only)
self.setAcceptDrops(not read_only and self._content_editable)
def cassette_state_at(self, index):
if self.cassette_hooks is None or not index.isValid():
return None
track = self.model_.track_at(self.model_.source_row(index.row()))
return self.cassette_hooks[0](track.track_id)
def is_cassette_hovered(self, index) -> bool:
hover = self._hover_cassette_index
return (hover is not None and hover.isValid()
and hover.row() == index.row()
and hover.column() == index.column())
def _is_cassette_index(self, index) -> bool:
return (self.cassette_hooks is not None and index.isValid()
and self.model_.fields[index.column()] == CASSETTE_FIELD)
def _set_cassette_hover(self, index):
new = QPersistentModelIndex(index) if index is not None else None
old = self._hover_cassette_index
if new == old:
return
self._hover_cassette_index = new
for persistent in (old, new):
if persistent is not None and persistent.isValid():
self.viewport().update(self.visualRect(
self.model_.index(persistent.row(), persistent.column())))
def set_content_editable(self, editable: bool):
"""Smart playlists pass False: dropping, reordering, cut/paste and
remove are all disabled (membership is derived from criteria)."""
self._content_editable = editable
self.setAcceptDrops(editable)
self.setAcceptDrops(editable and not self.read_only)
def context_id(self) -> str:
"""Identifies what this table is currently showing, so the now-playing
@@ -691,7 +766,7 @@ class TrackTableView(QTableView):
def copy_selection(self):
ids = self.selected_track_ids()
if not ids:
if not ids or self.read_only:
return
QApplication.clipboard().setMimeData(make_tracks_mime(
ids, self._source_playlist_id, self.selected_source_rows()))
@@ -700,6 +775,8 @@ class TrackTableView(QTableView):
"""Copy the selection, then — in a playlist — remove it immediately so
it visibly leaves; a paste re-inserts it at the target. From the
library there's nothing to remove, so this is just a copy."""
if self.read_only:
return
ids = self.selected_track_ids()
if not ids:
return
@@ -742,7 +819,10 @@ class TrackTableView(QTableView):
def _show_header_menu(self, pos):
menu = QMenu(self)
visible = [f for f in self.model_.fields if f != INDEX_FIELD]
for field, label, _w in ALL_COLUMNS:
columns = list(ALL_COLUMNS)
if self.cassette_hooks is not None:
columns = [(CASSETTE_FIELD, "Cassette", 30)] + columns
for field, label, _w in columns:
action = menu.addAction(label)
action.setCheckable(True)
action.setChecked(field in visible)
@@ -754,7 +834,7 @@ class TrackTableView(QTableView):
if chosen.isChecked():
new_visible = visible + [field]
# Preserve canonical column ordering
new_visible = [f for f, _l, _w in ALL_COLUMNS if f in new_visible]
new_visible = [f for f, _l, _w in columns if f in new_visible]
else:
new_visible = [f for f in visible if f != field]
if not new_visible:
@@ -766,6 +846,9 @@ class TrackTableView(QTableView):
def _show_context_menu(self, pos):
if not self.selected_track_ids():
return
if self.read_only:
self._show_read_only_menu(pos)
return
menu = QMenu(self)
info_action = menu.addAction("Get Info\tCtrl+I")
copy_action = menu.addAction("Copy\tCtrl+C")
@@ -850,6 +933,17 @@ class TrackTableView(QTableView):
elif chosen is delete_action:
self.delete_from_library_requested.emit(self.selected_track_ids())
def _show_read_only_menu(self, pos):
"""Someone else's songs: nothing here edits, plays or copies them."""
selected = self.selected_track_ids()
if len(selected) != 1:
return
menu = QMenu(self)
youtube_action = menu.addAction("lookup on youtube")
if menu.exec(self.viewport().mapToGlobal(pos)) is youtube_action:
track = self.model_.track_at(self.selected_source_rows()[0])
QDesktopServices.openUrl(youtube_search_url(track))
# ---- rating hover / click ----
def _is_rating_index(self, index) -> bool:
@@ -877,16 +971,27 @@ class TrackTableView(QTableView):
def mouseMoveEvent(self, event):
index = self.indexAt(event.position().toPoint())
self._set_rating_hover(index if self._is_rating_index(index) else None)
self._set_cassette_hover(index if self._is_cassette_index(index) else None)
super().mouseMoveEvent(event)
def leaveEvent(self, event):
self._set_rating_hover(None)
self._set_cassette_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_cassette_index(index):
track = self.model_.track_at(self.model_.source_row(index.row()))
if self.cassette_state_at(index) is not None:
self.cassette_hooks[1](track.track_id)
self.viewport().update(self.visualRect(index))
return # a cassette click never selects or starts a drag
if self.read_only and self._is_rating_index(index):
super().mousePressEvent(event)
return
if self._is_rating_index(index):
track = self.model_.track_at(self.model_.source_row(index.row()))
slot = rating_slot_at(
@@ -900,6 +1005,8 @@ class TrackTableView(QTableView):
# ---- playback ----
def _on_double_click(self, index):
if self.read_only:
return # browsing only: the audio isn't here
ids = self.view_order_track_ids()
if ids:
self.play_requested.emit(ids, index.row())