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>
This commit is contained in:
2026-08-13 21:49:53 -04:00
co-authored by Claude Opus 5
parent b6b6dc01f7
commit 087c4bf103
26 changed files with 2885 additions and 209 deletions
+22 -22
View File
@@ -48,21 +48,21 @@ class TestSameSourceReplay:
url = QUrl.fromLocalFile(loc)
player.play_queue([1], 0) # first load: source was empty, plain set
assert player._media.setSource.call_args_list[-1].args == (url,)
assert player._local._media.setSource.call_args_list[-1].args == (url,)
# The track is now the loaded source; the user edits its start time
# and plays it again. The media is already loaded, so the player must
# seek directly instead of waiting for a LoadedMedia that never comes.
player._media.source.return_value = url
player._local._media.source.return_value = url
track.start_time = 30_000
player._media.setSource.reset_mock()
player._local._media.setSource.reset_mock()
player.play_queue([1], 0)
player._media.setSource.assert_not_called()
player._media.stop.assert_called()
player._media.setPosition.assert_called_with(30_000)
assert player._pending_start_ms == 0 # consumed
player._media.play.assert_called()
player._local._media.setSource.assert_not_called()
player._local._media.stop.assert_called()
player._local._media.setPosition.assert_called_with(30_000)
assert player._local._pending_start_ms == 0 # consumed
player._local._media.play.assert_called()
def test_unchanged_url_without_start_time_restarts_at_zero(
self, qapp, tmp_path):
@@ -72,13 +72,13 @@ class TestSameSourceReplay:
player, _ = _player(qapp, [track])
player.play_queue([1], 0)
player._media.source.return_value = QUrl.fromLocalFile(loc)
player._media.setPosition.reset_mock()
player._local._media.source.return_value = QUrl.fromLocalFile(loc)
player._local._media.setPosition.reset_mock()
player.play_queue([1], 0)
# stop() rewinds to 0; no seek should be issued.
player._media.stop.assert_called()
player._media.setPosition.assert_not_called()
player._local._media.stop.assert_called()
player._local._media.setPosition.assert_not_called()
def test_different_url_loads_via_setsource(self, qapp, tmp_path):
for name in ("a.mp3", "b.mp3"):
@@ -87,16 +87,16 @@ class TestSameSourceReplay:
t2 = _track(2, location=str(tmp_path / "b.mp3"),
total_time=200_000, start_time=30_000)
player, _ = _player(qapp, [t1, t2])
player._media.source.return_value = QUrl.fromLocalFile(t1.location)
player._local._media.source.return_value = QUrl.fromLocalFile(t1.location)
player.play_queue([1, 2], 1) # loading b.mp3 while a.mp3 is the source
assert [c.args for c in player._media.setSource.call_args_list] \
assert [c.args for c in player._local._media.setSource.call_args_list] \
== [(QUrl.fromLocalFile(t2.location),)]
# New source: the seek waits for LoadedMedia as before.
assert player._pending_start_ms == 30_000
player._on_media_status(player_module.QMediaPlayer.MediaStatus.LoadedMedia)
player._media.setPosition.assert_called_with(30_000)
assert player._pending_start_ms == 0
assert player._local._pending_start_ms == 30_000
player._local._on_media_status(player_module.QMediaPlayer.MediaStatus.LoadedMedia)
player._local._media.setPosition.assert_called_with(30_000)
assert player._local._pending_start_ms == 0
class TestPreviousRestart:
@@ -107,9 +107,9 @@ class TestPreviousRestart:
player, _ = _player(qapp, [track])
player.play_queue([1], 0)
player._media.position.return_value = RESTART_THRESHOLD_MS + 1
player._local._media.position.return_value = RESTART_THRESHOLD_MS + 1
player.previous()
player._media.setPosition.assert_called_with(30_000)
player._local._media.setPosition.assert_called_with(30_000)
def test_restart_without_start_time_seeks_to_zero(self, qapp, tmp_path):
(tmp_path / "a.mp3").write_bytes(b"x")
@@ -117,6 +117,6 @@ class TestPreviousRestart:
player, _ = _player(qapp, [track])
player.play_queue([1], 0)
player._media.position.return_value = RESTART_THRESHOLD_MS + 1
player._local._media.position.return_value = RESTART_THRESHOLD_MS + 1
player.previous()
player._media.setPosition.assert_called_with(0)
player._local._media.setPosition.assert_called_with(0)