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
+8
View File
@@ -129,6 +129,14 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
- **`lintunes/gui/`** — `main_window.py` assembles a top `TransportBar` over a
horizontal `QSplitter` (`SidebarPanel` | stacked `LibraryView`/`PlaylistView`).
The bar's second row (`transport.HeaderRow`, placed by hand) holds three
things owned elsewhere: the sidebar's Library button (`SidebarPanel.header`,
kept as wide as the sidebar via `track_split`), the shown view's `header`
(search box, playlist name — mounted on the content stack's
`currentChanged`, returned to its view when it goes), and the seek row,
exactly under the now-playing panel. Since the Library button left the
sidebar, `MainWindow` must hand it the first focus, or the playlist tree
takes it and opens its first playlist at startup.
`track_table.py` is the shared track grid (drag/drop, copy/paste, drop
indicator). `playlist_ops.py::add_tracks_with_dup_check` is the single funnel
for every add-to-playlist path. `theme.py` applies the palette (highlight
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.31.2"
__version__ = "0.31.3"
+54
View File
@@ -0,0 +1,54 @@
"""A one-line label that shrinks its text a little before it gives up and
elides — for a playlist name in the header row, where the room left between
the Library button and the seek row depends on the window."""
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QFontMetrics
from PyQt6.QtWidgets import QLabel, QSizePolicy
# How far below its normal size the text may go (points) before eliding.
MAX_SHRINK_PT = 2
class FitLabel(QLabel):
def __init__(self, text: str = "", parent=None):
super().__init__(parent)
self._full = ""
self._base_pt = 0
self.setSizePolicy(QSizePolicy.Policy.Ignored,
QSizePolicy.Policy.Preferred)
self.setAlignment(Qt.AlignmentFlag.AlignVCenter
| Qt.AlignmentFlag.AlignLeft)
self.setText(text)
def setText(self, text: str):
self._full = text or ""
self.setToolTip("")
self._fit()
def full_text(self) -> str:
return self._full
def resizeEvent(self, event):
super().resizeEvent(event)
self._fit()
def _fit(self):
font = self.font()
if not self._base_pt:
self._base_pt = font.pointSize()
room = self.contentsRect().width() - 2 * self.margin()
text = self._full
for pt in range(self._base_pt, self._base_pt - MAX_SHRINK_PT - 1, -1):
font.setPointSize(max(1, pt))
if QFontMetrics(font).horizontalAdvance(text) <= room:
break
else:
text = QFontMetrics(font).elidedText(
text, Qt.TextElideMode.ElideRight, max(0, room))
if font != self.font():
self.setFont(font)
# Elided: the whole name is a hover away.
self.setToolTip(self._full if text != self._full else "")
super().setText(text)
+8 -4
View File
@@ -14,8 +14,9 @@ from __future__ import annotations
from PyQt6.QtCore import pyqtSignal
from PyQt6.QtGui import QColor
from PyQt6.QtWidgets import QLabel, QVBoxLayout, QWidget
from PyQt6.QtWidgets import QVBoxLayout, QWidget
from lintunes.gui.fit_label import FitLabel
from lintunes.gui.track_table import TrackTableView
from lintunes.models.playlist import PlaylistSettings
@@ -45,12 +46,15 @@ class FollowedPlaylistView(QWidget):
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self.title = QLabel()
# Shown in the transport bar's header row, not in this layout.
self.title = FitLabel(parent=self)
font = self.title.font()
font.setBold(True)
self.title.setFont(font)
self.title.setContentsMargins(8, 2, 8, 2)
layout.addWidget(self.title)
self.title.setContentsMargins(4, 0, 0, 0)
self.title.hide()
self.header = self.title
self.title.header_owner = self
self.table = TrackTableView(playlist_mode=True)
self.table.set_read_only(True)
self.table.set_content_editable(False)
+7 -3
View File
@@ -33,6 +33,7 @@ from lintunes.cassette.friend_library import (
from lintunes.cassette.state import in_dir
from lintunes.gui import cassette_art
from lintunes.gui.cassette_art import OFF, ON, PARTIAL
from lintunes.gui.fit_label import FitLabel
from lintunes.models.playlist import PlaylistSettings
FUZZY_GREY = QColor("#9a9a9a")
@@ -73,12 +74,15 @@ class FriendPlaylistPane(QWidget):
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self.title = QLabel()
# Shown in the transport bar's header row, not in this layout.
self.title = FitLabel(parent=self)
font = self.title.font()
font.setBold(True)
self.title.setFont(font)
self.title.setContentsMargins(8, 2, 8, 2)
layout.addWidget(self.title)
self.title.setContentsMargins(4, 0, 0, 0)
self.title.hide()
self.header = self.title
self.title.header_owner = self
self.table = TrackTableView(playlist_mode=True)
self.table.set_read_only(True)
self.table.set_content_editable(False)
+17 -11
View File
@@ -169,6 +169,10 @@ class _BrowserList(QListWidget):
return item.text()
# The search box fills the header row's slot up to this width.
SEARCH_MAX_WIDTH = 320
class LibraryView(TableSettingsMixin, QWidget):
"""All-tracks view with the iTunes-style 3-column browser (Ctrl+B)."""
@@ -185,24 +189,26 @@ class LibraryView(TableSettingsMixin, QWidget):
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# Header strip matching the sidebar's Library button / playlist name
# band, so the browser+tracklist top aligns with the playlist tree.
# It hosts the search box, which thereby sits directly right of the
# Library button across the splitter.
self._top_strip = QWidget()
layout.addWidget(self._top_strip)
# Header strip hosting the search box. Not in this view's layout:
# MainWindow shows the current view's ``header`` in the transport
# bar's header row, right of the Library button, where a playlist's
# name goes (TransportBar.set_view_header).
self._top_strip = QWidget(self)
self._top_strip.hide()
self.header = self._top_strip
self._top_strip.header_owner = self
# Search: a debounced filter box occupying ~1/3 of the width, top
# right (iTunes-style). It narrows the track list and the browser
# columns together.
# Search: a debounced filter box that narrows the track list and the
# browser columns together.
self._search_text = ""
self._search = QLineEdit()
self._search.setPlaceholderText("search")
self._search.setClearButtonEnabled(True)
self._search.setMaximumWidth(SEARCH_MAX_WIDTH)
strip_row = QHBoxLayout(self._top_strip)
strip_row.setContentsMargins(4, 2, 4, 2)
strip_row.addStretch(2)
strip_row.setContentsMargins(0, 2, 0, 2)
strip_row.addWidget(self._search, 1)
strip_row.addStretch(0)
self._search_timer = QTimer(self)
self._search_timer.setSingleShot(True)
+17
View File
@@ -135,6 +135,18 @@ class MainWindow(QMainWindow):
self._content.addWidget(self._playlist_view)
self._content.setCurrentWidget(self._library_view)
# The header row under the transport boxes: the Library button over
# the sidebar, then whichever view is showing's own header (search
# box, playlist name), then the seek row.
self._transport.set_sidebar_header(self._sidebar.header)
self._transport.track_split(self._sidebar, self._content)
self._content.currentChanged.connect(self._mount_view_header)
self._mount_view_header()
# Moving the Library button out of the sidebar put it last in the
# focus chain, and a playlist tree that takes the first focus selects
# its first playlist: the window would open on that, not the library.
self._sidebar.library_button.setFocus()
# Bottom status bar, left to right: version/self-update button, then a
# centered totals readout. Both are non-permanent widgets, so a
# transient showMessage (import/scrobble/error/update text) replaces
@@ -298,6 +310,10 @@ class MainWindow(QMainWindow):
self._version_button.refresh_theme()
self._sidebar.refresh_style()
def _mount_view_header(self, _index=None):
view = self._content.currentWidget()
self._transport.set_view_header(getattr(view, "header", None))
def apply_ui_metrics(self):
metrics = theme.scale_metrics(self._prefs)
for view in (self._library_view, self._playlist_view):
@@ -305,6 +321,7 @@ class MainWindow(QMainWindow):
self._library_view.set_header_height(metrics["header_strip"])
self._playlist_view.set_header_height(metrics["header_strip"])
self._sidebar.apply_metrics(metrics["header_strip"])
self._transport.set_header_height(metrics["header_strip"])
self.statusBar().setMinimumHeight(metrics["status_height"])
def _show_preferences(self):
+13 -5
View File
@@ -1,6 +1,7 @@
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QApplication
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QApplication
from PyQt6.QtCore import pyqtSignal
from lintunes.gui.fit_label import FitLabel
from lintunes.gui.table_settings import TableSettingsMixin
from lintunes.gui.track_table import TrackTableView, parse_tracks_mime
from lintunes.gui.playlist_ops import add_tracks_with_dup_check
@@ -21,10 +22,17 @@ class PlaylistView(TableSettingsMixin, QWidget):
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self._name_label = QLabel()
self._name_label.setStyleSheet("font-weight: bold; padding: 0 8px;")
self._name_label.setFixedHeight(34)
layout.addWidget(self._name_label)
# The playlist's name. Not in this view's layout: it is this view's
# ``header``, shown in the transport bar's header row (see
# TransportBar.set_view_header), shrinking a little when it's long.
self._name_label = FitLabel(parent=self)
font = self._name_label.font()
font.setBold(True)
self._name_label.setFont(font)
self._name_label.setContentsMargins(4, 0, 0, 0)
self._name_label.hide()
self.header = self._name_label
self._name_label.header_owner = self
self.table = TrackTableView(playlist_mode=True)
layout.addWidget(self.table)
+4 -1
View File
@@ -60,7 +60,10 @@ class SidebarPanel(QWidget):
top_row.addWidget(self.library_button, stretch=1)
top_row.addWidget(self.friend_button)
self.refresh_style()
layout.addWidget(top)
# Not in this panel's layout: MainWindow lays it into the transport
# bar's header row, over this panel's width (TransportBar.track_split).
self.header = top
top.header_owner = self
self._lists = QStackedWidget()
self.tree = PlaylistTree(manager)
+199 -20
View File
@@ -2,9 +2,9 @@ import time
from PyQt6.QtWidgets import (
QWidget, QFrame, QHBoxLayout, QVBoxLayout, QLabel, QToolButton, QSlider,
QPushButton, QSizePolicy, QStyle,
QPushButton, QSizePolicy, QStyle, QLayout,
)
from PyQt6.QtCore import Qt, QTimer, pyqtSignal
from PyQt6.QtCore import Qt, QTimer, QEvent, QPoint, QRect, pyqtSignal
from PyQt6.QtGui import QColor, QFont
from lintunes.tap_tempo import TapTempo
@@ -16,10 +16,20 @@ from lintunes import theme
ICON_GRAY = QColor("#4A4A4A")
# Height floor for the top control panel: the row of CONTROL_HEIGHT boxes and
# now-playing panel, the seek row hanging under the panel (~17px at medium
# scale), and the 3px margins. Any leftover sits below the seek row.
BAR_HEIGHT = 86
# Breathing room between the menu bar and the row of boxes.
TOP_MARGIN = 9
# Between the row of boxes and the header row under it.
ROW_GAP = 4
# Side margin of the row of boxes — the Library button's own left margin, so
# the transport box and the Library button share a left edge.
SIDE_MARGIN = 8
# The now-playing panel grows with the window up to this width; past it the
# extra goes to the gaps between the controls, evenly.
NOW_PLAYING_MAX = 560
NOW_PLAYING_MIN = 220
# Floor for each of those gaps.
MIN_GAP = 12
# Shared height for the boxed side controls (transport, shuffle, bpm) and the
# visualizer, so they line up. ~midway between their old heights: the stretchy
@@ -31,6 +41,10 @@ CONTROL_HEIGHT = 62
# panel (the seek row sits below the panel, not beside the boxes).
TOP = Qt.AlignmentFlag.AlignTop
# Height floor for the whole bar: margin, the row of boxes, and the header row
# (Library button · search or playlist name · seek) at the medium UI scale.
BAR_HEIGHT = TOP_MARGIN + CONTROL_HEIGHT + ROW_GAP + 34
def _box_style(bg: QColor, button: bool = False) -> str:
"""A transport box. A box that *is* one button (shuffle, bpm) gets the
shared button face — emboss, outline, whole-box darkening on hover; the
@@ -251,6 +265,73 @@ class BpmButton(QPushButton):
self.setText("bpm")
class HeaderRow(QWidget):
"""The band under the row of boxes, laid out by hand because its three
parts answer to three different things: the Library button is as wide as
the sidebar below it, the seek row sits exactly under the now-playing
panel above it, and the current view's header (search box or playlist
name) takes what lies between — which is where that view starts."""
GAP = 6
def __init__(self, seek: QWidget, parent=None):
super().__init__(parent)
self.seek = seek
seek.setParent(self)
self.left: QWidget | None = None
self.view_header: QWidget | None = None
self._split = (0, 0) # sidebar right edge, content left edge
self._panel = QRect() # now-playing panel, in our coordinates
def set_left(self, widget: QWidget):
self.left = widget
widget.setParent(self)
widget.show()
self.place()
def set_view_header(self, widget: QWidget | None):
"""Show ``widget`` (a view's header) in the middle slot. The one it
replaces goes back to its own view, hidden, so it lives and dies with
that view."""
old = self.view_header
if old is widget:
return
if old is not None:
old.hide()
old.setParent(getattr(old, "header_owner", None))
self.view_header = widget
if widget is not None:
widget.setParent(self)
widget.show()
self.place()
def set_split(self, sidebar_right: int, content_left: int):
self._split = (sidebar_right, content_left)
self.place()
def set_panel(self, rect: QRect):
self._panel = rect
self.place()
def resizeEvent(self, event):
super().resizeEvent(event)
self.place()
def place(self):
h = self.height()
sidebar_right, content_left = self._split
if self.left is not None:
self.left.setGeometry(0, 0, sidebar_right, h)
panel = self._panel
seek_left = max(panel.left(), content_left + self.GAP)
seek_h = min(h, self.seek.sizeHint().height())
self.seek.setGeometry(seek_left, (h - seek_h) // 2,
max(0, panel.right() + 1 - seek_left), seek_h)
if self.view_header is not None:
self.view_header.setGeometry(
content_left, 0, max(0, seek_left - self.GAP - content_left), h)
class TransportBar(QWidget):
"""Top bar: boxed transport buttons and shuffle (left), visualizer,
now-playing text, seek slider, boxed bpm tap button (right)."""
@@ -266,8 +347,24 @@ class TransportBar(QWidget):
self._current_track = None
self.setMinimumHeight(BAR_HEIGHT)
layout = QHBoxLayout(self)
layout.setContentsMargins(8, 3, 8, 3)
outer = QVBoxLayout(self)
outer.setContentsMargins(0, TOP_MARGIN, 0, 0)
outer.setSpacing(ROW_GAP)
# The panel's width is set from the bar's (_size_now_playing), so the
# bar's minimum must not come from the panel's current width, or a
# window could never shrink past the width it last had.
outer.setSizeConstraint(QLayout.SizeConstraint.SetNoConstraint)
layout = QHBoxLayout()
layout.setContentsMargins(SIDE_MARGIN, 0, SIDE_MARGIN, 0)
layout.setSpacing(0)
outer.addLayout(layout)
# Knolled: every control keeps its size and the gaps between them
# are equal, growing together once the now-playing panel is as wide
# as it gets.
def gap():
layout.addSpacing(MIN_GAP)
layout.addStretch(1)
# Rounded button boxes carry a user-tunable grayscale fill, decoupled
# from the stripe color they used to share via palette(alternate-base).
@@ -287,6 +384,7 @@ class TransportBar(QWidget):
height=CONTROL_HEIGHT, split=True)
self._boxes.append(transport_box)
layout.addWidget(transport_box, alignment=TOP)
gap()
self._shuffle_btn = FlashButton("shuffle")
self._shuffle_btn.setMinimumWidth(50)
@@ -296,12 +394,12 @@ class TransportBar(QWidget):
height=CONTROL_HEIGHT, split=True)
self._boxes.append(shuffle_box)
layout.addWidget(shuffle_box, alignment=TOP)
gap()
layout.addSpacing(10)
self._visualizer = VisualizerWidget(player, prefs)
self._visualizer.setFixedHeight(CONTROL_HEIGHT)
layout.addWidget(self._visualizer, alignment=TOP)
layout.addSpacing(10)
gap()
# Master output volume: a slim horizontal slider in its own slot
# between the visualizer and the timeline. The HBox centers it
@@ -325,10 +423,7 @@ class TransportBar(QWidget):
self._volume_save_timer.timeout.connect(self._save_volume)
self._volume_slider.valueChanged.connect(self._on_volume_changed)
layout.addWidget(self._volume_slider, alignment=TOP)
layout.addSpacing(10)
center = QVBoxLayout()
center.setSpacing(0)
gap()
# White rounded now-playing panel: holds only the song title and the
# artist/album line, centered, filling the zone above the seek row.
@@ -351,7 +446,9 @@ class TransportBar(QWidget):
np.addStretch(1)
self._apply_now_playing_font()
seek_row = QHBoxLayout()
seek = QWidget()
seek_row = QHBoxLayout(seek)
seek_row.setContentsMargins(0, 0, 0, 0)
self._elapsed_label = QLabel("")
self._elapsed_label.setFixedWidth(50)
self._remaining_label = QLabel("")
@@ -366,12 +463,15 @@ class TransportBar(QWidget):
# The now-playing panel is exactly as tall as the boxes and shares
# their top edge, so boxes, visualizer, panel and bpm read as one row;
# the seek row hangs underneath the panel and any slack goes below it.
# the seek row hangs underneath the panel, in the header row.
self._now_playing.setFixedHeight(CONTROL_HEIGHT)
center.addWidget(self._now_playing)
center.addLayout(seek_row)
center.addStretch(1)
layout.addLayout(center, stretch=1)
# Its width is set by _size_now_playing: all the room the controls
# and minimal gaps leave, up to NOW_PLAYING_MAX; the gaps share the
# rest.
self._now_playing.setFixedWidth(NOW_PLAYING_MIN)
layout.addWidget(self._now_playing, alignment=TOP)
self._now_playing.installEventFilter(self)
gap()
# bpm tap button lives on the far right as its own box, top-aligned
# with the now-playing panel like the transport boxes — kept out of
@@ -382,6 +482,16 @@ class TransportBar(QWidget):
hug=True, height=CONTROL_HEIGHT, split=True)
self._boxes.append(bpm_box)
layout.addWidget(bpm_box, alignment=TOP)
self._fixed_controls = (transport_box, shuffle_box, self._visualizer,
self._volume_slider, bpm_box)
# Library button · the current view's header · seek row. MainWindow
# hands over the first two (see set_sidebar_header / set_view_header)
# and says where the sidebar ends (track_split).
self._header_row = HeaderRow(seek)
self._header_row.setFixedHeight(34)
outer.addWidget(self._header_row)
self._split_widgets: tuple = ()
# Wiring
self._prev_btn.clicked.connect(player.previous)
@@ -401,6 +511,75 @@ class TransportBar(QWidget):
self._visualizer.cast_stop_requested.connect(
lambda: cast.disconnect())
# ---- the header row ----
def set_sidebar_header(self, widget: QWidget):
"""The Library button strip, laid over the sidebar's width."""
self._header_row.set_left(widget)
def set_view_header(self, widget: QWidget | None):
"""The shown view's header (search box, playlist name), between the
Library button and the seek row."""
self._header_row.set_view_header(widget)
def set_header_height(self, height: int):
self._header_row.setFixedHeight(height)
def track_split(self, sidebar: QWidget, content: QWidget):
"""Keep the header row's Library button as wide as ``sidebar`` and
the view header starting where ``content`` does, as the splitter or
window moves them."""
self._split_widgets = (sidebar, content)
for widget in self._split_widgets:
widget.installEventFilter(self)
self._place_header()
def eventFilter(self, obj, event):
if event.type() in (QEvent.Type.Resize, QEvent.Type.Move):
self._place_header()
return super().eventFilter(obj, event)
def resizeEvent(self, event):
super().resizeEvent(event)
self._size_now_playing()
self._place_header()
def _controls_width(self) -> int:
"""Everything in the row of boxes but the now-playing panel, with
each gap at its floor."""
return (2 * SIDE_MARGIN
# A fixed width shows in the minimum, not the size hint.
+ sum(max(w.sizeHint().width(), w.minimumWidth())
for w in self._fixed_controls)
+ len(self._fixed_controls) * MIN_GAP)
def _size_now_playing(self):
room = self.width() - self._controls_width()
self._now_playing.setFixedWidth(
max(NOW_PLAYING_MIN, min(NOW_PLAYING_MAX, room)))
def minimumSizeHint(self):
hint = super().minimumSizeHint()
hint.setWidth(self._controls_width() + NOW_PLAYING_MIN)
return hint
def sizeHint(self):
hint = super().sizeHint()
hint.setWidth(self._controls_width() + NOW_PLAYING_MAX)
return hint
def _place_header(self):
row = self._header_row
def x_of(widget, x=0): # siblings, not ancestors: go via global
return row.mapFromGlobal(widget.mapToGlobal(QPoint(x, 0))).x()
row.set_panel(QRect(x_of(self._now_playing), 0,
self._now_playing.width(), row.height()))
if self._split_widgets:
sidebar, content = self._split_widgets
row.set_split(x_of(sidebar, sidebar.width()), x_of(content))
def refresh_theme(self):
"""Re-apply everything driven by Preferences: button glyphs (highlight
color), the tunable grayscale button boxes, and the now-playing panel
+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()