v0.5.1: one cast control instead of two

The cast glyph showed up both under the volume slider and inside the
visualizer, which was redundant. Dropped the volume-slider indicator
(gui/cast_indicator.py deleted) and made the visualizer panel the single
cast control — the volume slider goes back to sitting on its own, and
nothing shifts position when a session starts.

The panel's glyph is bigger (20 -> 40px) and always painted in the theme
highlight rather than following the brightness mode. Clicking it now stops
casting instead of cycling on/dim/off, which meant nothing when there are
no bars to dim; that click was the useful behavior the volume-slider icon
had, so it moves here rather than being lost.

transport_icon() takes a size and scales the painter, so the bigger glyph
is repainted crisply rather than being a blown-up 20px pixmap; size joins
the cache key. The cast/cast_connected glyph pair collapses into one,
since only the connected state was ever drawn.

450 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 22:24:31 -04:00
co-authored by Claude Opus 5
parent 6c746c3330
commit bcc17a7542
8 changed files with 132 additions and 141 deletions
+6 -3
View File
@@ -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
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.5.0"
__version__ = "0.5.1"
-55
View File
@@ -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)
+17 -21
View File
@@ -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,
}
+7 -19
View File
@@ -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):
+25 -8
View File
@@ -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)
+16
View File
@@ -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
+60 -34
View File
@@ -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