Files
lintunes/tests/test_round10.py
T
travandClaude Opus 5 087c4bf103 v0.4.0: cast to a Chromecast from the Connections menu
The Device menu becomes Connections, with "Connect to Chromecast…" beside
the Rabbit sync. It opens a dialog that spins while it searches and lists
devices as they appear; picking one hands playback over, puts a small cast
glyph under the volume slider, and clicking that glyph disconnects.

Media-receiver model: lintunes serves the original file over the LAN on an
ephemeral port and the Chromecast decodes it itself. Bit-exact — no
transcode, no second lossy encode — and the device buffers for itself. The
cost is that lintunes is a remote control while connected: no local PCM, so
the visualizer shows the cast glyph instead of bars, and transport actions
land with about a second of round trip.

Player now walks its queue through a swappable PlaybackSink. It keeps
owning the queue, shuffle walk, start/stop times and the play-count and
scrobble bookkeeping, so casting counts plays and scrobbles exactly like
local playback. set_sink() carries track, position and playing-state both
ways, so connecting and disconnecting pick up mid-song. LocalSink stays in
player.py because the Player tests stub Qt Multimedia in that namespace.

The URL carries an opaque random token, never a path, so there is nothing
to traverse with; only the last few played tracks stay resolvable and the
whole map dies with the session. Range and HEAD are implemented because
the device seeks by re-requesting ranges and won't report a duration
without them.

Failure handling, verified against a real device: a dropped socket gets a
15s grace period, since pychromecast retries on its own and a Wi-Fi blip
heals itself. A real loss, another app taking the device, or a network
change falls back to local playback still playing, at the same position —
the sink reports the state lintunes last asked for rather than the IDLE
status a dying connection pushes just before it goes. Quitting stops the
device instead of leaving it fetching from a server that just died. The
~119 Apple Lossless / AIFF / protected-AAC tracks are skipped with a
status-bar message; the other 21,000+ MP3 and AAC files cast natively.

New dependency: pychromecast>=14.0.10, imported lazily so the app still
launches where it isn't installed (the menu item then explains the
install), and python_requires raised to >=3.11 to match its floor.

NOTE: the other machine needs `pip install -e .` before casting appears.

tests/test_round29.py: 67 tests; 440 pass overall.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:49:53 -04:00

105 lines
3.8 KiB
Python

"""Round 10: master output volume slider in the transport bar.
1. Player.set_volume / volume round-trip (logical 0..1, clamped).
2. TransportBar builds a volume slider initialized from the prefs value and
sets the player volume to match.
3. Moving the slider updates the player live and (after the debounce) persists
the new volume to preferences.json.
"""
from unittest.mock import MagicMock, patch
from lintunes.models import Library, Track
from lintunes.library_manager import LibraryManager
from lintunes.preferences import Preferences
from lintunes import player as player_module
from lintunes.player import Player
from lintunes.gui.transport import TransportBar, ClickJumpSlider
def _mock_player():
"""A real Player with the Qt multimedia backend stubbed out (it blocks on
init headless — see tests/test_player.py). The audio output becomes a mock,
so set_volume's setVolume call is a harmless no-op."""
library = Library(tracks={1: Track(track_id=1, name="One")})
with patch.multiple(
player_module,
QMediaPlayer=MagicMock(),
QAudioOutput=MagicMock(),
QAudioBufferOutput=MagicMock(),
QMediaDevices=MagicMock(),
):
return Player(MagicMock(library=library))
# --------------------------------------------------------------------------
# 1. Player volume API
# --------------------------------------------------------------------------
class TestPlayerVolume:
def test_round_trip(self, qapp):
player = _mock_player()
player.set_volume(0.5)
assert player.volume() == 0.5
# The linear gain handed to QAudioOutput is set (perceptual->linear).
player._local._audio.setVolume.assert_called()
def test_clamps_out_of_range(self, qapp):
player = _mock_player()
player.set_volume(1.5)
assert player.volume() == 1.0
player.set_volume(-0.3)
assert player.volume() == 0.0
# --------------------------------------------------------------------------
# 2 + 3. TransportBar volume slider
# --------------------------------------------------------------------------
class TestTransportVolumeSlider:
def _build(self, tmp_path, prefs):
player = _mock_player()
manager = LibraryManager(
Library(tracks={1: Track(track_id=1, name="One")}), tmp_path)
return TransportBar(player, manager, prefs), player
def test_slider_initialized_from_prefs(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
prefs.set("volume", 0.3)
transport, player = self._build(tmp_path, prefs)
assert transport._volume_slider.value() == 30
assert player.volume() == 0.3
def test_moving_slider_updates_player_live(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
transport, player = self._build(tmp_path, prefs)
transport._volume_slider.setValue(20)
assert player.volume() == 0.2
def test_save_persists_to_disk(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
transport, player = self._build(tmp_path, prefs)
transport._volume_slider.setValue(20)
# The debounce timer normally fires this; call it directly.
transport._save_volume()
# A freshly loaded Preferences sees the persisted value.
assert Preferences(tmp_path).get("volume") == 0.2
def test_click_jumps_to_position(self, qapp, tmp_path):
"""Click-to-jump: the slider lands on the clicked spot, not a step."""
prefs = Preferences(tmp_path)
transport, player = self._build(tmp_path, prefs)
slider = transport._volume_slider
assert isinstance(slider, ClickJumpSlider)
slider.resize(100, 20)
# A click at the far-left maps to the minimum, mid to ~middle.
assert slider._value_at(0) == 0
assert 40 <= slider._value_at(50) <= 60
assert slider._value_at(100) == 100