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:
@@ -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
|
- **`lintunes/gui/`** — `main_window.py` assembles a top `TransportBar` over a
|
||||||
horizontal `QSplitter` (`SidebarPanel` | stacked `LibraryView`/`PlaylistView`).
|
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
|
`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
|
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
|
for every add-to-playlist path. `theme.py` applies the palette (highlight
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||||
|
|
||||||
__version__ = "0.31.2"
|
__version__ = "0.31.3"
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -14,8 +14,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from PyQt6.QtCore import pyqtSignal
|
from PyQt6.QtCore import pyqtSignal
|
||||||
from PyQt6.QtGui import QColor
|
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.gui.track_table import TrackTableView
|
||||||
from lintunes.models.playlist import PlaylistSettings
|
from lintunes.models.playlist import PlaylistSettings
|
||||||
|
|
||||||
@@ -45,12 +46,15 @@ class FollowedPlaylistView(QWidget):
|
|||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
layout.setContentsMargins(0, 0, 0, 0)
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
layout.setSpacing(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 = self.title.font()
|
||||||
font.setBold(True)
|
font.setBold(True)
|
||||||
self.title.setFont(font)
|
self.title.setFont(font)
|
||||||
self.title.setContentsMargins(8, 2, 8, 2)
|
self.title.setContentsMargins(4, 0, 0, 0)
|
||||||
layout.addWidget(self.title)
|
self.title.hide()
|
||||||
|
self.header = self.title
|
||||||
|
self.title.header_owner = self
|
||||||
self.table = TrackTableView(playlist_mode=True)
|
self.table = TrackTableView(playlist_mode=True)
|
||||||
self.table.set_read_only(True)
|
self.table.set_read_only(True)
|
||||||
self.table.set_content_editable(False)
|
self.table.set_content_editable(False)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from lintunes.cassette.friend_library import (
|
|||||||
from lintunes.cassette.state import in_dir
|
from lintunes.cassette.state import in_dir
|
||||||
from lintunes.gui import cassette_art
|
from lintunes.gui import cassette_art
|
||||||
from lintunes.gui.cassette_art import OFF, ON, PARTIAL
|
from lintunes.gui.cassette_art import OFF, ON, PARTIAL
|
||||||
|
from lintunes.gui.fit_label import FitLabel
|
||||||
from lintunes.models.playlist import PlaylistSettings
|
from lintunes.models.playlist import PlaylistSettings
|
||||||
|
|
||||||
FUZZY_GREY = QColor("#9a9a9a")
|
FUZZY_GREY = QColor("#9a9a9a")
|
||||||
@@ -73,12 +74,15 @@ class FriendPlaylistPane(QWidget):
|
|||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
layout.setContentsMargins(0, 0, 0, 0)
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
layout.setSpacing(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 = self.title.font()
|
||||||
font.setBold(True)
|
font.setBold(True)
|
||||||
self.title.setFont(font)
|
self.title.setFont(font)
|
||||||
self.title.setContentsMargins(8, 2, 8, 2)
|
self.title.setContentsMargins(4, 0, 0, 0)
|
||||||
layout.addWidget(self.title)
|
self.title.hide()
|
||||||
|
self.header = self.title
|
||||||
|
self.title.header_owner = self
|
||||||
self.table = TrackTableView(playlist_mode=True)
|
self.table = TrackTableView(playlist_mode=True)
|
||||||
self.table.set_read_only(True)
|
self.table.set_read_only(True)
|
||||||
self.table.set_content_editable(False)
|
self.table.set_content_editable(False)
|
||||||
|
|||||||
@@ -169,6 +169,10 @@ class _BrowserList(QListWidget):
|
|||||||
return item.text()
|
return item.text()
|
||||||
|
|
||||||
|
|
||||||
|
# The search box fills the header row's slot up to this width.
|
||||||
|
SEARCH_MAX_WIDTH = 320
|
||||||
|
|
||||||
|
|
||||||
class LibraryView(TableSettingsMixin, QWidget):
|
class LibraryView(TableSettingsMixin, QWidget):
|
||||||
"""All-tracks view with the iTunes-style 3-column browser (Ctrl+B)."""
|
"""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.setContentsMargins(0, 0, 0, 0)
|
||||||
layout.setSpacing(0)
|
layout.setSpacing(0)
|
||||||
|
|
||||||
# Header strip matching the sidebar's Library button / playlist name
|
# Header strip hosting the search box. Not in this view's layout:
|
||||||
# band, so the browser+tracklist top aligns with the playlist tree.
|
# MainWindow shows the current view's ``header`` in the transport
|
||||||
# It hosts the search box, which thereby sits directly right of the
|
# bar's header row, right of the Library button, where a playlist's
|
||||||
# Library button across the splitter.
|
# name goes (TransportBar.set_view_header).
|
||||||
self._top_strip = QWidget()
|
self._top_strip = QWidget(self)
|
||||||
layout.addWidget(self._top_strip)
|
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
|
# Search: a debounced filter box that narrows the track list and the
|
||||||
# right (iTunes-style). It narrows the track list and the browser
|
# browser columns together.
|
||||||
# columns together.
|
|
||||||
self._search_text = ""
|
self._search_text = ""
|
||||||
self._search = QLineEdit()
|
self._search = QLineEdit()
|
||||||
self._search.setPlaceholderText("search")
|
self._search.setPlaceholderText("search")
|
||||||
self._search.setClearButtonEnabled(True)
|
self._search.setClearButtonEnabled(True)
|
||||||
|
self._search.setMaximumWidth(SEARCH_MAX_WIDTH)
|
||||||
strip_row = QHBoxLayout(self._top_strip)
|
strip_row = QHBoxLayout(self._top_strip)
|
||||||
strip_row.setContentsMargins(4, 2, 4, 2)
|
strip_row.setContentsMargins(0, 2, 0, 2)
|
||||||
strip_row.addStretch(2)
|
|
||||||
strip_row.addWidget(self._search, 1)
|
strip_row.addWidget(self._search, 1)
|
||||||
|
strip_row.addStretch(0)
|
||||||
|
|
||||||
self._search_timer = QTimer(self)
|
self._search_timer = QTimer(self)
|
||||||
self._search_timer.setSingleShot(True)
|
self._search_timer.setSingleShot(True)
|
||||||
|
|||||||
@@ -135,6 +135,18 @@ class MainWindow(QMainWindow):
|
|||||||
self._content.addWidget(self._playlist_view)
|
self._content.addWidget(self._playlist_view)
|
||||||
self._content.setCurrentWidget(self._library_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
|
# Bottom status bar, left to right: version/self-update button, then a
|
||||||
# centered totals readout. Both are non-permanent widgets, so a
|
# centered totals readout. Both are non-permanent widgets, so a
|
||||||
# transient showMessage (import/scrobble/error/update text) replaces
|
# transient showMessage (import/scrobble/error/update text) replaces
|
||||||
@@ -298,6 +310,10 @@ class MainWindow(QMainWindow):
|
|||||||
self._version_button.refresh_theme()
|
self._version_button.refresh_theme()
|
||||||
self._sidebar.refresh_style()
|
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):
|
def apply_ui_metrics(self):
|
||||||
metrics = theme.scale_metrics(self._prefs)
|
metrics = theme.scale_metrics(self._prefs)
|
||||||
for view in (self._library_view, self._playlist_view):
|
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._library_view.set_header_height(metrics["header_strip"])
|
||||||
self._playlist_view.set_header_height(metrics["header_strip"])
|
self._playlist_view.set_header_height(metrics["header_strip"])
|
||||||
self._sidebar.apply_metrics(metrics["header_strip"])
|
self._sidebar.apply_metrics(metrics["header_strip"])
|
||||||
|
self._transport.set_header_height(metrics["header_strip"])
|
||||||
self.statusBar().setMinimumHeight(metrics["status_height"])
|
self.statusBar().setMinimumHeight(metrics["status_height"])
|
||||||
|
|
||||||
def _show_preferences(self):
|
def _show_preferences(self):
|
||||||
|
|||||||
@@ -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 PyQt6.QtCore import pyqtSignal
|
||||||
|
|
||||||
|
from lintunes.gui.fit_label import FitLabel
|
||||||
from lintunes.gui.table_settings import TableSettingsMixin
|
from lintunes.gui.table_settings import TableSettingsMixin
|
||||||
from lintunes.gui.track_table import TrackTableView, parse_tracks_mime
|
from lintunes.gui.track_table import TrackTableView, parse_tracks_mime
|
||||||
from lintunes.gui.playlist_ops import add_tracks_with_dup_check
|
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.setContentsMargins(0, 0, 0, 0)
|
||||||
layout.setSpacing(0)
|
layout.setSpacing(0)
|
||||||
|
|
||||||
self._name_label = QLabel()
|
# The playlist's name. Not in this view's layout: it is this view's
|
||||||
self._name_label.setStyleSheet("font-weight: bold; padding: 0 8px;")
|
# ``header``, shown in the transport bar's header row (see
|
||||||
self._name_label.setFixedHeight(34)
|
# TransportBar.set_view_header), shrinking a little when it's long.
|
||||||
layout.addWidget(self._name_label)
|
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)
|
self.table = TrackTableView(playlist_mode=True)
|
||||||
layout.addWidget(self.table)
|
layout.addWidget(self.table)
|
||||||
|
|||||||
@@ -60,7 +60,10 @@ class SidebarPanel(QWidget):
|
|||||||
top_row.addWidget(self.library_button, stretch=1)
|
top_row.addWidget(self.library_button, stretch=1)
|
||||||
top_row.addWidget(self.friend_button)
|
top_row.addWidget(self.friend_button)
|
||||||
self.refresh_style()
|
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._lists = QStackedWidget()
|
||||||
self.tree = PlaylistTree(manager)
|
self.tree = PlaylistTree(manager)
|
||||||
|
|||||||
+199
-20
@@ -2,9 +2,9 @@ import time
|
|||||||
|
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QWidget, QFrame, QHBoxLayout, QVBoxLayout, QLabel, QToolButton, QSlider,
|
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 PyQt6.QtGui import QColor, QFont
|
||||||
|
|
||||||
from lintunes.tap_tempo import TapTempo
|
from lintunes.tap_tempo import TapTempo
|
||||||
@@ -16,10 +16,20 @@ from lintunes import theme
|
|||||||
|
|
||||||
ICON_GRAY = QColor("#4A4A4A")
|
ICON_GRAY = QColor("#4A4A4A")
|
||||||
|
|
||||||
# Height floor for the top control panel: the row of CONTROL_HEIGHT boxes and
|
# Breathing room between the menu bar and the row of boxes.
|
||||||
# now-playing panel, the seek row hanging under the panel (~17px at medium
|
TOP_MARGIN = 9
|
||||||
# scale), and the 3px margins. Any leftover sits below the seek row.
|
# Between the row of boxes and the header row under it.
|
||||||
BAR_HEIGHT = 86
|
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
|
# Shared height for the boxed side controls (transport, shuffle, bpm) and the
|
||||||
# visualizer, so they line up. ~midway between their old heights: the stretchy
|
# 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).
|
# panel (the seek row sits below the panel, not beside the boxes).
|
||||||
TOP = Qt.AlignmentFlag.AlignTop
|
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:
|
def _box_style(bg: QColor, button: bool = False) -> str:
|
||||||
"""A transport box. A box that *is* one button (shuffle, bpm) gets the
|
"""A transport box. A box that *is* one button (shuffle, bpm) gets the
|
||||||
shared button face — emboss, outline, whole-box darkening on hover; the
|
shared button face — emboss, outline, whole-box darkening on hover; the
|
||||||
@@ -251,6 +265,73 @@ class BpmButton(QPushButton):
|
|||||||
self.setText("bpm")
|
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):
|
class TransportBar(QWidget):
|
||||||
"""Top bar: boxed transport buttons and shuffle (left), visualizer,
|
"""Top bar: boxed transport buttons and shuffle (left), visualizer,
|
||||||
now-playing text, seek slider, boxed bpm tap button (right)."""
|
now-playing text, seek slider, boxed bpm tap button (right)."""
|
||||||
@@ -266,8 +347,24 @@ class TransportBar(QWidget):
|
|||||||
self._current_track = None
|
self._current_track = None
|
||||||
|
|
||||||
self.setMinimumHeight(BAR_HEIGHT)
|
self.setMinimumHeight(BAR_HEIGHT)
|
||||||
layout = QHBoxLayout(self)
|
outer = QVBoxLayout(self)
|
||||||
layout.setContentsMargins(8, 3, 8, 3)
|
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
|
# Rounded button boxes carry a user-tunable grayscale fill, decoupled
|
||||||
# from the stripe color they used to share via palette(alternate-base).
|
# from the stripe color they used to share via palette(alternate-base).
|
||||||
@@ -287,6 +384,7 @@ class TransportBar(QWidget):
|
|||||||
height=CONTROL_HEIGHT, split=True)
|
height=CONTROL_HEIGHT, split=True)
|
||||||
self._boxes.append(transport_box)
|
self._boxes.append(transport_box)
|
||||||
layout.addWidget(transport_box, alignment=TOP)
|
layout.addWidget(transport_box, alignment=TOP)
|
||||||
|
gap()
|
||||||
|
|
||||||
self._shuffle_btn = FlashButton("shuffle")
|
self._shuffle_btn = FlashButton("shuffle")
|
||||||
self._shuffle_btn.setMinimumWidth(50)
|
self._shuffle_btn.setMinimumWidth(50)
|
||||||
@@ -296,12 +394,12 @@ class TransportBar(QWidget):
|
|||||||
height=CONTROL_HEIGHT, split=True)
|
height=CONTROL_HEIGHT, split=True)
|
||||||
self._boxes.append(shuffle_box)
|
self._boxes.append(shuffle_box)
|
||||||
layout.addWidget(shuffle_box, alignment=TOP)
|
layout.addWidget(shuffle_box, alignment=TOP)
|
||||||
|
gap()
|
||||||
|
|
||||||
layout.addSpacing(10)
|
|
||||||
self._visualizer = VisualizerWidget(player, prefs)
|
self._visualizer = VisualizerWidget(player, prefs)
|
||||||
self._visualizer.setFixedHeight(CONTROL_HEIGHT)
|
self._visualizer.setFixedHeight(CONTROL_HEIGHT)
|
||||||
layout.addWidget(self._visualizer, alignment=TOP)
|
layout.addWidget(self._visualizer, alignment=TOP)
|
||||||
layout.addSpacing(10)
|
gap()
|
||||||
|
|
||||||
# Master output volume: a slim horizontal slider in its own slot
|
# Master output volume: a slim horizontal slider in its own slot
|
||||||
# between the visualizer and the timeline. The HBox centers it
|
# 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_save_timer.timeout.connect(self._save_volume)
|
||||||
self._volume_slider.valueChanged.connect(self._on_volume_changed)
|
self._volume_slider.valueChanged.connect(self._on_volume_changed)
|
||||||
layout.addWidget(self._volume_slider, alignment=TOP)
|
layout.addWidget(self._volume_slider, alignment=TOP)
|
||||||
layout.addSpacing(10)
|
gap()
|
||||||
|
|
||||||
center = QVBoxLayout()
|
|
||||||
center.setSpacing(0)
|
|
||||||
|
|
||||||
# White rounded now-playing panel: holds only the song title and the
|
# White rounded now-playing panel: holds only the song title and the
|
||||||
# artist/album line, centered, filling the zone above the seek row.
|
# artist/album line, centered, filling the zone above the seek row.
|
||||||
@@ -351,7 +446,9 @@ class TransportBar(QWidget):
|
|||||||
np.addStretch(1)
|
np.addStretch(1)
|
||||||
self._apply_now_playing_font()
|
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 = QLabel("")
|
||||||
self._elapsed_label.setFixedWidth(50)
|
self._elapsed_label.setFixedWidth(50)
|
||||||
self._remaining_label = QLabel("")
|
self._remaining_label = QLabel("")
|
||||||
@@ -366,12 +463,15 @@ class TransportBar(QWidget):
|
|||||||
|
|
||||||
# The now-playing panel is exactly as tall as the boxes and shares
|
# 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;
|
# 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)
|
self._now_playing.setFixedHeight(CONTROL_HEIGHT)
|
||||||
center.addWidget(self._now_playing)
|
# Its width is set by _size_now_playing: all the room the controls
|
||||||
center.addLayout(seek_row)
|
# and minimal gaps leave, up to NOW_PLAYING_MAX; the gaps share the
|
||||||
center.addStretch(1)
|
# rest.
|
||||||
layout.addLayout(center, stretch=1)
|
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
|
# 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
|
# 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)
|
hug=True, height=CONTROL_HEIGHT, split=True)
|
||||||
self._boxes.append(bpm_box)
|
self._boxes.append(bpm_box)
|
||||||
layout.addWidget(bpm_box, alignment=TOP)
|
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
|
# Wiring
|
||||||
self._prev_btn.clicked.connect(player.previous)
|
self._prev_btn.clicked.connect(player.previous)
|
||||||
@@ -401,6 +511,75 @@ class TransportBar(QWidget):
|
|||||||
self._visualizer.cast_stop_requested.connect(
|
self._visualizer.cast_stop_requested.connect(
|
||||||
lambda: cast.disconnect())
|
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):
|
def refresh_theme(self):
|
||||||
"""Re-apply everything driven by Preferences: button glyphs (highlight
|
"""Re-apply everything driven by Preferences: button glyphs (highlight
|
||||||
color), the tunable grayscale button boxes, and the now-playing panel
|
color), the tunable grayscale button boxes, and the now-playing panel
|
||||||
|
|||||||
@@ -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):
|
def test_seek_row_hangs_under_the_panel(qapp, tmp_path):
|
||||||
bar = _build_bar(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
@@ -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
|
The Library button, the view's header (search box or playlist name) and the
|
||||||
alphabetically, or inside a folder named after the friend (a per-friend
|
seek row share one row under the transport boxes: the Library button as wide
|
||||||
setting) — and is never a Playlist of mine, so it stays out of the
|
as the sidebar, the header where the view starts, the seek row exactly under
|
||||||
library and its conflict merge.
|
the now-playing panel. The panel stops at NOW_PLAYING_MAX and the gaps between
|
||||||
* It plays from the cache: Player queues opaque keys
|
the controls share what's left.
|
||||||
("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.
|
|
||||||
"""
|
"""
|
||||||
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.gui.fit_label import FitLabel
|
||||||
from lintunes.cassette import requests as req
|
from lintunes.gui.main_window import MainWindow
|
||||||
from lintunes.cassette.service import CassetteService
|
from lintunes.gui.transport import NOW_PLAYING_MAX, NOW_PLAYING_MIN
|
||||||
from lintunes.cassette.state import (
|
from lintunes.library_manager import LibraryManager
|
||||||
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.models.library import Library
|
from lintunes.models.library import Library
|
||||||
|
from lintunes.models.playlist import Playlist
|
||||||
TOKEN = "0123456789abcdef"
|
from lintunes.models.track import Track
|
||||||
|
from lintunes.preferences import Preferences
|
||||||
|
|
||||||
|
|
||||||
def _friend_share(root, mp3_file, cached=(10,)):
|
def _window(qapp, tmp_path, width=1280):
|
||||||
theirs = Library()
|
library = Library(tracks={1: Track(track_id=1, name="One")})
|
||||||
for tid, name in ((10, "One"), (11, "Two"), (12, "Three")):
|
library.playlists["abcd1234"] = Playlist(
|
||||||
theirs.tracks[tid] = Track(track_id=tid, name=name, artist="Sam's Band",
|
persistent_id="abcd1234", name="Road Trip", track_ids=[1])
|
||||||
album="Demo", total_time=1000, size=100)
|
window = MainWindow(LibraryManager(library, tmp_path), Preferences(tmp_path))
|
||||||
theirs.playlists["P"] = Playlist(name="Road Trip", persistent_id="P",
|
window.resize(width, 500)
|
||||||
track_ids=[11, 10])
|
window.show()
|
||||||
share.publish_share(in_dir(root, TOKEN), theirs,
|
qapp.processEvents()
|
||||||
Selection(all_library=True, all_playlists=True))
|
return window
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
# ---- keys and the player ----
|
def _x(widget, window, x=0):
|
||||||
|
return widget.mapTo(window, QPoint(x, 0)).x()
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
# ---- the service never re-asks for cached songs ----
|
def _gx(widget, x=0):
|
||||||
|
return widget.mapToGlobal(QPoint(x, 0)).x()
|
||||||
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 == []
|
|
||||||
|
|
||||||
|
|
||||||
# ---- the sidebar and the view ----
|
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
|
||||||
class _Window:
|
# took the first focus and selected its first playlist.
|
||||||
def __init__(self, qapp):
|
window = _window(qapp, tmp_path)
|
||||||
from PyQt6.QtWidgets import QMainWindow, QStackedWidget
|
assert window._content.currentWidget() is window._library_view
|
||||||
from lintunes.gui.sidebar import SidebarPanel
|
assert window._sidebar.library_button.isChecked()
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
def test_library_button_spans_the_sidebar(qapp, tmp_path):
|
||||||
def followed(qapp, tmp_path, mp3_file):
|
window = _window(qapp, tmp_path)
|
||||||
from lintunes.gui.cassette_ui import CassetteUi
|
header = window.sidebar.header
|
||||||
from lintunes.library_manager import LibraryManager
|
assert header.parentWidget() is not window.sidebar
|
||||||
root = tmp_path / "root"
|
assert _gx(header) == _gx(window.sidebar)
|
||||||
_friend_share(root, mp3_file, cached=(10,))
|
assert header.width() == window.sidebar.width()
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class TestSidebar:
|
def test_search_then_title_sit_where_the_view_starts(qapp, tmp_path):
|
||||||
def test_mixed_in_alphabetically(self, followed):
|
window = _window(qapp, tmp_path)
|
||||||
ui, window, _, _ = followed
|
search_strip = window._library_view.header
|
||||||
tree = window.sidebar.tree
|
assert search_strip.isVisible()
|
||||||
names = [tree.topLevelItem(i).text(0) for i in range(tree.topLevelItemCount())]
|
assert _gx(search_strip) == _gx(window._content)
|
||||||
assert names == ["Morning", "Road Trip", "Zebra"]
|
|
||||||
|
|
||||||
def test_in_a_friend_folder(self, followed):
|
window._show_playlist("abcd1234")
|
||||||
ui, window, service, _ = followed
|
qapp.processEvents()
|
||||||
service._state.friends[TOKEN].layout = "folder"
|
label = window._playlist_view.header
|
||||||
ui._refresh_followed()
|
assert label.isVisible() and label.full_text() == "Road Trip"
|
||||||
tree = window.sidebar.tree
|
assert _gx(label) == _gx(window._content)
|
||||||
names = [tree.topLevelItem(i).text(0) for i in range(tree.topLevelItemCount())]
|
# The search strip went home to its view, hidden.
|
||||||
assert names == ["Morning", "Sam", "Zebra"]
|
assert not search_strip.isVisible()
|
||||||
folder = tree.topLevelItem(1)
|
assert search_strip.parentWidget() is window._library_view
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class TestFollowedView:
|
def test_header_row_shares_the_seek_band(qapp, tmp_path):
|
||||||
def test_cached_plays_uncached_is_grey(self, followed):
|
window = _window(qapp, tmp_path)
|
||||||
ui, window, _, _ = followed
|
bar = window._transport
|
||||||
ui.show_followed(f"cassette:{TOKEN}:P")
|
panel = bar._now_playing
|
||||||
view = ui.followed_view
|
seek = bar._slider.parentWidget()
|
||||||
assert [t.track_id for t in view.table.model_.tracks] == [11, 10]
|
# Under the panel, edge to edge, and level with the Library button.
|
||||||
assert view._foreground(view.table.model_.tracks[0]) is not None # 11 not here
|
assert _gx(seek) == _gx(panel)
|
||||||
assert view._foreground(view.table.model_.tracks[1]) is None # 10 cached
|
assert seek.width() == panel.width()
|
||||||
view._on_double_click(view.table.model_.index(1, 0))
|
library = window.sidebar.library_button
|
||||||
assert window.played == [([track_key(TOKEN, 10)], 0,
|
row = bar._header_row
|
||||||
f"followed:cassette:{TOKEN}:P")]
|
assert row.geometry().top() > panel.geometry().bottom()
|
||||||
view._on_double_click(view.table.model_.index(0, 0)) # not here yet
|
assert library.mapTo(bar, QPoint()).y() >= row.geometry().top()
|
||||||
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"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestSettings:
|
def test_panel_caps_and_gaps_take_the_rest(qapp, tmp_path):
|
||||||
def test_follow_settings_on_the_friend_page(self, qapp, tmp_path):
|
window = _window(qapp, tmp_path, width=1600)
|
||||||
from lintunes.gui.sync_settings_dialog import SyncSettingsDialog
|
bar = window._transport
|
||||||
from tests.test_round64 import _NoProbe
|
assert bar._now_playing.width() == NOW_PLAYING_MAX
|
||||||
state = CassetteState()
|
window.resize(800, 500)
|
||||||
state.friends["t"] = Friend(token="t", device_id="D", name="Sam")
|
qapp.processEvents()
|
||||||
dialog = SyncSettingsDialog(state, tmp_path, {}, probe=_NoProbe())
|
assert NOW_PLAYING_MIN <= bar._now_playing.width() < NOW_PLAYING_MAX
|
||||||
page = dialog.friend_pages["t"]
|
bpm = bar._bpm_button.parentWidget()
|
||||||
assert page.layout_folder.isChecked() and page.mode_separate.isChecked()
|
assert bar._now_playing.geometry().right() < bpm.geometry().left()
|
||||||
page.layout_mixed.setChecked(True)
|
assert bpm.geometry().right() < bar.width()
|
||||||
page.mode_library.setChecked(True)
|
|
||||||
friend = dialog.state.friends["t"]
|
|
||||||
assert (friend.layout, friend.followed_mode) == ("mixed", "library")
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user