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
+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