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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user