v0.31.3: header row moves up beside the seek bar; knolled top row

The Library button, the view's header (search box or playlist name) and
the seek row now share one band under the transport boxes, instead of the
seek row hanging alone with a header strip below it. The now-playing panel
caps at 560px and the gaps between controls share the rest evenly; more
room under the menu bar. Long playlist names shrink up to 2pt, then elide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 22:43:21 -07:00
co-authored by Claude Opus 5.5
parent 8797e63bd8
commit 43d7b91aa6
12 changed files with 423 additions and 276 deletions
+2 -1
View File
@@ -23,4 +23,5 @@ def test_every_control_shares_the_now_playing_row(qapp, tmp_path):
def test_seek_row_hangs_under_the_panel(qapp, tmp_path):
bar = _build_bar(qapp, tmp_path)
assert bar._slider.geometry().top() > bar._now_playing.geometry().bottom()
slider_top = bar._slider.mapTo(bar, bar._slider.rect().topLeft()).y()
assert slider_top > bar._now_playing.geometry().bottom()
+93 -230
View File
@@ -1,252 +1,115 @@
"""Round 69: followed playlists in my sidebar.
"""Round 69: the header row moves up into the seek row's band.
* A followed friend playlist shows in my sidebar with a purple UFO — mixed in
alphabetically, or inside a folder named after the friend (a per-friend
setting) — and is never a Playlist of mine, so it stays out of the
library and its conflict merge.
* It plays from the cache: Player queues opaque keys
("cassette:<token>:<their id>") resolved through Cassette. Plays scrobble
but don't count in my play journal. Songs not here yet are grey and skipped.
* Its cassette column is hidden by default; pressing one takes the song into
my library — straight from the cache if it's there, otherwise by asking.
* Unfollowing frees the cache; requests never re-ask for cached songs.
The Library button, the view's header (search box or playlist name) and the
seek row share one row under the transport boxes: the Library button as wide
as the sidebar, the header where the view starts, the seek row exactly under
the now-playing panel. The panel stops at NOW_PLAYING_MAX and the gaps between
the controls share what's left.
"""
import shutil
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from PyQt6.QtCore import QPoint
from lintunes.cassette import delivery, matching, publish, share
from lintunes.cassette import requests as req
from lintunes.cassette.service import CassetteService
from lintunes.cassette.state import (
CassetteState, Friend, Selection, cache_dir, in_dir, out_dir,
)
from lintunes.gui.followed_view import parse_key, track_key
from lintunes.models import Playlist, Track
from lintunes.gui.fit_label import FitLabel
from lintunes.gui.main_window import MainWindow
from lintunes.gui.transport import NOW_PLAYING_MAX, NOW_PLAYING_MIN
from lintunes.library_manager import LibraryManager
from lintunes.models.library import Library
TOKEN = "0123456789abcdef"
from lintunes.models.playlist import Playlist
from lintunes.models.track import Track
from lintunes.preferences import Preferences
def _friend_share(root, mp3_file, cached=(10,)):
theirs = Library()
for tid, name in ((10, "One"), (11, "Two"), (12, "Three")):
theirs.tracks[tid] = Track(track_id=tid, name=name, artist="Sam's Band",
album="Demo", total_time=1000, size=100)
theirs.playlists["P"] = Playlist(name="Road Trip", persistent_id="P",
track_ids=[11, 10])
share.publish_share(in_dir(root, TOKEN), theirs,
Selection(all_library=True, all_playlists=True))
publish.publish_intro(in_dir(root, TOKEN), "Sam", None)
cache = cache_dir(root, TOKEN)
cache.mkdir(parents=True, exist_ok=True)
for tid in cached:
shutil.copyfile(mp3_file, cache / f"{tid}.mp3")
return theirs
def _window(qapp, tmp_path, width=1280):
library = Library(tracks={1: Track(track_id=1, name="One")})
library.playlists["abcd1234"] = Playlist(
persistent_id="abcd1234", name="Road Trip", track_ids=[1])
window = MainWindow(LibraryManager(library, tmp_path), Preferences(tmp_path))
window.resize(width, 500)
window.show()
qapp.processEvents()
return window
# ---- keys and the player ----
class TestPlayer:
def test_keys(self):
assert parse_key(track_key(TOKEN, 12)) == (TOKEN, 12)
assert parse_key("12") is None and parse_key(12) is None
def test_friend_track_scrobbles_but_isnt_counted(self, qapp, tmp_path, mp3_file):
from lintunes.player import Player
manager = MagicMock()
player = Player(manager)
friend_track = Track(track_id=5, name="Theirs", location=str(mp3_file))
player.track_resolver = lambda key: friend_track if key == "cassette:t:5" else None
finished = []
player.track_finished.connect(finished.append)
player._current_track = friend_track
player._current_key = "cassette:t:5"
player._note_finished(friend_track)
player._note_finished(friend_track)
manager.record_play.assert_not_called()
assert finished == [friend_track] # Last.fm still hears it
assert player._resolve("cassette:t:5") is friend_track
assert player._resolve("cassette:t:6") is None
def _x(widget, window, x=0):
return widget.mapTo(window, QPoint(x, 0)).x()
# ---- the service never re-asks for cached songs ----
class TestRequests:
def test_followed_songs_already_cached_arent_requested(self, qapp, tmp_path, mp3_file):
root = tmp_path / "root"
_friend_share(root, mp3_file, cached=(10,))
service = CassetteService(root=root, config_loader=lambda: {})
service._state.friends[TOKEN] = Friend(token=TOKEN, device_id="D", name="Sam")
service.save_requests(TOKEN, {12}, ["P"])
assert req.read_requests(out_dir(root, TOKEN)) == {11, 12}
def test_unfollowed_cache_is_pruned(self, tmp_path, mp3_file):
root = tmp_path / "root"
_friend_share(root, mp3_file, cached=(10, 12))
friend = Friend(token=TOKEN, device_id="D", name="Sam", followed=["P"])
plan = delivery.plan_receive(friend, root, matching.LibraryIndex([]))
assert [p.name for p in plan.cache_prune] == ["12.mp3"]
friend.followed = []
plan = delivery.plan_receive(friend, root, matching.LibraryIndex([]))
assert sorted(p.name for p in plan.cache_prune) == ["10.mp3", "12.mp3"]
delivery.run_plans([], [plan], None)
assert list(cache_dir(root, TOKEN).iterdir()) == []
def test_no_playlists_file_prunes_nothing(self, tmp_path, mp3_file):
root = tmp_path / "root"
_friend_share(root, mp3_file, cached=(10,))
(in_dir(root, TOKEN) / share.PLAYLISTS_FILE).unlink()
friend = Friend(token=TOKEN, device_id="D", name="Sam")
plan = delivery.plan_receive(friend, root, matching.LibraryIndex([]))
assert plan.cache_prune == []
def _gx(widget, x=0):
return widget.mapToGlobal(QPoint(x, 0)).x()
# ---- the sidebar and the view ----
class _Window:
def __init__(self, qapp):
from PyQt6.QtWidgets import QMainWindow, QStackedWidget
from lintunes.gui.sidebar import SidebarPanel
self.main = QMainWindow()
self.content_stack = QStackedWidget()
self.main.setCentralWidget(self.content_stack)
manager = MagicMock()
manager.library.playlists = {
"M1": Playlist(name="Morning", persistent_id="M1"),
"M2": Playlist(name="Zebra", persistent_id="M2")}
self.sidebar = SidebarPanel(manager, MagicMock())
self.player = MagicMock()
self.player.current_key = None
self.player.is_playing.return_value = False
self.now_playing_context = ""
self.played = []
self.totals = 0
def statusBar(self):
return self.main.statusBar()
def play_keys(self, keys, start, context):
self.played.append((keys, start, context))
def update_totals(self):
self.totals += 1
def show_my_library(self):
pass
def test_startup_still_opens_on_the_library(qapp, tmp_path):
# The moved Library button fell to the end of the focus chain, the tree
# took the first focus and selected its first playlist.
window = _window(qapp, tmp_path)
assert window._content.currentWidget() is window._library_view
assert window._sidebar.library_button.isChecked()
@pytest.fixture
def followed(qapp, tmp_path, mp3_file):
from lintunes.gui.cassette_ui import CassetteUi
from lintunes.library_manager import LibraryManager
root = tmp_path / "root"
_friend_share(root, mp3_file, cached=(10,))
service = CassetteService(root=root, config_loader=lambda: {})
service._state.friends[TOKEN] = Friend(token=TOKEN, device_id="D", name="Sam",
followed=["P"], layout="mixed")
manager = LibraryManager(Library(), tmp_path / "data")
manager.organize_root = lambda: tmp_path / "Music"
window = _Window(qapp)
ui = CassetteUi(window.main, MagicMock(), manager)
ui._window = window
ui.service = service
ui._hook_window()
return ui, window, service, manager
def test_library_button_spans_the_sidebar(qapp, tmp_path):
window = _window(qapp, tmp_path)
header = window.sidebar.header
assert header.parentWidget() is not window.sidebar
assert _gx(header) == _gx(window.sidebar)
assert header.width() == window.sidebar.width()
class TestSidebar:
def test_mixed_in_alphabetically(self, followed):
ui, window, _, _ = followed
tree = window.sidebar.tree
names = [tree.topLevelItem(i).text(0) for i in range(tree.topLevelItemCount())]
assert names == ["Morning", "Road Trip", "Zebra"]
def test_search_then_title_sit_where_the_view_starts(qapp, tmp_path):
window = _window(qapp, tmp_path)
search_strip = window._library_view.header
assert search_strip.isVisible()
assert _gx(search_strip) == _gx(window._content)
def test_in_a_friend_folder(self, followed):
ui, window, service, _ = followed
service._state.friends[TOKEN].layout = "folder"
ui._refresh_followed()
tree = window.sidebar.tree
names = [tree.topLevelItem(i).text(0) for i in range(tree.topLevelItemCount())]
assert names == ["Morning", "Sam", "Zebra"]
folder = tree.topLevelItem(1)
assert folder.child(0).text(0) == "Road Trip"
def test_followed_items_arent_editable_or_draggable(self, followed):
from PyQt6.QtCore import Qt
_, window, _, _ = followed
tree = window.sidebar.tree
item = tree._find_item(f"cassette:{TOKEN}:P")
assert not item.flags() & Qt.ItemFlag.ItemIsEditable
assert not item.flags() & Qt.ItemFlag.ItemIsDragEnabled
window._show_playlist("abcd1234")
qapp.processEvents()
label = window._playlist_view.header
assert label.isVisible() and label.full_text() == "Road Trip"
assert _gx(label) == _gx(window._content)
# The search strip went home to its view, hidden.
assert not search_strip.isVisible()
assert search_strip.parentWidget() is window._library_view
class TestFollowedView:
def test_cached_plays_uncached_is_grey(self, followed):
ui, window, _, _ = followed
ui.show_followed(f"cassette:{TOKEN}:P")
view = ui.followed_view
assert [t.track_id for t in view.table.model_.tracks] == [11, 10]
assert view._foreground(view.table.model_.tracks[0]) is not None # 11 not here
assert view._foreground(view.table.model_.tracks[1]) is None # 10 cached
view._on_double_click(view.table.model_.index(1, 0))
assert window.played == [([track_key(TOKEN, 10)], 0,
f"followed:cassette:{TOKEN}:P")]
view._on_double_click(view.table.model_.index(0, 0)) # not here yet
assert len(window.played) == 1
assert "last synced" in view.status_suffix()
def test_cassette_column_hidden_by_default(self, followed):
from lintunes.gui.track_table import CASSETTE_FIELD
ui, _, _, _ = followed
ui.show_followed(f"cassette:{TOKEN}:P")
assert CASSETTE_FIELD not in ui.followed_view.table.model_.fields
assert ui.followed_view.table.cassette_hooks is not None
def test_resolve_points_at_the_cache(self, followed):
ui, _, service, _ = followed
track = ui.resolve_key(track_key(TOKEN, 10))
assert Path(track.location).parent == cache_dir(service.root, TOKEN)
assert ui.resolve_key(track_key(TOKEN, 11)) is None
def test_cassette_a_cached_song_imports_it_now(self, followed, tmp_path):
ui, _, service, manager = followed
ui.toggle_cassette(TOKEN, 10)
[track] = manager.library.tracks.values()
assert tmp_path / "Music" in Path(track.location).parents
assert track.play_count == 0
assert service.state().friends[TOKEN].cassetted == []
def test_cassette_an_uncached_song_asks_for_it(self, followed):
ui, _, service, manager = followed
ui.toggle_cassette(TOKEN, 12)
assert service.state().friends[TOKEN].cassetted == [12]
assert 12 in req.read_requests(out_dir(service.root, TOKEN))
assert ui.cassette_state(TOKEN, 12) == "on"
ui.toggle_cassette(TOKEN, 12)
assert ui.cassette_state(TOKEN, 12) == "off"
def test_unfollow(self, followed):
ui, window, service, _ = followed
ui.unfollow(f"cassette:{TOKEN}:P")
assert service.state().friends[TOKEN].followed == []
tree = window.sidebar.tree
names = [tree.topLevelItem(i).text(0) for i in range(tree.topLevelItemCount())]
assert names == ["Morning", "Zebra"]
def test_header_row_shares_the_seek_band(qapp, tmp_path):
window = _window(qapp, tmp_path)
bar = window._transport
panel = bar._now_playing
seek = bar._slider.parentWidget()
# Under the panel, edge to edge, and level with the Library button.
assert _gx(seek) == _gx(panel)
assert seek.width() == panel.width()
library = window.sidebar.library_button
row = bar._header_row
assert row.geometry().top() > panel.geometry().bottom()
assert library.mapTo(bar, QPoint()).y() >= row.geometry().top()
class TestSettings:
def test_follow_settings_on_the_friend_page(self, qapp, tmp_path):
from lintunes.gui.sync_settings_dialog import SyncSettingsDialog
from tests.test_round64 import _NoProbe
state = CassetteState()
state.friends["t"] = Friend(token="t", device_id="D", name="Sam")
dialog = SyncSettingsDialog(state, tmp_path, {}, probe=_NoProbe())
page = dialog.friend_pages["t"]
assert page.layout_folder.isChecked() and page.mode_separate.isChecked()
page.layout_mixed.setChecked(True)
page.mode_library.setChecked(True)
friend = dialog.state.friends["t"]
assert (friend.layout, friend.followed_mode) == ("mixed", "library")
def test_panel_caps_and_gaps_take_the_rest(qapp, tmp_path):
window = _window(qapp, tmp_path, width=1600)
bar = window._transport
assert bar._now_playing.width() == NOW_PLAYING_MAX
window.resize(800, 500)
qapp.processEvents()
assert NOW_PLAYING_MIN <= bar._now_playing.width() < NOW_PLAYING_MAX
bpm = bar._bpm_button.parentWidget()
assert bar._now_playing.geometry().right() < bpm.geometry().left()
assert bpm.geometry().right() < bar.width()
def test_views_seek_row_never_overlaps_the_header(qapp, tmp_path):
window = _window(qapp, tmp_path, width=800)
strip = window._library_view.header
seek = window._transport._slider.parentWidget()
assert _gx(strip, strip.width()) <= _gx(seek)
def test_long_name_shrinks_then_elides(qapp):
label = FitLabel()
base = label.font().pointSize()
label.resize(120, 30)
label.setText("A short one")
assert label.text() == "A short one"
label.setText("An Extraordinarily Long Playlist Name That Goes On Forever")
assert label.font().pointSize() >= base - 2
assert label.text().endswith("…")
assert label.toolTip() == label.full_text()