diff --git a/CLAUDE.md b/CLAUDE.md index d06b8bc..a38654a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,9 +127,12 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal `CastBrowser`; `sink.py` is the `PlaybackSink`; `controller.py` owns the session and its own `SleepInhibitor`. **pychromecast is imported lazily**, never at module scope, so the app still launches where the dep isn't installed yet. - While casting there is no local PCM, so the visualizer shows the cast glyph - instead of bars, and position comes from a 500 ms poll of - `adjusted_current_time` (only trusted while PLAYING — it creeps while paused). + While casting there is no local PCM, so the **visualizer panel doubles as the + cast indicator**: it shows the cast glyph instead of bars and a click there + stops casting (the brightness cycle is suppressed — it means nothing with no + bars). That is the only cast control in the transport bar. Position comes from + a 500 ms poll of `adjusted_current_time` (only trusted while PLAYING — it + creeps while paused). ## Conventions & gotchas diff --git a/lintunes/__init__.py b/lintunes/__init__.py index 08a06a3..421e480 100644 --- a/lintunes/__init__.py +++ b/lintunes/__init__.py @@ -1,3 +1,3 @@ """LinTunes — iTunes-style music library manager and player for Linux.""" -__version__ = "0.5.0" +__version__ = "0.5.1" diff --git a/lintunes/gui/cast_indicator.py b/lintunes/gui/cast_indicator.py deleted file mode 100644 index 76b3389..0000000 --- a/lintunes/gui/cast_indicator.py +++ /dev/null @@ -1,55 +0,0 @@ -"""The small cast glyph under the volume slider. Click to disconnect.""" - -from PyQt6.QtCore import QSize, Qt -from PyQt6.QtGui import QIcon -from PyQt6.QtWidgets import QToolButton - -from lintunes.gui.icons import transport_icon - -HEIGHT = 18 - - -class CastIndicator(QToolButton): - """Shows what playback is coming out of, when it isn't this machine. - - Never hidden, even when nothing is connected. `setVisible(False)` would - take it out of the layout's height budget, which lifts the volume slider - the instant you connect and drops it again when you disconnect — so idle - is a visible widget with an empty icon, holding its space. - """ - - def __init__(self, parent=None): - super().__init__(parent) - self._connected = False - self._device = "" - self.setAutoRaise(True) - self.setFocusPolicy(Qt.FocusPolicy.NoFocus) - self.setFixedHeight(HEIGHT) - self.setIconSize(QSize(HEIGHT, HEIGHT)) - self._apply() - - def set_connected(self, connected: bool, device_name: str = ""): - self._connected = bool(connected) - self._device = device_name or "" - self._apply() - - def refresh_theme(self): - self._apply() - - def sizeHint(self) -> QSize: - return QSize(HEIGHT + 6, HEIGHT) - - def _apply(self): - if self._connected: - self.setIcon(transport_icon( - "cast_connected", self.palette().highlight().color())) - name = self._device or "a Chromecast" - self.setToolTip(f"Casting to {name} — click to stop") - self.setCursor(Qt.CursorShape.PointingHandCursor) - self.setEnabled(True) - else: - # Empty icon rather than a hidden widget: holds the slot open. - self.setIcon(QIcon()) - self.setToolTip("") - self.setCursor(Qt.CursorShape.ArrowCursor) - self.setEnabled(False) diff --git a/lintunes/gui/icons.py b/lintunes/gui/icons.py index 47016fe..69b14ce 100644 --- a/lintunes/gui/icons.py +++ b/lintunes/gui/icons.py @@ -10,14 +10,19 @@ _cache: dict[tuple, QIcon] = {} SIZE = 20 -def transport_icon(kind: str, color: QColor) -> QIcon: - key = (kind, color.name()) +def transport_icon(kind: str, color: QColor, size: int = SIZE) -> QIcon: + """A glyph painted at `size` px. The drawers work in a fixed SIZE-square + coordinate space and the painter is scaled to fit, so a bigger icon is + re-rendered crisply rather than being a blown-up 20px pixmap.""" + key = (kind, color.name(), size) if key in _cache: return _cache[key] - pixmap = QPixmap(SIZE, SIZE) + pixmap = QPixmap(size, size) pixmap.fill(Qt.GlobalColor.transparent) painter = QPainter(pixmap) painter.setRenderHint(QPainter.RenderHint.Antialiasing) + if size != SIZE: + painter.scale(size / SIZE, size / SIZE) painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(color) _DRAWERS[kind](painter, color) @@ -69,8 +74,10 @@ def _shuffle(p, color): p.drawPolygon(QPolygonF([QPointF(15, 11), QPointF(19, 14), QPointF(15, 17)])) -def _cast_outline(p, color, filled: bool): - """The standard cast glyph: a screen with signal arcs at its lower left.""" +def _cast(p, color): + """The standard cast glyph: a screen with signal arcs at its lower left. + The screen is lit rather than empty — it is only ever drawn while a device + is actually receiving.""" pen = QPen(color, 1.6) pen.setCapStyle(Qt.PenCapStyle.RoundCap) p.setPen(pen) @@ -80,14 +87,12 @@ def _cast_outline(p, color, filled: bool): QPointF(6.5, 15.5), QPointF(17.5, 15.5), QPointF(17.5, 4.5), QPointF(3.5, 4.5), QPointF(3.5, 7.0), ])) - if filled: - # "Receiving": the screen is lit rather than empty. - p.setPen(Qt.PenStyle.NoPen) - p.setBrush(color) - p.drawRect(QRectF(8.5, 8.0, 7.2, 5.8)) - p.setPen(pen) - p.setBrush(Qt.BrushStyle.NoBrush) + p.setPen(Qt.PenStyle.NoPen) + p.setBrush(color) + p.drawRect(QRectF(8.5, 8.0, 7.2, 5.8)) # Two arcs radiating from the corner (16ths of a degree, 0..90). + p.setPen(pen) + p.setBrush(Qt.BrushStyle.NoBrush) for radius in (4.5, 8.0): p.drawArc(QRectF(3.5 - radius, 15.5 - radius, radius * 2, radius * 2), 0, 90 * 16) @@ -97,14 +102,6 @@ def _cast_outline(p, color, filled: bool): p.drawEllipse(QPointF(3.8, 15.2), 1.5, 1.5) -def _cast(p, color): - _cast_outline(p, color, filled=False) - - -def _cast_connected(p, color): - _cast_outline(p, color, filled=True) - - _DRAWERS = { "play": _play, "pause": _pause, @@ -112,5 +109,4 @@ _DRAWERS = { "previous": _previous, "shuffle": _shuffle, "cast": _cast, - "cast_connected": _cast_connected, } diff --git a/lintunes/gui/transport.py b/lintunes/gui/transport.py index efdf845..20e59fd 100644 --- a/lintunes/gui/transport.py +++ b/lintunes/gui/transport.py @@ -8,7 +8,6 @@ from PyQt6.QtCore import Qt, QTimer, pyqtSignal from PyQt6.QtGui import QColor, QFont from lintunes.tap_tempo import TapTempo -from lintunes.gui.cast_indicator import CastIndicator from lintunes.gui.icons import transport_icon from lintunes.gui.track_table import format_time from lintunes.gui.visualizer import VisualizerWidget @@ -282,9 +281,8 @@ class TransportBar(QWidget): layout.addSpacing(10) # Master output volume: a slim horizontal slider in its own slot - # between the visualizer and the timeline, with the cast indicator - # parked directly beneath it. The column is shorter than the boxes - # beside it, so the HBox still centers it and BAR_HEIGHT is untouched. + # between the visualizer and the timeline. The HBox centers it + # vertically, so nothing stacks above or below it (iTunes-style). self._volume_slider = ClickJumpSlider() self._volume_slider.setRange(0, 100) self._volume_slider.setFixedWidth(96) @@ -300,15 +298,7 @@ class TransportBar(QWidget): self._volume_save_timer.setInterval(400) self._volume_save_timer.timeout.connect(self._save_volume) self._volume_slider.valueChanged.connect(self._on_volume_changed) - volume_col = QVBoxLayout() - volume_col.setSpacing(2) - volume_col.setContentsMargins(0, 0, 0, 0) - volume_col.addWidget(self._volume_slider, - alignment=Qt.AlignmentFlag.AlignHCenter) - self._cast_indicator = CastIndicator() - volume_col.addWidget(self._cast_indicator, - alignment=Qt.AlignmentFlag.AlignHCenter) - layout.addLayout(volume_col) + layout.addWidget(self._volume_slider) layout.addSpacing(10) center = QVBoxLayout() @@ -377,11 +367,10 @@ class TransportBar(QWidget): player.position_changed.connect(self._on_position_changed) player.duration_changed.connect(self._on_duration_changed) if cast is not None: - cast.connected.connect( - lambda name: self._cast_indicator.set_connected(True, name)) - cast.disconnected.connect( - lambda _reason: self._cast_indicator.set_connected(False)) - self._cast_indicator.clicked.connect(lambda: cast.disconnect()) + # While casting, the visualizer panel *is* the cast indicator — + # there is no spectrum to show, so a click there stops casting. + self._visualizer.cast_stop_requested.connect( + lambda: cast.disconnect()) def refresh_theme(self): """Re-apply everything driven by Preferences: button glyphs (highlight @@ -395,7 +384,6 @@ class TransportBar(QWidget): box.setStyleSheet(_box_style(box_bg)) self._apply_now_playing_bg() self._apply_now_playing_font() - self._cast_indicator.refresh_theme() self._visualizer.update() # dim-mode bar color may have changed def _apply_now_playing_bg(self): diff --git a/lintunes/gui/visualizer.py b/lintunes/gui/visualizer.py index 1132b5b..446fff7 100644 --- a/lintunes/gui/visualizer.py +++ b/lintunes/gui/visualizer.py @@ -7,7 +7,7 @@ no extra sync buffering is needed — we just FFT the newest samples. import numpy as np from PyQt6.QtWidgets import QWidget -from PyQt6.QtCore import Qt, QTimer, QRectF +from PyQt6.QtCore import Qt, QTimer, QRectF, pyqtSignal from PyQt6.QtGui import QPainter, QColor, QPainterPath, QPen from PyQt6.QtMultimedia import QAudioFormat @@ -22,7 +22,7 @@ FRAME_MS = 16 # ~60 fps DECAY = 0.82 # slow decay; attack is instant FLOOR_DB = -55.0 RADIUS = 8 # rounded corners, matching the transport button boxes -ICON_SIZE = 20 # the cast glyph shown in place of bars while casting +ICON_SIZE = 40 # the cast glyph shown in place of bars while casting # Visualizer click cycles through these brightness modes in order. MODE_ON = "on" @@ -43,8 +43,13 @@ class VisualizerWidget(QWidget): On = highlight-colored bars; dim = light-gray bars just darker than the panel; off = blank. + + While casting there is no local PCM to analyze, so the panel doubles as the + cast indicator: it shows the device glyph and a click stops casting. """ + cast_stop_requested = pyqtSignal() + def __init__(self, player, prefs=None, parent=None): super().__init__(parent) self._player = player @@ -59,6 +64,7 @@ class VisualizerWidget(QWidget): # True while audio is coming out of something other than this machine # (a Chromecast), so there is no decoded PCM to draw. self._no_pcm = False + self._device = "" self.setFixedWidth(140) self.setMinimumHeight(36) @@ -75,16 +81,20 @@ class VisualizerWidget(QWidget): player.sink_changed.connect(self._on_sink_changed) def _on_sink_changed(self, sink): - """Casting means no local decode, so there is nothing to draw. Say so - with the cast glyph rather than animating a flat line off a buffer of - zeros 30 times a second.""" + """Casting means no local decode, so there is nothing to draw. The + panel becomes the cast indicator instead: it shows where the audio + went, and clicking it stops casting.""" self._no_pcm = not getattr(sink, "provides_pcm", True) if self._no_pcm: + self._device = getattr(sink, "name", "") or "a Chromecast" self._timer.stop() self._clear() - self.setToolTip("No spectrum while casting") + self.setToolTip(f"Casting to {self._device} — click to stop") + self.setCursor(Qt.CursorShape.PointingHandCursor) else: + self._device = "" self.setToolTip("Click to cycle: on / dim / off") + self.setCursor(Qt.CursorShape.ArrowCursor) if self._mode != MODE_OFF and self._player.is_playing(): self._timer.start() self.update() @@ -138,6 +148,12 @@ class VisualizerWidget(QWidget): self.update() def mousePressEvent(self, event): + if self._no_pcm: + # While casting the panel is the cast indicator, not a spectrum: + # cycling its brightness would be meaningless, so a click stops + # casting instead. + self.cast_stop_requested.emit() + return # Cycle on -> dim -> off -> on. idx = _MODE_CYCLE.index(self._mode) self._mode = _MODE_CYCLE[(idx + 1) % len(_MODE_CYCLE)] @@ -206,8 +222,9 @@ class VisualizerWidget(QWidget): if self._no_pcm: # Casting: the audio never passes through this machine, so show # where it went instead of pretending to analyze silence. - glyph = transport_icon("cast_connected", self._bar_color()).pixmap( - ICON_SIZE, ICON_SIZE) + glyph = transport_icon( + "cast", self.palette().highlight().color(), + size=ICON_SIZE).pixmap(ICON_SIZE, ICON_SIZE) painter.drawPixmap( (self.width() - ICON_SIZE) // 2, (self.height() - ICON_SIZE) // 2, glyph) diff --git a/tasks-done.md b/tasks-done.md index 3389885..c3322a1 100644 --- a/tasks-done.md +++ b/tasks-done.md @@ -1,5 +1,21 @@ ## Done +### Round 31 (2026-08-13) — One cast control, not two (v0.5.1) + +The cast glyph appeared both under the volume slider and inside the visualizer, +which was redundant. Dropped the volume-slider indicator (`gui/cast_indicator.py` +deleted); the visualizer panel is now the single cast control. + +- [x] **The visualizer panel is the indicator** — bigger glyph (20 → 40 px), + always painted in the theme highlight rather than following the brightness + mode, and clicking it stops casting instead of cycling on/dim/off (which + meant nothing with no bars to dim). New `cast_stop_requested` signal. +- [x] **`transport_icon(kind, color, size=…)`** scales the painter instead of + upscaling a 20px pixmap, so the panel-sized glyph is crisp; `size` joins the + cache key. +- [x] Collapsed the `cast`/`cast_connected` glyph pair into one — only the + connected state was ever drawn. + ### Round 30 (2026-08-13) — Album art on the cast device (v0.5.0) The Chromecast is on a TV, so it should show the cover. `play_media` now diff --git a/tests/test_round29.py b/tests/test_round29.py index c152192..866ed8b 100644 --- a/tests/test_round29.py +++ b/tests/test_round29.py @@ -29,7 +29,6 @@ from lintunes.cast.support import ( content_type_for, image_type_for, local_ip_for, parse_range, uncastable_reason) from lintunes.gui.cast_dialog import ChromecastDialog -from lintunes.gui.cast_indicator import CastIndicator from lintunes.gui.icons import transport_icon from lintunes.gui.spinner import Spinner from lintunes.models import Track @@ -866,32 +865,8 @@ def _device(uuid="u1", name="Kitchen", model="Chromecast Audio"): host="10.0.0.5", port=8009) -class TestCastIndicator: - def test_holds_its_space_when_idle(self, qapp): - indicator = CastIndicator() - indicator.show() - # Hiding it would collapse the layout and shift the volume slider the - # moment you connect — idle is a visible widget with no icon. - assert indicator.icon().isNull() - assert not indicator.isEnabled() - idle_height = indicator.sizeHint().height() - - indicator.set_connected(False) - assert not indicator.isHidden() # never hides itself - - indicator.set_connected(True, "Kitchen") - assert not indicator.icon().isNull() - assert indicator.isEnabled() - assert "Kitchen" in indicator.toolTip() - assert indicator.sizeHint().height() == idle_height - - indicator.set_connected(False) - assert indicator.icon().isNull() - - class TestTransportLayout: - def test_cast_indicator_does_not_move_the_volume_slider( - self, qapp, tmp_path): + def test_casting_leaves_the_bar_layout_alone(self, qapp, tmp_path): from lintunes.gui.transport import BAR_HEIGHT, TransportBar from lintunes.preferences import Preferences @@ -901,14 +876,40 @@ class TestTransportLayout: bar.show() idle_y = bar._volume_slider.pos().y() - bar._cast_indicator.set_connected(True, "Kitchen") + # The visualizer panel doubles as the cast indicator, so connecting + # adds no widget and nothing moves. + player.set_sink(_FakeSink()) bar.layout().activate() assert bar._volume_slider.pos().y() == idle_y - # The whole column has to stay inside the bar's documented height. assert bar.sizeHint().height() <= BAR_HEIGHT bar.hide() + def test_clicking_the_visualizer_while_casting_disconnects( + self, qapp, tmp_path): + from lintunes.gui.transport import TransportBar + from lintunes.preferences import Preferences + + player = _player(qapp, []) + cast = MagicMock() + bar = TransportBar(player, MagicMock(), Preferences(tmp_path), + cast=cast) + vis = bar._visualizer + before = vis._mode + + # Not casting: a click still cycles the brightness as it always did. + vis.mousePressEvent(None) + assert vis._mode != before + cast.disconnect.assert_not_called() + + player.set_sink(_FakeSink()) + mode_while_casting = vis._mode + vis.mousePressEvent(None) + + cast.disconnect.assert_called_once() + # Brightness must not cycle — it means nothing when there are no bars. + assert vis._mode == mode_while_casting + class TestCastDialog: def test_devices_appear_and_disappear(self, qapp): @@ -966,13 +967,24 @@ class TestSpinner: class TestCastIcons: - def test_cast_glyphs_render_and_differ(self, qapp): + def test_cast_glyph_renders(self, qapp): from PyQt6.QtGui import QColor - idle = transport_icon("cast", QColor("#4A4A4A")) - live = transport_icon("cast_connected", QColor("#4A4A4A")) - assert not idle.isNull() and not live.isNull() - assert (idle.pixmap(20, 20).toImage() - != live.pixmap(20, 20).toImage()) + assert not transport_icon("cast", QColor("#4A4A4A")).isNull() + + def test_a_bigger_glyph_is_repainted_not_upscaled(self, qapp): + from PyQt6.QtGui import QColor + big = transport_icon("cast", QColor("#4A4A4A"), size=40) + # Rendered at the requested size rather than a stretched 20px pixmap, + # so the panel-sized glyph stays crisp. + assert big.pixmap(40, 40).size().width() == 40 + assert big.availableSizes()[0].width() == 40 + + def test_size_is_part_of_the_cache_key(self, qapp): + from PyQt6.QtGui import QColor + small = transport_icon("cast", QColor("#4A4A4A"), size=20) + big = transport_icon("cast", QColor("#4A4A4A"), size=40) + assert small.availableSizes()[0].width() == 20 + assert big.availableSizes()[0].width() == 40 class TestVisualizerWhileCasting: @@ -992,3 +1004,17 @@ class TestVisualizerWhileCasting: player.set_sink(None) assert vis._no_pcm is False + + def test_names_the_device_it_is_casting_to(self, qapp, tmp_path): + from lintunes.gui.visualizer import VisualizerWidget + from lintunes.preferences import Preferences + + player = _player(qapp, []) + vis = VisualizerWidget(player, Preferences(tmp_path)) + + player.set_sink(_FakeSink()) # _FakeSink.name == "Kitchen" + assert "Kitchen" in vis.toolTip() + assert "click to stop" in vis.toolTip() + + player.set_sink(None) + assert "cycle" in vis.toolTip() # back to the brightness control