Files
lintunes/lintunes/gui/friend_mode.py
T
travandClaude Opus 5.5 8099cfc85f 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>
2026-09-28 21:27:06 -07:00

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