- 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>
359 lines
14 KiB
Python
359 lines
14 KiB
Python
"""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
|