Rockbox open source high quality audio player as a Music Player Daemon
mpris rockbox mpd libadwaita audio rust zig deno
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Document Chromecast cast_loop and lifecycle

Update README to explain that src/pcm.rs's cast_loop exclusively owns
the
Cast session, describe the WAV/HTTP broadcast and BroadcastBuffer,
explain generation/RELOAD/teardown semantics, and clarify the FFI
surface
(pcm_chromecast_start/pcm_chromecast_teardown/close behavior).

+158 -161
+158 -161
crates/chromecast/README.md
··· 8 8 9 9 ## Architecture overview 10 10 11 - The Chromecast implementation has two independent, complementary halves: 11 + All Chromecast functionality is driven by `src/pcm.rs`. The `src/lib.rs` module 12 + contains a `Player` trait implementation that is retained for internal use but is 13 + **not** invoked from the server connect handler — the `cast_loop` thread in 14 + `pcm.rs` owns the Cast session exclusively. 12 15 13 16 ``` 14 17 ┌──────────────────────────────────────────────────────────────────────┐ ··· 27 30 │ └─────────────────────┬───────────────────────┘ │ 28 31 │ │ HTTP (WAV) │ 29 32 │ ┌──────────────────────▼──────────────────────┐ │ 30 - │ │ Cast protocol thread │ │ 31 - │ │ (Cast TCP 8009, TLS, Protobuf) │ │ 33 + │ │ cast_loop thread (src/pcm.rs) │ │ 34 + │ │ · connects to Cast device on port 8009 │ │ 35 + │ │ · launches Rockbox app (ID 88DCBD57) │ │ 32 36 │ │ · media.load(url=http://host:7881/…) │ │ 33 37 │ │ · heartbeat.ping() every 500 ms │ │ 34 38 │ │ · on track change → media.load() again │ │ 39 + │ │ · on RELOAD_REQUESTED → buffer.reset() │ │ 40 + │ │ + media.load() (pause/resume recovery) │ │ 41 + │ │ · exits when CAST_GENERATION changes │ │ 35 42 │ └──────────────────────┬──────────────────────┘ │ 36 43 └─────────────────────────┼────────────────────────────────────────────┘ 37 44 │ Cast protocol (TLS, port 8009) ··· 42 49 └────────────┘ 43 50 ``` 44 51 45 - The two halves communicate through a standard URL: the Cast protocol thread 46 - tells the device to fetch `http://<rockboxd-host>:7881/stream.wav`, and the HTTP 47 - server serves that stream from the broadcast buffer filled by the firmware. 48 - 49 52 --- 50 53 51 54 ## Module map 52 55 53 - | File | Responsibility | 54 - |---------------|------------------------------------------------------------------------------------| 55 - | `src/lib.rs` | `Player` trait implementation; Cast protocol control thread; MPSC command dispatch | 56 - | `src/pcm.rs` | HTTP WAV broadcast server; album art server; `BroadcastBuffer`; C FFI surface | 57 - | `src/main.rs` | Example binary (connects to a hardcoded IP for manual testing) | 56 + | File | Responsibility | 57 + |---------------|-----------------------------------------------------------------------------------------| 58 + | `src/pcm.rs` | Primary: HTTP WAV server; `BroadcastBuffer`; `cast_loop`; full C FFI surface | 59 + | `src/lib.rs` | Secondary: `Player` trait impl; Cast command dispatch (retained, not called by server) | 60 + | `src/main.rs` | Example binary (connects to a hardcoded IP for manual testing) | 58 61 59 62 --- 60 63 61 - ## Part 1 — Cast protocol (src/lib.rs) 62 - 63 - ### Connection 64 - 65 - `Chromecast::connect(device: Device)` establishes the Cast session: 66 - 67 - 1. Open a TLS TCP connection to the device on port 8009 68 - (`CastDevice::connect_without_host_verification`) — host verification is 69 - skipped because Chromecast devices use self-signed certificates. 70 - 2. Connect to the built-in `receiver-0` destination. 71 - 3. Ping the heartbeat channel. 72 - 4. Launch the **Default Media Receiver** (app ID `"88DCBD57"`). 73 - 5. Save the resulting `transport_id` and `session_id`. 74 - 6. Spawn a background Tokio task (`CastPlayerInternal`) that owns the 75 - `CastDevice` handle and processes commands. 76 - 77 - The method returns a `Box<dyn Player>` usable through the shared `Player` trait. 78 - 79 - ### Command dispatch 80 - 81 - All player operations (play, pause, next, previous, load_tracks, …) send a 82 - `CastPlayerCommand` over an unbounded MPSC channel to the background task. 83 - This avoids blocking the calling thread and keeps the Cast device handle 84 - single-owner. 85 - 86 - ``` 87 - caller thread background task (CastPlayerInternal) 88 - │ │ 89 - │──CastPlayerCommand::Play─────────►│ 90 - │ │── cast_device.media.play() 91 - │──CastPlayerCommand::Next─────────►│ 92 - │ │── cast_device.media.next() 93 - │──CastPlayerCommand::LoadTracks──►│ 94 - │ │── cast_device.media.queue_load([…]) 95 - ``` 96 - 97 - ### Status polling 98 - 99 - Every 500 ms the background task calls `media.get_status()` and updates a 100 - shared `Arc<Mutex<CurrentPlayback>>`. `get_current_playback()` reads this value 101 - without talking to the device, so it is always fast and non-blocking. 102 - 103 - ### Track metadata 104 - 105 - When loading a track or a queue, Rockbox builds a Cast `Media` object with: 106 - 107 - - `content_id` — the stream URL (`http://<host>:7881/stream.wav`) 108 - - `content_type` — `"audio/wav"` 109 - - `stream_type` — `Buffered` when duration is known, `Live` otherwise 110 - - `duration` — seconds from the Rockbox track entry 111 - - `metadata` — `MusicTrackMediaMetadata` with title, artist, album, and an 112 - album art URL (`http://<host>:7881/now-playing/art?t=<seq>`) 113 - 114 - The `?t=<seq>` cache-buster increments on every track change so the Chromecast 115 - re-fetches the art rather than using its internal cache. 116 - 117 - ### Queue management 118 - 119 - - `load_tracks(tracks, start_index)` → `media.queue_load([Media…])` — replaces 120 - the entire queue and starts playback from `start_index`. 121 - - `play_next(track)` → `media.queue_insert(Media)` — inserts one track before 122 - the next queue item. 123 - 124 - ### Session lifecycle 125 - 126 - | Player method | Cast action | 127 - |----------------|---------------------------------------------------------| 128 - | `play()` | `media.play()` | 129 - | `pause()` | `media.pause()` | 130 - | `resume()` | `media.play()` | 131 - | `stop()` | no-op — session stays alive so pause/resume is seamless | 132 - | `next()` | `media.next()` | 133 - | `previous()` | `media.previous()` | 134 - | `disconnect()` | `receiver.stop_app(session_id)` | 135 - 136 - `stop()` is intentionally a no-op at the Cast level. The Cast app is only 137 - stopped when `disconnect()` is called explicitly (e.g. the user switches to 138 - another output or closes the app). This prevents the Chromecast UI from 139 - dismissing the "Now Playing" card between tracks. 64 + ## Part 1 — WAV/HTTP broadcast + cast_loop (src/pcm.rs) 140 65 141 - --- 142 - 143 - ## Part 2 — WAV/HTTP broadcast (src/pcm.rs) 66 + This is the **primary implementation**. The server connect handler arms the PCM 67 + sink and the C firmware drives everything else through the FFI surface. 144 68 145 69 ### BroadcastBuffer 146 70 ··· 151 75 ┌────────────────────────────────────────────┐ 152 76 │ BroadcastBuffer (max 4 MB) │ 153 77 │ - sequence counter (u64) │ 154 - │ - Vec of (seq, chunk) pairs │ 78 + │ - VecDeque of (seq, chunk) pairs │ 155 79 │ - Condvar to wake sleeping readers │ 156 80 │ - evict oldest chunks when full │ 81 + │ - close() / reset() for session teardown │ 157 82 └────────────────────────────────────────────┘ 158 - │ reader cursor per HTTP client 83 + │ independent cursor per HTTP client 159 84 160 - HTTP client 1, HTTP client 2, … 85 + HTTP client 1 (Chromecast), HTTP client 2, … 161 86 ``` 162 87 163 - Each HTTP client (i.e. the Chromecast device) gets its own cursor into the 164 - buffer. A slow client skips forward to the current position rather than 165 - blocking the writer — this prevents audio glitches when network conditions are 166 - poor. 88 + Each HTTP client gets its own `BroadcastReceiver` cursor. A lagging reader skips 89 + forward to the current position so it never blocks the writer. 167 90 168 91 ### WAV HTTP server 169 92 170 - The server listens on `chromecast_http_port` (default **7881**) and handles two 171 - routes: 93 + Listens on `chromecast_http_port` (default **7881**). Started once on the first 94 + `pcm_chromecast_start()` call and kept alive for the process lifetime. 172 95 173 - #### `GET /stream.wav` (or any path) 96 + #### `GET /stream.wav` 174 97 175 - 1. Computes a WAV RIFF header from the current track's sample rate, channel 176 - count, and duration: 98 + 1. Derives a WAV RIFF header from the current track: 177 99 - `data_size = (duration_ms × byte_rate) / 1000` 178 100 - `byte_rate = sample_rate × 4` (stereo 16-bit) 179 101 - `Content-Length = 44 (header) + data_size` 180 102 - If duration is unknown, `data_size = 0xFFFFFFFF` (Chromecast shows ∞). 181 - 2. Streams the header, then sends chunks from the broadcast buffer as they 182 - arrive until `data_size` bytes have been sent or the client disconnects. 183 - 184 - Using a finite `Content-Length` is important: Chromecast's Default Media 185 - Receiver uses it to show a progress bar and to know when a track ends so it 186 - can automatically advance to the next queue item. 103 + 2. Streams the header then drains `BroadcastBuffer` chunks until `data_size` 104 + bytes are sent or the client disconnects. 187 105 188 106 #### `GET /now-playing/art` 189 107 190 - Searches the directory of the currently playing track for common album art 191 - filenames: 108 + Searches the current track's directory for common album art filenames and 109 + returns the first match. Returns 404 if none found. 110 + 111 + ### cast_loop and session lifecycle 112 + 113 + `cast_loop(host, port, http_port, gen)` runs in a background thread. `gen` is 114 + the current `CAST_GENERATION` value at spawn time. 192 115 193 116 ``` 194 - cover.jpg cover.png cover.webp folder.jpg folder.png 195 - front.jpg front.png artwork.jpg artwork.jpeg artwork.png 117 + cast_loop(gen) 118 + 119 + └─► cast_session(gen) 120 + · CastDevice::connect_without_host_verification 121 + · connection.connect("receiver-0") 122 + · heartbeat.ping() 123 + · receiver.launch_app("88DCBD57") ← Rockbox Cast app 124 + · connection.connect(transport_id) 125 + · media.load(initial) 126 + 127 + └─► monitor loop (every 500 ms) 128 + · if CAST_STOP || CAST_GENERATION != gen → stop_app, return true 129 + · heartbeat.ping() (failure → return false → reconnect) 130 + · if track path changed → art_seq++ → media.load(new metadata) 131 + · if RELOAD_REQUESTED && !CAST_STOP → buffer.reset() + media.load() 196 132 ``` 197 133 198 - Returns the first match with the correct MIME type, or 404 if none is found. 134 + If `cast_session` returns `false` (heartbeat lost), `cast_loop` retries after 135 + 3 seconds. If it returns `true` (graceful stop), `cast_loop` exits and sets 136 + `CAST_PLAYING = false` — but only if the generation is still current. 199 137 200 - ### Track change detection 138 + ### Generation counter 201 139 202 - A dedicated `cast_loop` thread polls `rockbox_sys::playback::current_track()` 203 - every 500 ms and compares the path to the previously known path. On a change: 140 + `CAST_GENERATION: AtomicU32` is the mechanism that allows a stale `cast_loop` 141 + to exit cleanly even after `CAST_STOP` has been re-armed for a new session: 204 142 205 - 1. Increments the global `art_seq` counter. 206 - 2. Calls `media.load(Media{…})` on the active Cast session with fresh 207 - metadata and a cache-busted art URL. 208 - 3. The Chromecast fetches the new `/stream.wav` URL and the updated album art. 143 + - `pcm_chromecast_teardown()` increments the generation. 144 + - Each `cast_loop` captures the generation at spawn time and exits when it 145 + diverges, preventing two concurrent cast loops from fighting over the device. 146 + 147 + ### RELOAD_REQUESTED 148 + 149 + Set by `pcm_chromecast_start()` when `CAST_PLAYING = true` and `CAST_STOP = 150 + false` (i.e. the cast loop is running normally). This covers the pause/resume 151 + case: after a pause the C sink calls `sink_dma_stop()` then `sink_dma_start()`, 152 + and `RELOAD_REQUESTED` signals the monitor loop to reset the buffer and reload 153 + media so the Chromecast reconnects to the fresh stream. 154 + 155 + `RELOAD_REQUESTED` is **not** set when `CAST_STOP = true` (teardown is in 156 + progress) — in that case `pcm_chromecast_start()` detects `CAST_PLAYING = false` 157 + and spawns a new `cast_loop` from scratch instead. 209 158 210 159 ### FFI surface 211 160 212 - The C firmware calls these symbols (defined with `#[no_mangle]` in `pcm.rs`): 161 + The C firmware calls these symbols (`#[cfg(feature = "ffi")]` in `pcm.rs`): 213 162 214 163 ```c 215 164 void pcm_chromecast_set_http_port(uint16_t port); 216 165 void pcm_chromecast_set_device_host(const char *host); 217 166 void pcm_chromecast_set_device_port(uint16_t port); 218 167 void pcm_chromecast_set_sample_rate(uint32_t rate); 219 - int pcm_chromecast_start(void); /* idempotent; starts HTTP + cast threads */ 168 + 169 + // Called from sink_dma_start(). Starts HTTP server on first call (idempotent), 170 + // resets buffer on subsequent calls, spawns cast_loop if not running. 171 + int pcm_chromecast_start(void); 172 + 220 173 int pcm_chromecast_write(const uint8_t *buf, size_t len); 221 - void pcm_chromecast_stop(void); /* no-op — session stays open */ 222 - void pcm_chromecast_close(void); /* stops threads, frees resources */ 174 + 175 + // No-op — Cast session stays alive during pause for seamless resume. 176 + void pcm_chromecast_stop(void); 177 + 178 + // Graceful teardown: increments CAST_GENERATION, sets CAST_STOP, clears 179 + // CAST_PLAYING, closes buffer. HTTP server stays alive. 180 + // Called by the server when switching away from or to the Chromecast sink. 181 + void pcm_chromecast_teardown(void); 182 + 183 + // Full shutdown (process exit): also resets PCM_STARTED. 184 + void pcm_chromecast_close(void); 223 185 ``` 224 186 225 - The C implementation lives in 226 - `firmware/target/hosted/pcm-chromecast.c`, which is registered as 227 - `PCM_SINK_CHROMECAST` in `firmware/export/pcm_sink.h`. 187 + The C implementation lives in `firmware/target/hosted/pcm-chromecast.c`, 188 + registered as `PCM_SINK_CHROMECAST` in `firmware/export/pcm_sink.h`. 228 189 229 190 --- 230 191 231 - ## Device discovery 192 + ## Part 2 — Player trait (src/lib.rs) 232 193 233 - Chromecast devices are discovered automatically via **mDNS** (`_googlecast._tcp.local.`). 234 - The `rockbox-discovery` crate (using `mdns_sd`) browses the LAN and publishes 235 - each found device as a `Device` struct: 194 + `Chromecast::connect(device)` and the `CastPlayerInternal` background task are 195 + retained for completeness but are **not** called by the server connect handler. 196 + The `cast_loop` in `pcm.rs` owns the Cast session exclusively. 236 197 237 - | Field | Value | 238 - |------------------|---------------------------------------| 239 - | `app` | `"chromecast"` | 240 - | `port` | `8009` (Cast protocol) | 241 - | `ip` | IPv4 address of the device | 242 - | `name` | Friendly name from mDNS `fn` property | 243 - | `is_cast_device` | `true` | 198 + If you need to drive the Cast protocol directly (e.g. from a test binary or a 199 + future multi-session feature), `lib.rs` provides: 200 + 201 + | Player method | Cast action | 202 + |----------------|--------------------------------------| 203 + | `play()` | `media.play()` | 204 + | `pause()` | `media.pause()` | 205 + | `resume()` | `media.play()` | 206 + | `stop()` | no-op | 207 + | `disconnect()` | `receiver.stop_app(session_id)` | 244 208 245 - Discovered devices appear in the GraphQL `devices` query and in the web/desktop 246 - UI device picker in real time. 209 + Next / previous are **not** routed through `lib.rs` — the server always calls 210 + `rb::playback::next()` / `rb::playback::prev()` directly, and the `cast_loop` 211 + monitor detects the resulting track-path change and calls `media.load()`. 247 212 248 213 --- 249 214 250 - ## Connecting a device 215 + ## Connect / disconnect flow 251 216 252 - Once discovered, connect via: 217 + ### Connecting to Chromecast 253 218 254 219 ``` 255 - POST /devices/:id/connect 220 + server: PUT /devices/:id/connect (service = "chromecast") 221 + 1. pcm::chromecast_teardown() ← stops any running cast_loop cleanly 222 + 2. pcm::chromecast_set_device_host / port / http_port 223 + 3. pcm::switch_sink(PCM_SINK_CHROMECAST) 224 + 4. settings saved, device marked active 225 + 226 + [firmware starts playing] 227 + 5. sink_dma_start() → pcm_chromecast_start() 228 + · HTTP server starts (first time) or buffer is reset (subsequent) 229 + · CAST_PLAYING = false → spawn cast_loop(gen) 230 + · cast_loop: connect → launch app → media.load → monitor loop 256 231 ``` 257 232 258 - This calls `Chromecast::connect(device)`, stores the resulting `Player` in the 259 - shared HTTP server context, and marks the device as active. All subsequent 260 - play/pause/next/… calls go through this player. 233 + ### Switching away / disconnecting 261 234 262 - Disconnect with: 235 + ``` 236 + server: PUT /devices/:id/connect (service != "chromecast") 237 + — or — 238 + server: PUT /devices/:id/disconnect 263 239 240 + 1. pcm::chromecast_teardown() 241 + · CAST_GENERATION++ ← old cast_loop will see generation mismatch 242 + · CAST_STOP = true ← cast_loop exits monitor loop, stop_app() 243 + · CAST_PLAYING = false 244 + · buffer.close() ← WAV readers unblock and exit 245 + 2. pcm::switch_sink(new sink) 264 246 ``` 265 - POST /devices/:id/disconnect 266 - ``` 247 + 248 + ### Reconnecting to Chromecast (e.g. after using built-in) 249 + 250 + Because `teardown()` set `CAST_PLAYING = false`, the next `pcm_chromecast_start()` 251 + call always spawns a **fresh** `cast_loop` with a new generation — no stale state 252 + from the previous session. 253 + 254 + --- 255 + 256 + ## Device discovery 257 + 258 + Chromecast devices are discovered via **mDNS** (`_googlecast._tcp.local.`). 259 + `scan_chromecast_devices()` in `crates/server/src/scan.rs` browses the LAN and 260 + sets `device.service = "chromecast"` on each result. Devices appear in the 261 + GraphQL `devices` query and the UI device picker in real time. 267 262 268 - This stops the Cast app session and clears the player context. 263 + > **Note**: the `Device::from(ServiceInfo)` conversion does not set the 264 + > `service` field — `scan_chromecast_devices` overrides it explicitly to 265 + > `"chromecast"` so the connect handler routes correctly. 269 266 270 267 --- 271 268 ··· 282 279 chromecast_http_port = 7881 # optional, default 7881 283 280 ``` 284 281 285 - `chromecast_host` must be the LAN IP of the Chromecast device you want to use 286 - as the fixed PCM output sink. If you prefer to select the device dynamically 287 - through the UI, use `audio_output = "builtin"` and connect via the device 288 - picker instead — the WAV stream and Cast session are started on demand. 282 + If you prefer to select the device dynamically via the UI, use 283 + `audio_output = "builtin"` at startup and connect through the device picker. 284 + The WAV stream and Cast session are started on demand when audio plays. 289 285 290 286 ### Port summary 291 287 ··· 303 299 304 300 | Feature | Status | 305 301 |---------------------------------------|---------------------------------------------| 306 - | Play / pause / stop / next / previous | ✅ Implemented | 307 - | Queue management (load, insert) | ✅ Implemented | 308 - | Metadata + album art display | ✅ Implemented | 302 + | Play / pause / resume | ✅ Implemented | 303 + | Next / previous track | ✅ Via `rb::playback::next/prev` + cast_loop | 304 + | Track metadata + album art display | ✅ Implemented | 305 + | Reconnect after output switch | ✅ Via teardown + fresh cast_loop | 309 306 | Volume control | ⏳ Not yet implemented | 310 307 | Seek within track | ⏳ Not yet implemented | 311 308 | Multi-device fan-out | ⏳ Not yet implemented (single device only) |