···11+# rockbox-chromecast
22+33+Chromecast output sink for Rockbox Zig. Streams live audio from the Rockbox
44+firmware to any Google Cast-compatible device on the LAN — Google Home, Chromecast
55+Audio, Chromecast with Google TV, Nest Hub, and third-party Cast receivers.
66+77+---
88+99+## Architecture overview
1010+1111+The Chromecast implementation has two independent, complementary halves:
1212+1313+```
1414+┌──────────────────────────────────────────────────────────────────────┐
1515+│ rockboxd process │
1616+│ │
1717+│ Rockbox firmware (C) │
1818+│ ┌────────────────────┐ │
1919+│ │ PCM engine │──pcm_chromecast_write()──► BroadcastBuffer │
2020+│ │ (44.1 kHz S16LE) │ (ring buffer) │
2121+│ └────────────────────┘ │ │
2222+│ │ │
2323+│ ┌─────────────────────────────────────────────┐ │ │
2424+│ │ HTTP server (port 7881) │◄──────────┘ │
2525+│ │ GET /stream.wav → WAV header + PCM chunks│ │
2626+│ │ GET /now-playing/art → album art (JPEG…) │ │
2727+│ └─────────────────────┬───────────────────────┘ │
2828+│ │ HTTP (WAV) │
2929+│ ┌──────────────────────▼──────────────────────┐ │
3030+│ │ Cast protocol thread │ │
3131+│ │ (Cast TCP 8009, TLS, Protobuf) │ │
3232+│ │ · media.load(url=http://host:7881/…) │ │
3333+│ │ · heartbeat.ping() every 500 ms │ │
3434+│ │ · on track change → media.load() again │ │
3535+│ └──────────────────────┬──────────────────────┘ │
3636+└─────────────────────────┼────────────────────────────────────────────┘
3737+ │ Cast protocol (TLS, port 8009)
3838+ │ + WAV stream (HTTP, port 7881)
3939+ ┌─────▼──────┐
4040+ │ Chromecast │
4141+ │ device │
4242+ └────────────┘
4343+```
4444+4545+The two halves communicate through a standard URL: the Cast protocol thread
4646+tells the device to fetch `http://<rockboxd-host>:7881/stream.wav`, and the HTTP
4747+server serves that stream from the broadcast buffer filled by the firmware.
4848+4949+---
5050+5151+## Module map
5252+5353+| File | Responsibility |
5454+|---------------|------------------------------------------------------------------------------------|
5555+| `src/lib.rs` | `Player` trait implementation; Cast protocol control thread; MPSC command dispatch |
5656+| `src/pcm.rs` | HTTP WAV broadcast server; album art server; `BroadcastBuffer`; C FFI surface |
5757+| `src/main.rs` | Example binary (connects to a hardcoded IP for manual testing) |
5858+5959+---
6060+6161+## Part 1 — Cast protocol (src/lib.rs)
6262+6363+### Connection
6464+6565+`Chromecast::connect(device: Device)` establishes the Cast session:
6666+6767+1. Open a TLS TCP connection to the device on port 8009
6868+ (`CastDevice::connect_without_host_verification`) — host verification is
6969+ skipped because Chromecast devices use self-signed certificates.
7070+2. Connect to the built-in `receiver-0` destination.
7171+3. Ping the heartbeat channel.
7272+4. Launch the **Default Media Receiver** (app ID `"88DCBD57"`).
7373+5. Save the resulting `transport_id` and `session_id`.
7474+6. Spawn a background Tokio task (`CastPlayerInternal`) that owns the
7575+ `CastDevice` handle and processes commands.
7676+7777+The method returns a `Box<dyn Player>` usable through the shared `Player` trait.
7878+7979+### Command dispatch
8080+8181+All player operations (play, pause, next, previous, load_tracks, …) send a
8282+`CastPlayerCommand` over an unbounded MPSC channel to the background task.
8383+This avoids blocking the calling thread and keeps the Cast device handle
8484+single-owner.
8585+8686+```
8787+caller thread background task (CastPlayerInternal)
8888+ │ │
8989+ │──CastPlayerCommand::Play─────────►│
9090+ │ │── cast_device.media.play()
9191+ │──CastPlayerCommand::Next─────────►│
9292+ │ │── cast_device.media.next()
9393+ │──CastPlayerCommand::LoadTracks──►│
9494+ │ │── cast_device.media.queue_load([…])
9595+```
9696+9797+### Status polling
9898+9999+Every 500 ms the background task calls `media.get_status()` and updates a
100100+shared `Arc<Mutex<CurrentPlayback>>`. `get_current_playback()` reads this value
101101+without talking to the device, so it is always fast and non-blocking.
102102+103103+### Track metadata
104104+105105+When loading a track or a queue, Rockbox builds a Cast `Media` object with:
106106+107107+- `content_id` — the stream URL (`http://<host>:7881/stream.wav`)
108108+- `content_type` — `"audio/wav"`
109109+- `stream_type` — `Buffered` when duration is known, `Live` otherwise
110110+- `duration` — seconds from the Rockbox track entry
111111+- `metadata` — `MusicTrackMediaMetadata` with title, artist, album, and an
112112+ album art URL (`http://<host>:7881/now-playing/art?t=<seq>`)
113113+114114+The `?t=<seq>` cache-buster increments on every track change so the Chromecast
115115+re-fetches the art rather than using its internal cache.
116116+117117+### Queue management
118118+119119+- `load_tracks(tracks, start_index)` → `media.queue_load([Media…])` — replaces
120120+ the entire queue and starts playback from `start_index`.
121121+- `play_next(track)` → `media.queue_insert(Media)` — inserts one track before
122122+ the next queue item.
123123+124124+### Session lifecycle
125125+126126+| Player method | Cast action |
127127+|----------------|---------------------------------------------------------|
128128+| `play()` | `media.play()` |
129129+| `pause()` | `media.pause()` |
130130+| `resume()` | `media.play()` |
131131+| `stop()` | no-op — session stays alive so pause/resume is seamless |
132132+| `next()` | `media.next()` |
133133+| `previous()` | `media.previous()` |
134134+| `disconnect()` | `receiver.stop_app(session_id)` |
135135+136136+`stop()` is intentionally a no-op at the Cast level. The Cast app is only
137137+stopped when `disconnect()` is called explicitly (e.g. the user switches to
138138+another output or closes the app). This prevents the Chromecast UI from
139139+dismissing the "Now Playing" card between tracks.
140140+141141+---
142142+143143+## Part 2 — WAV/HTTP broadcast (src/pcm.rs)
144144+145145+### BroadcastBuffer
146146+147147+```
148148+firmware (writer)
149149+ │ pcm_chromecast_write(buf, len)
150150+ ▼
151151+┌────────────────────────────────────────────┐
152152+│ BroadcastBuffer (max 4 MB) │
153153+│ - sequence counter (u64) │
154154+│ - Vec of (seq, chunk) pairs │
155155+│ - Condvar to wake sleeping readers │
156156+│ - evict oldest chunks when full │
157157+└────────────────────────────────────────────┘
158158+ │ reader cursor per HTTP client
159159+ ▼
160160+HTTP client 1, HTTP client 2, …
161161+```
162162+163163+Each HTTP client (i.e. the Chromecast device) gets its own cursor into the
164164+buffer. A slow client skips forward to the current position rather than
165165+blocking the writer — this prevents audio glitches when network conditions are
166166+poor.
167167+168168+### WAV HTTP server
169169+170170+The server listens on `chromecast_http_port` (default **7881**) and handles two
171171+routes:
172172+173173+#### `GET /stream.wav` (or any path)
174174+175175+1. Computes a WAV RIFF header from the current track's sample rate, channel
176176+ count, and duration:
177177+ - `data_size = (duration_ms × byte_rate) / 1000`
178178+ - `byte_rate = sample_rate × 4` (stereo 16-bit)
179179+ - `Content-Length = 44 (header) + data_size`
180180+ - If duration is unknown, `data_size = 0xFFFFFFFF` (Chromecast shows ∞).
181181+2. Streams the header, then sends chunks from the broadcast buffer as they
182182+ arrive until `data_size` bytes have been sent or the client disconnects.
183183+184184+Using a finite `Content-Length` is important: Chromecast's Default Media
185185+Receiver uses it to show a progress bar and to know when a track ends so it
186186+can automatically advance to the next queue item.
187187+188188+#### `GET /now-playing/art`
189189+190190+Searches the directory of the currently playing track for common album art
191191+filenames:
192192+193193+```
194194+cover.jpg cover.png cover.webp folder.jpg folder.png
195195+front.jpg front.png artwork.jpg artwork.jpeg artwork.png
196196+```
197197+198198+Returns the first match with the correct MIME type, or 404 if none is found.
199199+200200+### Track change detection
201201+202202+A dedicated `cast_loop` thread polls `rockbox_sys::playback::current_track()`
203203+every 500 ms and compares the path to the previously known path. On a change:
204204+205205+1. Increments the global `art_seq` counter.
206206+2. Calls `media.load(Media{…})` on the active Cast session with fresh
207207+ metadata and a cache-busted art URL.
208208+3. The Chromecast fetches the new `/stream.wav` URL and the updated album art.
209209+210210+### FFI surface
211211+212212+The C firmware calls these symbols (defined with `#[no_mangle]` in `pcm.rs`):
213213+214214+```c
215215+void pcm_chromecast_set_http_port(uint16_t port);
216216+void pcm_chromecast_set_device_host(const char *host);
217217+void pcm_chromecast_set_device_port(uint16_t port);
218218+void pcm_chromecast_set_sample_rate(uint32_t rate);
219219+int pcm_chromecast_start(void); /* idempotent; starts HTTP + cast threads */
220220+int pcm_chromecast_write(const uint8_t *buf, size_t len);
221221+void pcm_chromecast_stop(void); /* no-op — session stays open */
222222+void pcm_chromecast_close(void); /* stops threads, frees resources */
223223+```
224224+225225+The C implementation lives in
226226+`firmware/target/hosted/pcm-chromecast.c`, which is registered as
227227+`PCM_SINK_CHROMECAST` in `firmware/export/pcm_sink.h`.
228228+229229+---
230230+231231+## Device discovery
232232+233233+Chromecast devices are discovered automatically via **mDNS** (`_googlecast._tcp.local.`).
234234+The `rockbox-discovery` crate (using `mdns_sd`) browses the LAN and publishes
235235+each found device as a `Device` struct:
236236+237237+| Field | Value |
238238+|------------------|---------------------------------------|
239239+| `app` | `"chromecast"` |
240240+| `port` | `8009` (Cast protocol) |
241241+| `ip` | IPv4 address of the device |
242242+| `name` | Friendly name from mDNS `fn` property |
243243+| `is_cast_device` | `true` |
244244+245245+Discovered devices appear in the GraphQL `devices` query and in the web/desktop
246246+UI device picker in real time.
247247+248248+---
249249+250250+## Connecting a device
251251+252252+Once discovered, connect via:
253253+254254+```
255255+POST /devices/:id/connect
256256+```
257257+258258+This calls `Chromecast::connect(device)`, stores the resulting `Player` in the
259259+shared HTTP server context, and marks the device as active. All subsequent
260260+play/pause/next/… calls go through this player.
261261+262262+Disconnect with:
263263+264264+```
265265+POST /devices/:id/disconnect
266266+```
267267+268268+This stops the Cast app session and clears the player context.
269269+270270+---
271271+272272+## Configuration
273273+274274+Add to `~/.config/rockbox.org/settings.toml`:
275275+276276+```toml
277277+music_dir = "/path/to/Music"
278278+audio_output = "chromecast"
279279+280280+chromecast_host = "192.168.1.60" # IP of the target Chromecast
281281+chromecast_port = 8009 # optional, default 8009
282282+chromecast_http_port = 7881 # optional, default 7881
283283+```
284284+285285+`chromecast_host` must be the LAN IP of the Chromecast device you want to use
286286+as the fixed PCM output sink. If you prefer to select the device dynamically
287287+through the UI, use `audio_output = "builtin"` and connect via the device
288288+picker instead — the WAV stream and Cast session are started on demand.
289289+290290+### Port summary
291291+292292+| Port | Protocol | Purpose |
293293+|------|-----------|-----------------------------------------------------|
294294+| 8009 | TCP / TLS | Cast control channel (Protobuf) |
295295+| 7881 | HTTP | WAV audio stream + album art served **by rockboxd** |
296296+297297+> Port 7881 must be reachable from the Chromecast device. If rockboxd runs
298298+> inside a VM or container, ensure the WAV HTTP port is forwarded to the host.
299299+300300+---
301301+302302+## Known limitations
303303+304304+| Feature | Status |
305305+|---------------------------------------|---------------------------------------------|
306306+| Play / pause / stop / next / previous | ✅ Implemented |
307307+| Queue management (load, insert) | ✅ Implemented |
308308+| Metadata + album art display | ✅ Implemented |
309309+| Volume control | ⏳ Not yet implemented |
310310+| Seek within track | ⏳ Not yet implemented |
311311+| Multi-device fan-out | ⏳ Not yet implemented (single device only) |
312312+313313+---
314314+315315+## Dependencies
316316+317317+| Crate | Version | Purpose |
318318+|-------------------|-----------|-----------------------------------------------------------|
319319+| `chromecast` | 0.18.2 | Cast protocol client (Protobuf/TLS) |
320320+| `tokio` | workspace | Async runtime for Cast background task |
321321+| `async-trait` | workspace | `Player` trait with async methods |
322322+| `rockbox-traits` | local | `Player` trait definition |
323323+| `rockbox-types` | local | `Device`, `Track`, `Playback` types |
324324+| `rockbox-sys` | local | FFI to Rockbox C firmware (current track, playback state) |
325325+| `rockbox-library` | local | SQLite library for track lookups |
326326+| `md5` | — | Device ID hashing |
327327+| `tracing` | workspace | Structured logging |
+35-7
crates/chromecast/src/pcm.rs
···344344 buf: Arc<BroadcastBuffer>,
345345 peer: &str,
346346) {
347347- let hdr = "HTTP/1.0 200 OK\r\nContent-Type: audio/wav\r\nCache-Control: no-cache\r\n\r\n";
348348-349349- // Compute the exact PCM byte count from the track length so the Chromecast
350350- // can display an accurate progress bar. Falls back to 0xFFFFFFFF only when
351351- // no track info is available (pre-playback probe connections, etc.).
347347+ // Compute the exact PCM byte count from the track length.
348348+ // Without Content-Length, Chrome/Chromecast treats any HTTP response as a
349349+ // live stream and shows ∞ — it does NOT read duration from the WAV header
350350+ // alone. We must set Content-Length AND enforce the byte limit so the
351351+ // response is a properly-bounded file the receiver can fully parse.
352352 let duration_ms = rockbox_sys::playback::current_track()
353353 .map(|t| t.length)
354354 .filter(|&l| l > 0)
···361361 };
362362363363 let wav_hdr = wav_header(sample_rate, data_size);
364364+ let known_length = data_size != 0xFFFF_FFFF;
365365+366366+ let hdr = if known_length {
367367+ format!(
368368+ "HTTP/1.0 200 OK\r\nContent-Type: audio/wav\r\nContent-Length: {}\r\nCache-Control: no-cache\r\n\r\n",
369369+ 44u64 + data_size as u64
370370+ )
371371+ } else {
372372+ "HTTP/1.0 200 OK\r\nContent-Type: audio/wav\r\nCache-Control: no-cache\r\n\r\n".to_string()
373373+ };
374374+364375 if stream.write_all(hdr.as_bytes()).is_err() || stream.write_all(&wav_hdr).is_err() {
365376 return;
366377 }
367367- tracing::info!("chromecast/pcm: streaming WAV to {peer}");
378378+ tracing::info!(
379379+ "chromecast/pcm: streaming WAV to {peer} ({} bytes)",
380380+ if known_length { data_size as u64 } else { 0 }
381381+ );
368382369383 let mut rx = buf.subscribe();
384384+ let mut written: u64 = 0;
385385+ let limit: u64 = if known_length {
386386+ data_size as u64
387387+ } else {
388388+ u64::MAX
389389+ };
390390+370391 loop {
392392+ if written >= limit {
393393+ break;
394394+ }
371395 match rx.recv_blocking() {
372396 RecvResult::Data(chunk) => {
373373- if stream.write_all(&chunk).is_err() {
397397+ let remaining = (limit - written) as usize;
398398+ let to_write = chunk.len().min(remaining);
399399+ if stream.write_all(&chunk[..to_write]).is_err() {
374400 tracing::debug!("chromecast/pcm: {peer} disconnected");
375401 break;
376402 }
403403+ written += to_write as u64;
377404 }
378405 RecvResult::Closed => break,
379406 }
380407 }
408408+ tracing::debug!("chromecast/pcm: {peer} done ({written} bytes)");
381409}
382410383411// ---------------------------------------------------------------------------