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. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -481,6 +481,18 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
|
||||
`syncthing` run a real handshake with `pytest --syncthing`. The service
|
||||
starts from `run_gui`, never from constructing `MainWindow` — tests build
|
||||
windows by the dozen and must not reach a real Syncthing.
|
||||
**Friend mode** (`gui/friend_mode.py`, entered from the ▾ beside Library)
|
||||
shows a friend's `library.json` through a second `LibraryView` over
|
||||
`cassette/friend_library.FriendSource` — the slice of `LibraryManager` that
|
||||
view reads, read-only. Their track ids are *theirs*: the table is
|
||||
`set_read_only(True)` (no play, no drag, no copy into my playlists, only
|
||||
"lookup on youtube"), and the cassette column is `track_table.CASSETTE_FIELD`
|
||||
(registered in `COLUMN_MAP`, deliberately not `ALL_COLUMNS`), live only
|
||||
where `cassette_hooks` is set. Nothing is written until Save and Close
|
||||
(`service.save_requests` → `requests.json`); any other exit with changes
|
||||
asks "Save changes?". `cassette/matching.py` is best effort (exact vs
|
||||
fuzzy); `cassette/space.py` judges the music disk (red) and Syncthing's
|
||||
`minDiskFree` floor on the cassette disk (orange) separately.
|
||||
|
||||
- **`lintunes/cast/`** — Chromecast playback (Connections menu), using the
|
||||
**media-receiver model**: `server.py` runs a `ThreadingHTTPServer` on an
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||
|
||||
__version__ = "0.28.0"
|
||||
__version__ = "0.29.0"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)}
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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())
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
"""Round 67: friend mode — browsing a friend's library and cassetting songs.
|
||||
|
||||
* Matching is best effort: exact = artist + album + title + length within 2 s;
|
||||
fuzzy = artist + title. Case, accents, punctuation, "feat." and a leading
|
||||
"The" don't count.
|
||||
* The friend's library is read-only here: no playing, no editing, no
|
||||
dragging their ids into my playlists.
|
||||
* Nothing is saved as you click. Save and Close writes requests.json;
|
||||
Cancel drops everything; leaving any other way with changes asks.
|
||||
* The size readout goes orange when Syncthing would stall (its free-space
|
||||
floor) and red when the songs won't fit.
|
||||
"""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from PyQt6.QtCore import Qt
|
||||
|
||||
from lintunes.cassette import matching, publish, requests as req, share, space
|
||||
from lintunes.cassette.friend_library import load_friend_library
|
||||
from lintunes.cassette.service import CassetteService
|
||||
from lintunes.cassette.state import Friend, Selection, in_dir, out_dir
|
||||
from lintunes.models import Playlist, Track
|
||||
from lintunes.models.library import Library
|
||||
|
||||
|
||||
def _t(tid, name, artist, album, ms=200_000, **kw):
|
||||
return Track(track_id=tid, name=name, artist=artist, album=album,
|
||||
total_time=ms, size=5_000_000, **kw)
|
||||
|
||||
|
||||
# ---- matching ----
|
||||
|
||||
class TestMatching:
|
||||
def test_normalize(self):
|
||||
assert matching.normalize("The Beatles") == "beatles"
|
||||
assert matching.normalize("Beyoncé") == "beyonce"
|
||||
assert matching.normalize("Crazy In Love (feat. Jay-Z)") == "crazy in love"
|
||||
assert matching.normalize("Hey Jude - Remastered 2015") == "hey jude remastered 2015"
|
||||
assert matching.normalize("Hey Jude (Remastered 2015)") == "hey jude"
|
||||
assert matching.normalize("Simon & Garfunkel") == "simon and garfunkel"
|
||||
assert matching.normalize("Song ft. Someone") == "song"
|
||||
|
||||
def test_exact_needs_album_and_length(self):
|
||||
index = matching.LibraryIndex([_t(1, "Hey Jude", "The Beatles", "1")])
|
||||
assert index.classify(_t(9, "hey jude", "Beatles", "1", ms=201_500)) == matching.EXACT
|
||||
assert index.classify(_t(9, "Hey Jude", "The Beatles", "1", ms=210_000)) == matching.FUZZY
|
||||
assert index.classify(_t(9, "Hey Jude", "The Beatles", "Past Masters")) == matching.FUZZY
|
||||
assert index.classify(_t(9, "Let It Be", "The Beatles", "1")) is None
|
||||
|
||||
def test_album_artist_or_artist(self):
|
||||
mine = _t(1, "Track", "Guest", "Comp", album_artist="Various Artists")
|
||||
index = matching.LibraryIndex([mine])
|
||||
assert index.classify(_t(9, "Track", "Guest", "Comp")) == matching.EXACT
|
||||
|
||||
def test_unknown_length_still_exact(self):
|
||||
index = matching.LibraryIndex([_t(1, "A", "B", "C", ms=0)])
|
||||
assert index.classify(_t(9, "A", "B", "C")) == matching.EXACT
|
||||
|
||||
|
||||
# ---- space ----
|
||||
|
||||
class TestSpace:
|
||||
def test_verdicts(self):
|
||||
gb = 10**9
|
||||
assert space.verdict(1 * gb, 50 * gb, 50 * gb, 1 * gb) == space.OK
|
||||
assert space.verdict(49 * gb, 50 * gb, 50 * gb, 2 * gb) == space.TIGHT
|
||||
assert space.verdict(60 * gb, 50 * gb, 500 * gb, 1 * gb) == space.FULL
|
||||
|
||||
def test_human(self):
|
||||
assert space.human(740_000) == "740 KB"
|
||||
assert space.human(1_200_000_000) == "1.2 GB"
|
||||
|
||||
def test_disk_usage_of_missing_path_uses_its_parent(self, tmp_path):
|
||||
assert space.disk_usage(tmp_path / "not" / "yet") is not None
|
||||
|
||||
|
||||
# ---- requests & the friend's copy ----
|
||||
|
||||
class TestFiles:
|
||||
def test_requests_round_trip(self, tmp_path):
|
||||
assert req.write_requests(tmp_path, [3, 1, 3])
|
||||
assert json.loads((tmp_path / req.REQUESTS_FILE).read_text())["tracks"] == [1, 3]
|
||||
assert not req.write_requests(tmp_path, {1, 3})
|
||||
assert req.read_requests(tmp_path) == {1, 3}
|
||||
|
||||
def test_garbled_requests_is_empty(self, tmp_path):
|
||||
(tmp_path / req.REQUESTS_FILE).write_text("{half a fi")
|
||||
assert req.read_requests(tmp_path) == set()
|
||||
|
||||
def test_load_friend_library(self, tmp_path):
|
||||
library = Library()
|
||||
library.tracks[5] = _t(5, "Song", "Artist", "Album", rating=100)
|
||||
library.playlists["P"] = Playlist(name="Mix", persistent_id="P",
|
||||
track_ids=[5])
|
||||
share.publish_share(tmp_path, library, Selection(all_library=True,
|
||||
all_playlists=True))
|
||||
publish.publish_intro(tmp_path, "Sam", None)
|
||||
friend = load_friend_library(tmp_path)
|
||||
assert friend.name == "Sam"
|
||||
assert friend.tracks[5].name == "Song"
|
||||
assert friend.tracks[5].rating == 0 # never crossed over
|
||||
assert friend.playlists[0].name == "Mix"
|
||||
assert friend.playlists[0].track_ids == [5]
|
||||
|
||||
def test_missing_or_garbled_is_empty(self, tmp_path):
|
||||
assert load_friend_library(tmp_path, "Sam").tracks == {}
|
||||
(tmp_path / share.LIBRARY_FILE).write_text("[1, 2")
|
||||
(tmp_path / share.PLAYLISTS_FILE).write_text(json.dumps(
|
||||
{"playlists": [{"id": "P", "track_ids": ["x", 99]}]}))
|
||||
friend = load_friend_library(tmp_path, "Sam")
|
||||
assert friend.name == "Sam" and friend.tracks == {}
|
||||
assert friend.playlists[0].track_ids == []
|
||||
|
||||
|
||||
# ---- read-only table and the cassette column ----
|
||||
|
||||
class TestTable:
|
||||
def test_read_only_never_plays_or_copies(self, qapp):
|
||||
from lintunes.gui.track_table import TrackTableView
|
||||
table = TrackTableView()
|
||||
table.set_read_only(True)
|
||||
table.set_tracks([_t(1, "A", "B", "C")])
|
||||
played = []
|
||||
table.play_requested.connect(lambda *a: played.append(a))
|
||||
table._on_double_click(table.model_.index(0, 0))
|
||||
assert played == []
|
||||
assert not table.dragEnabled() and not table.acceptDrops()
|
||||
|
||||
def test_cassette_column_click_toggles(self, qapp):
|
||||
from PyQt6.QtCore import QPointF
|
||||
from PyQt6.QtGui import QMouseEvent
|
||||
from lintunes.gui.track_table import CASSETTE_FIELD, TrackTableView
|
||||
table = TrackTableView()
|
||||
on = set()
|
||||
table.cassette_hooks = (lambda tid: "on" if tid in on else "off",
|
||||
lambda tid: on.symmetric_difference_update({tid}))
|
||||
table.model_.set_visible_columns([CASSETTE_FIELD, "name"])
|
||||
table.set_tracks([_t(7, "A", "B", "C")])
|
||||
table.resize(400, 200)
|
||||
table.show()
|
||||
rect = table.visualRect(table.model_.index(0, 0))
|
||||
event = QMouseEvent(QMouseEvent.Type.MouseButtonPress,
|
||||
QPointF(rect.center()), QPointF(rect.center()),
|
||||
Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton,
|
||||
Qt.KeyboardModifier.NoModifier)
|
||||
table.mousePressEvent(event)
|
||||
assert on == {7}
|
||||
assert table.cassette_state_at(table.model_.index(0, 0)) == "on"
|
||||
|
||||
|
||||
# ---- friend mode itself ----
|
||||
|
||||
class _Window:
|
||||
"""What FriendMode touches on MainWindow, and nothing more."""
|
||||
|
||||
def __init__(self):
|
||||
from PyQt6.QtWidgets import QMainWindow, QStackedWidget
|
||||
from lintunes.gui.sidebar import SidebarPanel
|
||||
from unittest.mock import MagicMock
|
||||
self.main = QMainWindow()
|
||||
self.content_stack = QStackedWidget()
|
||||
self.main.setCentralWidget(self.content_stack)
|
||||
manager = MagicMock()
|
||||
manager.library.playlists = {}
|
||||
player = MagicMock()
|
||||
self.sidebar = SidebarPanel(manager, player)
|
||||
self.shown_mine = 0
|
||||
|
||||
def statusBar(self):
|
||||
return self.main.statusBar()
|
||||
|
||||
def show_my_library(self):
|
||||
self.shown_mine += 1
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def visit(qapp, tmp_path):
|
||||
from lintunes.gui.friend_mode import FriendMode
|
||||
friend_lib = Library()
|
||||
for tid, (name, artist, album) in enumerate(
|
||||
[("Hey Jude", "The Beatles", "1"), ("Let It Be", "The Beatles", "1"),
|
||||
("Blackbird", "The Beatles", "White"), ("Doo Wop", "Lauryn Hill", "Mis")],
|
||||
start=10):
|
||||
friend_lib.tracks[tid] = _t(tid, name, artist, album)
|
||||
friend_lib.playlists["P"] = Playlist(name="Road Trip", persistent_id="P",
|
||||
track_ids=[13, 10])
|
||||
service = CassetteService(root=tmp_path, config_loader=lambda: {})
|
||||
service._state.friends["tok"] = Friend(token="tok", device_id="X", name="Sam",
|
||||
in_folder="f-in")
|
||||
share.publish_share(in_dir(tmp_path, "tok"), friend_lib,
|
||||
Selection(all_library=True, all_playlists=True))
|
||||
mine = Library()
|
||||
mine.tracks[1] = _t(1, "Hey Jude", "Beatles", "1") # exact
|
||||
mine.tracks[2] = _t(2, "Blackbird", "The Beatles", "Other") # fuzzy
|
||||
window = _Window()
|
||||
mode = FriendMode(window.main, service, mine, tmp_path)
|
||||
mode._window = window
|
||||
mode.enter("tok")
|
||||
return mode, window, service
|
||||
|
||||
|
||||
class TestFriendMode:
|
||||
def test_enter_shows_their_library_purple(self, visit):
|
||||
mode, window, _ = visit
|
||||
assert window.sidebar.friend_mode
|
||||
assert window.sidebar.library_button.text() == "Sam's Library"
|
||||
assert window.content_stack.currentWidget() is mode._view
|
||||
assert mode._view.table.read_only
|
||||
assert mode.matches[10] == matching.EXACT
|
||||
assert mode.matches[12] == matching.FUZZY
|
||||
assert mode.matches[13] is None
|
||||
|
||||
def test_artist_cassettes_down_the_tree(self, visit):
|
||||
mode, _, _ = visit
|
||||
ids = mode._view.group_track_ids("artist", "The Beatles")
|
||||
mode.toggle(ids)
|
||||
assert mode.cassetted == {10, 11, 12}
|
||||
assert mode.state_of([10, 13]) == "partial"
|
||||
mode.toggle(ids)
|
||||
assert mode.cassetted == set()
|
||||
|
||||
def test_nothing_saved_until_save(self, visit, tmp_path):
|
||||
mode, window, service = visit
|
||||
mode.toggle([13])
|
||||
mode._toggle_follow("P")
|
||||
assert not (out_dir(tmp_path, "tok") / req.REQUESTS_FILE).exists()
|
||||
assert service.state().friends["tok"].cassetted == []
|
||||
assert mode.is_dirty()
|
||||
mode.save_and_close()
|
||||
friend = service.state().friends["tok"]
|
||||
assert friend.cassetted == [13] and friend.followed == ["P"]
|
||||
# the followed playlist's songs are asked for too
|
||||
assert req.read_requests(out_dir(tmp_path, "tok")) == {10, 13}
|
||||
assert not mode.active and not window.sidebar.friend_mode
|
||||
assert window.shown_mine == 1
|
||||
|
||||
def test_cancel_discards(self, visit, tmp_path):
|
||||
mode, _, service = visit
|
||||
mode.toggle([13])
|
||||
mode.cancel()
|
||||
assert service.state().friends["tok"].cassetted == []
|
||||
assert not (out_dir(tmp_path, "tok") / req.REQUESTS_FILE).exists()
|
||||
|
||||
def test_leaving_with_changes_asks(self, visit, monkeypatch):
|
||||
from lintunes.gui import friend_mode
|
||||
mode, _, service = visit
|
||||
mode.toggle([11])
|
||||
monkeypatch.setattr(friend_mode, "ask_save_changes", lambda parent: friend_mode.STAY)
|
||||
assert not mode.confirm_leave() and mode.active
|
||||
monkeypatch.setattr(friend_mode, "ask_save_changes", lambda parent: friend_mode.DISCARD)
|
||||
assert mode.confirm_leave() and not mode.active
|
||||
assert service.state().friends["tok"].cassetted == []
|
||||
|
||||
def test_leaving_clean_never_asks(self, visit, monkeypatch):
|
||||
from lintunes.gui import friend_mode
|
||||
mode, _, _ = visit
|
||||
monkeypatch.setattr(friend_mode, "ask_save_changes",
|
||||
lambda parent: pytest.fail("asked"))
|
||||
assert mode.confirm_leave()
|
||||
|
||||
def test_hide_tracks_i_have(self, visit):
|
||||
mode, _, _ = visit
|
||||
mode.hide_have.setChecked(True)
|
||||
shown = {t.track_id for t in mode._view.table.model_.tracks}
|
||||
assert 10 not in shown # exact: hidden
|
||||
assert {11, 12, 13} <= shown # fuzzy stays (grey)
|
||||
grey = mode._foreground(mode.library.tracks[12])
|
||||
assert grey is not None
|
||||
|
||||
def test_their_playlist_in_order(self, visit):
|
||||
mode, window, _ = visit
|
||||
mode._show_playlist("P")
|
||||
assert window.content_stack.currentWidget() is mode._pane
|
||||
assert [t.track_id for t in mode._pane.table.model_.tracks] == [13, 10]
|
||||
assert mode._pane.table.read_only
|
||||
|
||||
def test_space_turns_red(self, visit, monkeypatch):
|
||||
mode, _, _ = visit
|
||||
from collections import namedtuple
|
||||
usage = namedtuple("usage", "total used free")
|
||||
monkeypatch.setattr(space, "disk_usage",
|
||||
lambda path: usage(100_000_000, 99_000_000, 1_000_000))
|
||||
mode.toggle([10, 11])
|
||||
assert mode.space_state()[0] == space.FULL
|
||||
assert "#d0342c" in mode.size_label.styleSheet()
|
||||
|
||||
|
||||
class TestSelector:
|
||||
def test_hidden_until_there_is_a_friend(self, qapp):
|
||||
from unittest.mock import MagicMock
|
||||
from lintunes.gui.sidebar import SidebarPanel
|
||||
manager = MagicMock()
|
||||
manager.library.playlists = {}
|
||||
sidebar = SidebarPanel(manager, MagicMock())
|
||||
assert sidebar.friend_button.isHidden()
|
||||
sidebar.set_friend_selector_visible(True)
|
||||
assert not sidebar.friend_button.isHidden()
|
||||
assert "border-top-right-radius: 0" in sidebar.library_button.styleSheet()
|
||||
Reference in New Issue
Block a user