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
+35
View File
@@ -1,5 +1,40 @@
## Done
### Round 29 (2026-08-13) — Cast to Chromecast (v0.4.0)
The Device menu is now **Connections**, with "Connect to Chromecast…" alongside
the Rabbit sync. Picking a device from the search dialog hands playback to it;
a small cast glyph appears under the volume slider and clicking it disconnects.
See `tests/test_round29.py` (67 tests).
Uses the **media-receiver model**: lintunes serves the original file over the
LAN and the Chromecast decodes it. Bit-exact (no transcode, no double-lossy on
already-lossy files) and the device buffers for itself; the cost is that
lintunes becomes a remote control while connected — no local PCM, so the
visualizer shows the cast glyph instead of bars, and transport actions land
with ~1 s of round trip.
- [x] **`lintunes/cast/`** — `support.py` (format gate, source-address
selection, range parsing), `server.py` (token-addressed
`ThreadingHTTPServer` with Range/HEAD), `discovery.py` (`CastBrowser` behind
Qt signals), `sink.py` (`CastSink`), `controller.py` (session lifecycle +
its own `SleepInhibitor`).
- [x] **`Player` sink split** — new `PlaybackSink` base with `LocalSink` and
`CastSink`; `set_sink()` carries track/position/playing-state both ways, so
connecting and disconnecting pick up mid-song. Play counts and Last.fm
scrobbles fire identically on both paths.
- [x] **Format handling** — 12,610 MP3 + ~8,750 AAC cast natively; the ~119
Apple Lossless / AIFF / protected-AAC tracks are skipped with a status-bar
message rather than stalling the device.
- [x] **Failure handling** — a dropped socket gets a 15 s grace period
(pychromecast retries on its own, so a Wi-Fi blip heals itself); a real loss,
a foreign app taking the device, or a network change falls back to local
playback **still playing**, at the same position. Quitting stops the device
rather than leaving it fetching from a dead server.
- [x] **New dependency: `pychromecast>=14.0.10`**, imported lazily so the app
still launches where it isn't installed; `python_requires` raised to >=3.11
(its floor). The menu item explains the `pip install -e .` when missing.
### Misc (recorded 2026-07-02, moved from TASKS.md during the Round 17 rewrite)
- [x] Confirm Last.fm scrobbling works