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
+28 -7
View File
@@ -67,13 +67,19 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
without recursing. Widgets react to its signals (`playlists_changed`,
`playlist_content_changed(pid)`, `track_updated(id)`, `track_fields_edited`).
- **`lintunes/player.py`** — `Player(QObject)` wraps `QMediaPlayer`/`QAudioOutput`.
It is deliberately **context-agnostic** (knows a queue + shuffle walk order, not
which view started playback). It exposes `track_changed`/`playing_changed`/
`position_changed`/`duration_changed` signals consumed by the transport, MPRIS,
and the visualizer (a `QAudioBufferOutput` tee feeds PCM to the spectrum bars).
Playback *context* ("library" / "playlist:<pid>") is tracked in `MainWindow`,
not the player.
- **`lintunes/player.py`** — `Player(QObject)` walks a queue through a swappable
**`PlaybackSink`**. Player owns the queue, shuffle walk, per-track start/stop
times and the play-count/scrobble bookkeeping; a sink owns only "make this file
come out of something, and report where it's up to". `LocalSink` (the
`QMediaPlayer`/`QAudioOutput` pipeline, with the `QAudioBufferOutput` PCM tee
that feeds the visualizer) **stays in `player.py`** — the Player tests stub Qt
Multimedia with `patch.multiple(player_module, QMediaPlayer=..., …)`, so those
names must resolve in this module. `cast/sink.py::CastSink` is the other
implementation. `set_sink()` carries the current track, position and
playing-state across a swap and deliberately does *not* re-emit
`track_changed` (that would double-scrobble the same song).
Player is also deliberately **context-agnostic**: playback *context*
("library" / "playlist:<pid>") is tracked in `MainWindow`, not the player.
- **`lintunes/gui/`** — `main_window.py` assembles a top `TransportBar` over a
horizontal `QSplitter` (`SidebarPanel` | stacked `LibraryView`/`PlaylistView`).
@@ -105,6 +111,21 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
plus an Auxio-importable `.m3u`); it never deletes outside that folder and
only ever *reads* local library files.
- **`lintunes/cast/`** — Chromecast playback (Connections menu), using the
**media-receiver model**: `server.py` runs a `ThreadingHTTPServer` on an
ephemeral port for the life of a session and the device fetches the *original*
file itself (bit-exact, no transcode). URLs carry an opaque random token, never
a path, so traversal is structurally impossible; Range + HEAD are mandatory
(the device seeks by re-requesting ranges and won't report a duration without
them). `support.py` is the format gate — ALAC, AIFF and protected AAC are
refused and Player skips them with a status-bar message. `discovery.py` wraps
`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).
## Conventions & gotchas
- **Tests are organized as `tests/test_roundN.py`** — each development round adds