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.

Add Chromecast PCM sink and integration

Introduce rockbox-chromecast crate (ffi feature) with Rust FFI exports.
Add firmware/target/hosted/pcm-chromecast.c and register
PCM_SINK_CHROMECAST.
Wire settings, sys types, RPC fields and include the crate in
crates/cli.
Update Cargo.lock to record added dependencies.

+947
+3
Cargo.lock
··· 9137 9137 "md5", 9138 9138 "rockbox-graphql", 9139 9139 "rockbox-library", 9140 + "rockbox-sys", 9140 9141 "rockbox-traits", 9141 9142 "rockbox-types", 9142 9143 "sqlx", 9143 9144 "tokio", 9144 9145 "tokio-stream", 9146 + "tracing", 9145 9147 ] 9146 9148 9147 9149 [[package]] ··· 9155 9157 "owo-colors 4.1.0", 9156 9158 "reqwest", 9157 9159 "rockbox-airplay", 9160 + "rockbox-chromecast", 9158 9161 "rockbox-library", 9159 9162 "rockbox-playlists", 9160 9163 "rockbox-rocksky",
+6
crates/chromecast/Cargo.toml
··· 3 3 name = "rockbox-chromecast" 4 4 version = "0.1.0" 5 5 6 + [features] 7 + default = [] 8 + ffi = [] 9 + 6 10 [dependencies] 7 11 anyhow = "1.0.93" 8 12 async-trait = "0.1.83" ··· 11 15 md5 = "0.7.0" 12 16 rockbox-graphql = {path = "../graphql"} 13 17 rockbox-library = {path = "../library"} 18 + rockbox-sys = {path = "../sys"} 14 19 rockbox-traits = {path = "../traits"} 15 20 rockbox-types = {path = "../types"} 16 21 sqlx = {version = "0.8.2", features = ["runtime-tokio", "tls-rustls", "sqlite", "chrono", "derive", "macros"]} 17 22 tokio = {version = "1.36.0", features = ["full"]} 18 23 tokio-stream = "0.1" 24 + tracing = { workspace = true }
+6
crates/chromecast/src/lib.rs
··· 1 + pub mod pcm; 2 + 1 3 use std::{ 2 4 future::Future, 3 5 pin::Pin, ··· 24 26 use rockbox_traits::Player; 25 27 use rockbox_types::device::Device; 26 28 use tokio::sync::mpsc; 29 + 30 + // Called from rockbox-cli to force this crate's symbols into librockbox_cli.a 31 + #[doc(hidden)] 32 + pub fn _link_chromecast() {} 27 33 28 34 const DEFAULT_DESTINATION_ID: &str = "receiver-0"; 29 35 const DEFAULT_APP_ID: &str = "88DCBD57";
+659
crates/chromecast/src/pcm.rs
··· 1 + // Chromecast PCM sink — streams WAV over HTTP and drives playback via Cast protocol. 2 + // 3 + // Architecture: 4 + // 1. An HTTP server serves /stream.wav (live WAV from a broadcast buffer) and 5 + // /now-playing/art (album art from the track's directory). 6 + // 2. A Cast thread connects to the Chromecast device, launches the Default Media 7 + // Receiver app, and tells it to load http://{local_ip}:{port}/stream.wav. 8 + // 3. A monitor loop in the Cast thread detects track changes every 500 ms and 9 + // reloads the media with fresh title/artist/album/art metadata. 10 + // 11 + // FFI surface (called from firmware/target/hosted/pcm-chromecast.c): 12 + // pcm_chromecast_set_http_port(u16) 13 + // pcm_chromecast_set_device_host(*const c_char) 14 + // pcm_chromecast_set_device_port(u16) 15 + // pcm_chromecast_set_sample_rate(u32) 16 + // pcm_chromecast_start() -> c_int (0 = ok, <0 = error) 17 + // pcm_chromecast_write(*const u8, usize) -> c_int 18 + // pcm_chromecast_stop() 19 + // pcm_chromecast_close() 20 + 21 + use std::collections::VecDeque; 22 + use std::io::Write as _; 23 + use std::net::TcpListener; 24 + use std::path::Path; 25 + use std::sync::atomic::{AtomicBool, Ordering}; 26 + use std::sync::{Arc, Condvar, Mutex, OnceLock}; 27 + use std::thread; 28 + use std::time::Duration; 29 + 30 + #[cfg(feature = "ffi")] 31 + use std::ffi::CStr; 32 + #[cfg(feature = "ffi")] 33 + use std::os::raw::{c_char, c_int}; 34 + 35 + use chromecast::{ 36 + channels::{ 37 + media::{Image, Media, Metadata, MusicTrackMediaMetadata, StreamType}, 38 + receiver::CastDeviceApp, 39 + }, 40 + CastDevice, 41 + }; 42 + 43 + // --------------------------------------------------------------------------- 44 + // Broadcast buffer — one writer, N independent readers. 45 + // --------------------------------------------------------------------------- 46 + 47 + enum RecvResult { 48 + Data(Vec<u8>), 49 + Closed, 50 + } 51 + 52 + struct BroadcastBuffer { 53 + inner: Mutex<BroadcastInner>, 54 + condvar: Condvar, 55 + } 56 + 57 + struct BroadcastInner { 58 + chunks: VecDeque<(u64, Vec<u8>)>, 59 + next_seq: u64, 60 + total_bytes: usize, 61 + closed: bool, 62 + } 63 + 64 + const MAX_BUFFERED: usize = 4 * 1024 * 1024; 65 + 66 + impl BroadcastBuffer { 67 + fn new() -> Self { 68 + BroadcastBuffer { 69 + inner: Mutex::new(BroadcastInner { 70 + chunks: VecDeque::new(), 71 + next_seq: 0, 72 + total_bytes: 0, 73 + closed: false, 74 + }), 75 + condvar: Condvar::new(), 76 + } 77 + } 78 + 79 + fn push(&self, data: &[u8]) { 80 + let mut g = self.inner.lock().unwrap(); 81 + if g.closed { 82 + return; 83 + } 84 + let seq = g.next_seq; 85 + g.next_seq += 1; 86 + g.total_bytes += data.len(); 87 + g.chunks.push_back((seq, data.to_vec())); 88 + while g.total_bytes > MAX_BUFFERED { 89 + if let Some((_, old)) = g.chunks.pop_front() { 90 + g.total_bytes -= old.len(); 91 + } else { 92 + break; 93 + } 94 + } 95 + self.condvar.notify_all(); 96 + } 97 + 98 + fn subscribe(self: &Arc<Self>) -> BroadcastReceiver { 99 + let next_seq = self.inner.lock().unwrap().next_seq; 100 + BroadcastReceiver { 101 + buf: Arc::clone(self), 102 + next_seq, 103 + } 104 + } 105 + 106 + fn reset(&self) { 107 + let mut g = self.inner.lock().unwrap(); 108 + g.chunks.clear(); 109 + g.total_bytes = 0; 110 + g.closed = false; 111 + } 112 + 113 + fn close(&self) { 114 + let mut g = self.inner.lock().unwrap(); 115 + g.closed = true; 116 + self.condvar.notify_all(); 117 + } 118 + } 119 + 120 + struct BroadcastReceiver { 121 + buf: Arc<BroadcastBuffer>, 122 + next_seq: u64, 123 + } 124 + 125 + impl BroadcastReceiver { 126 + fn recv_blocking(&mut self) -> RecvResult { 127 + let mut g = self.buf.inner.lock().unwrap(); 128 + loop { 129 + if g.closed { 130 + return RecvResult::Closed; 131 + } 132 + if let Some(&(front_seq, _)) = g.chunks.front() { 133 + if self.next_seq < front_seq { 134 + tracing::debug!( 135 + "chromecast/pcm: receiver lagging, skipping {} → {}", 136 + self.next_seq, 137 + front_seq 138 + ); 139 + self.next_seq = front_seq; 140 + } 141 + if self.next_seq < g.next_seq { 142 + let idx = (self.next_seq - front_seq) as usize; 143 + let chunk = g.chunks[idx].1.clone(); 144 + self.next_seq += 1; 145 + return RecvResult::Data(chunk); 146 + } 147 + } 148 + g = self.buf.condvar.wait(g).unwrap(); 149 + } 150 + } 151 + } 152 + 153 + // --------------------------------------------------------------------------- 154 + // Global state 155 + // --------------------------------------------------------------------------- 156 + 157 + static BUFFER: OnceLock<Arc<BroadcastBuffer>> = OnceLock::new(); 158 + static PCM_STARTED: Mutex<bool> = Mutex::new(false); 159 + static CAST_PLAYING: Mutex<bool> = Mutex::new(false); 160 + static CAST_STOP: AtomicBool = AtomicBool::new(false); 161 + 162 + struct ChromecastPcmConfig { 163 + device_host: String, 164 + device_port: u16, 165 + http_port: u16, 166 + sample_rate: u32, 167 + } 168 + 169 + static CONFIG: Mutex<ChromecastPcmConfig> = Mutex::new(ChromecastPcmConfig { 170 + device_host: String::new(), 171 + device_port: 8009, 172 + http_port: 7881, 173 + sample_rate: 44100, 174 + }); 175 + 176 + fn get_buffer() -> Arc<BroadcastBuffer> { 177 + BUFFER 178 + .get_or_init(|| Arc::new(BroadcastBuffer::new())) 179 + .clone() 180 + } 181 + 182 + fn get_local_ip() -> std::net::Ipv4Addr { 183 + if let Ok(socket) = std::net::UdpSocket::bind("0.0.0.0:0") { 184 + if socket.connect("8.8.8.8:80").is_ok() { 185 + if let Ok(addr) = socket.local_addr() { 186 + if let std::net::IpAddr::V4(ip) = addr.ip() { 187 + return ip; 188 + } 189 + } 190 + } 191 + } 192 + std::net::Ipv4Addr::LOCALHOST 193 + } 194 + 195 + // --------------------------------------------------------------------------- 196 + // Minimal HTTP server — WAV stream + album art endpoint 197 + // --------------------------------------------------------------------------- 198 + 199 + fn wav_header(sample_rate: u32) -> [u8; 44] { 200 + let channels: u32 = 2; 201 + let bits: u32 = 16; 202 + let byte_rate = sample_rate * channels * bits / 8; 203 + let block_align = (channels * bits / 8) as u16; 204 + let mut h = [0u8; 44]; 205 + h[0..4].copy_from_slice(b"RIFF"); 206 + h[4..8].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); 207 + h[8..12].copy_from_slice(b"WAVE"); 208 + h[12..16].copy_from_slice(b"fmt "); 209 + h[16..20].copy_from_slice(&16u32.to_le_bytes()); 210 + h[20..22].copy_from_slice(&1u16.to_le_bytes()); 211 + h[22..24].copy_from_slice(&(channels as u16).to_le_bytes()); 212 + h[24..28].copy_from_slice(&sample_rate.to_le_bytes()); 213 + h[28..32].copy_from_slice(&byte_rate.to_le_bytes()); 214 + h[32..34].copy_from_slice(&block_align.to_le_bytes()); 215 + h[34..36].copy_from_slice(&(bits as u16).to_le_bytes()); 216 + h[36..40].copy_from_slice(b"data"); 217 + h[40..44].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); 218 + h 219 + } 220 + 221 + fn find_album_art(track_path: &str) -> Option<(Vec<u8>, &'static str)> { 222 + let dir = Path::new(track_path).parent()?; 223 + const CANDIDATES: &[(&str, &'static str)] = &[ 224 + ("cover.jpg", "image/jpeg"), 225 + ("cover.jpeg", "image/jpeg"), 226 + ("cover.png", "image/png"), 227 + ("cover.webp", "image/webp"), 228 + ("folder.jpg", "image/jpeg"), 229 + ("folder.jpeg", "image/jpeg"), 230 + ("folder.png", "image/png"), 231 + ("album.jpg", "image/jpeg"), 232 + ("album.png", "image/png"), 233 + ("front.jpg", "image/jpeg"), 234 + ("front.jpeg", "image/jpeg"), 235 + ("front.png", "image/png"), 236 + ("artwork.jpg", "image/jpeg"), 237 + ("artwork.png", "image/png"), 238 + ("AlbumArt.jpg", "image/jpeg"), 239 + ("AlbumArt.jpeg", "image/jpeg"), 240 + ("AlbumArt.png", "image/png"), 241 + ]; 242 + for (name, mime) in CANDIDATES { 243 + let p = dir.join(name); 244 + if let Ok(data) = std::fs::read(&p) { 245 + return Some((data, mime)); 246 + } 247 + } 248 + None 249 + } 250 + 251 + fn parse_request_path(stream: &mut std::net::TcpStream) -> std::io::Result<String> { 252 + use std::io::Read; 253 + let mut buf: Vec<u8> = Vec::with_capacity(1024); 254 + let mut byte = [0u8; 1]; 255 + loop { 256 + stream.read_exact(&mut byte)?; 257 + buf.push(byte[0]); 258 + if buf.ends_with(b"\r\n\r\n") || buf.ends_with(b"\n\n") { 259 + break; 260 + } 261 + if buf.len() > 8192 { 262 + break; 263 + } 264 + } 265 + let raw = String::from_utf8_lossy(&buf); 266 + let path = raw 267 + .lines() 268 + .next() 269 + .and_then(|l| l.split_whitespace().nth(1)) 270 + .unwrap_or("/") 271 + .to_string(); 272 + Ok(path) 273 + } 274 + 275 + pub(crate) fn serve_http(port: u16, sample_rate: u32, buf: Arc<BroadcastBuffer>) { 276 + let listener = match TcpListener::bind(("0.0.0.0", port)) { 277 + Ok(l) => l, 278 + Err(e) => { 279 + tracing::error!("chromecast/pcm: bind :{port} failed: {e}"); 280 + return; 281 + } 282 + }; 283 + tracing::info!("chromecast/pcm: WAV stream on :{port}"); 284 + 285 + for stream in listener.incoming() { 286 + match stream { 287 + Ok(mut tcp) => { 288 + let buf = buf.clone(); 289 + thread::spawn(move || { 290 + let peer = tcp.peer_addr().map(|a| a.to_string()).unwrap_or_default(); 291 + let path = match parse_request_path(&mut tcp) { 292 + Ok(p) => p, 293 + Err(e) => { 294 + tracing::warn!("chromecast/pcm: request read error from {peer}: {e}"); 295 + return; 296 + } 297 + }; 298 + 299 + match path.as_str() { 300 + "/now-playing/art" | "/now-playing/art.jpg" | "/now-playing/art.png" => { 301 + serve_art(&mut tcp); 302 + } 303 + _ => { 304 + serve_wav(&mut tcp, sample_rate, buf, &peer); 305 + } 306 + } 307 + }); 308 + } 309 + Err(e) => tracing::warn!("chromecast/pcm: accept error: {e}"), 310 + } 311 + } 312 + } 313 + 314 + fn serve_art(stream: &mut std::net::TcpStream) { 315 + let art = rockbox_sys::playback::current_track() 316 + .filter(|t| !t.path.is_empty()) 317 + .and_then(|t| find_album_art(&t.path)); 318 + 319 + match art { 320 + Some((data, mime)) => { 321 + let hdr = format!( 322 + "HTTP/1.0 200 OK\r\nContent-Type: {mime}\r\nContent-Length: {}\r\nCache-Control: no-cache\r\n\r\n", 323 + data.len() 324 + ); 325 + let _ = stream.write_all(hdr.as_bytes()); 326 + let _ = stream.write_all(&data); 327 + } 328 + None => { 329 + let _ = stream.write_all(b"HTTP/1.0 404 Not Found\r\nContent-Length: 0\r\n\r\n"); 330 + } 331 + } 332 + } 333 + 334 + fn serve_wav( 335 + stream: &mut std::net::TcpStream, 336 + sample_rate: u32, 337 + buf: Arc<BroadcastBuffer>, 338 + peer: &str, 339 + ) { 340 + let hdr = "HTTP/1.0 200 OK\r\nContent-Type: audio/wav\r\nCache-Control: no-cache\r\n\r\n"; 341 + let wav_hdr = wav_header(sample_rate); 342 + if stream.write_all(hdr.as_bytes()).is_err() || stream.write_all(&wav_hdr).is_err() { 343 + return; 344 + } 345 + tracing::info!("chromecast/pcm: streaming WAV to {peer}"); 346 + 347 + let mut rx = buf.subscribe(); 348 + loop { 349 + match rx.recv_blocking() { 350 + RecvResult::Data(chunk) => { 351 + if stream.write_all(&chunk).is_err() { 352 + tracing::debug!("chromecast/pcm: {peer} disconnected"); 353 + break; 354 + } 355 + } 356 + RecvResult::Closed => break, 357 + } 358 + } 359 + } 360 + 361 + // --------------------------------------------------------------------------- 362 + // Cast protocol thread — connects, loads stream, monitors track changes 363 + // --------------------------------------------------------------------------- 364 + 365 + fn build_media(stream_url: &str, art_url: &str) -> Media { 366 + let track = rockbox_sys::playback::current_track(); 367 + let (title, artist, album) = match &track { 368 + Some(t) => { 369 + let title = if t.title.trim().is_empty() { 370 + t.path 371 + .rsplit('/') 372 + .next() 373 + .and_then(|f| { 374 + f.rsplit('.') 375 + .nth(1) 376 + .map(|_| f.rsplit('.').skip(1).collect::<Vec<_>>().join(".")) 377 + }) 378 + .unwrap_or_else(|| t.path.clone()) 379 + } else { 380 + t.title.clone() 381 + }; 382 + (title, t.artist.clone(), t.album.clone()) 383 + } 384 + None => ("Live Stream".to_string(), String::new(), String::new()), 385 + }; 386 + 387 + let images = if art_url.is_empty() { 388 + vec![] 389 + } else { 390 + vec![Image { 391 + url: art_url.to_string(), 392 + dimensions: None, 393 + }] 394 + }; 395 + 396 + Media { 397 + content_id: stream_url.to_string(), 398 + content_type: "audio/wav".to_string(), 399 + stream_type: StreamType::Live, 400 + duration: None, 401 + metadata: Some(Metadata::MusicTrack(MusicTrackMediaMetadata { 402 + title: Some(title), 403 + artist: Some(artist), 404 + album_name: Some(album), 405 + album_artist: None, 406 + track_number: None, 407 + disc_number: None, 408 + images, 409 + release_date: None, 410 + composer: None, 411 + })), 412 + } 413 + } 414 + 415 + fn cast_session(host: &str, device_port: u16, stream_url: &str, art_url: &str) -> bool { 416 + let cast_device = match CastDevice::connect_without_host_verification(host, device_port) { 417 + Ok(d) => d, 418 + Err(e) => { 419 + tracing::error!( 420 + "chromecast/pcm: connect to {}:{} failed: {}", 421 + host, 422 + device_port, 423 + e 424 + ); 425 + return false; 426 + } 427 + }; 428 + 429 + if cast_device 430 + .connection 431 + .connect("receiver-0".to_string()) 432 + .is_err() 433 + { 434 + tracing::error!("chromecast/pcm: connection.connect failed"); 435 + return false; 436 + } 437 + if cast_device.heartbeat.ping().is_err() { 438 + tracing::error!("chromecast/pcm: initial ping failed"); 439 + return false; 440 + } 441 + 442 + let app = match cast_device 443 + .receiver 444 + .launch_app(&CastDeviceApp::DefaultMediaReceiver) 445 + { 446 + Ok(app) => app, 447 + Err(e) => { 448 + tracing::error!("chromecast/pcm: launch_app failed: {}", e); 449 + return false; 450 + } 451 + }; 452 + 453 + if cast_device 454 + .connection 455 + .connect(app.transport_id.as_str()) 456 + .is_err() 457 + { 458 + tracing::error!("chromecast/pcm: connect to app transport failed"); 459 + return false; 460 + } 461 + 462 + let media = build_media(stream_url, art_url); 463 + let track_title = media 464 + .metadata 465 + .as_ref() 466 + .and_then(|m| match m { 467 + Metadata::MusicTrack(m) => m.title.clone(), 468 + _ => None, 469 + }) 470 + .unwrap_or_default(); 471 + 472 + if let Err(e) = cast_device 473 + .media 474 + .load(app.transport_id.as_str(), "", &media) 475 + { 476 + tracing::warn!("chromecast/pcm: media.load failed: {}", e); 477 + return false; 478 + } 479 + tracing::info!( 480 + "chromecast/pcm: playing «{}» on {}:{}", 481 + track_title, 482 + host, 483 + device_port 484 + ); 485 + 486 + let mut last_track_path = rockbox_sys::playback::current_track() 487 + .map(|t| t.path) 488 + .unwrap_or_default(); 489 + 490 + // Monitor loop: heartbeat + track-change metadata updates 491 + loop { 492 + if CAST_STOP.load(Ordering::SeqCst) { 493 + let _ = cast_device.receiver.stop_app(app.session_id.as_str()); 494 + return true; 495 + } 496 + 497 + thread::sleep(Duration::from_millis(500)); 498 + 499 + if cast_device.heartbeat.ping().is_err() { 500 + tracing::warn!("chromecast/pcm: heartbeat lost, reconnecting"); 501 + return false; // caller will retry 502 + } 503 + 504 + let current = rockbox_sys::playback::current_track(); 505 + let current_path = current.as_ref().map(|t| t.path.clone()).unwrap_or_default(); 506 + 507 + if !current_path.is_empty() && current_path != last_track_path { 508 + last_track_path = current_path; 509 + let updated = build_media(stream_url, art_url); 510 + let new_title = updated 511 + .metadata 512 + .as_ref() 513 + .and_then(|m| match m { 514 + Metadata::MusicTrack(m) => m.title.clone(), 515 + _ => None, 516 + }) 517 + .unwrap_or_default(); 518 + 519 + // Reload with new metadata; brief HTTP reconnect is acceptable 520 + if let Err(e) = cast_device 521 + .media 522 + .load(app.transport_id.as_str(), "", &updated) 523 + { 524 + tracing::warn!("chromecast/pcm: track reload failed: {}, reconnecting", e); 525 + return false; 526 + } 527 + tracing::info!("chromecast/pcm: track change → «{}»", new_title); 528 + } 529 + } 530 + } 531 + 532 + fn cast_loop(host: String, device_port: u16, http_port: u16) { 533 + let local_ip = get_local_ip(); 534 + let stream_url = format!("http://{}:{}/stream.wav", local_ip, http_port); 535 + let art_url = format!("http://{}:{}/now-playing/art", local_ip, http_port); 536 + 537 + while !CAST_STOP.load(Ordering::SeqCst) { 538 + let ok = cast_session(&host, device_port, &stream_url, &art_url); 539 + if ok || CAST_STOP.load(Ordering::SeqCst) { 540 + break; 541 + } 542 + // Brief pause before reconnect attempt 543 + thread::sleep(Duration::from_secs(3)); 544 + } 545 + 546 + *CAST_PLAYING.lock().unwrap() = false; 547 + } 548 + 549 + // --------------------------------------------------------------------------- 550 + // FFI exports 551 + // --------------------------------------------------------------------------- 552 + 553 + #[cfg(feature = "ffi")] 554 + #[no_mangle] 555 + pub extern "C" fn pcm_chromecast_set_http_port(port: u16) { 556 + CONFIG.lock().unwrap().http_port = port; 557 + } 558 + 559 + #[cfg(feature = "ffi")] 560 + #[no_mangle] 561 + pub extern "C" fn pcm_chromecast_set_device_host(host: *const c_char) { 562 + if host.is_null() { 563 + CONFIG.lock().unwrap().device_host = String::new(); 564 + return; 565 + } 566 + let s = unsafe { CStr::from_ptr(host) } 567 + .to_str() 568 + .unwrap_or("") 569 + .to_string(); 570 + CONFIG.lock().unwrap().device_host = s; 571 + } 572 + 573 + #[cfg(feature = "ffi")] 574 + #[no_mangle] 575 + pub extern "C" fn pcm_chromecast_set_device_port(port: u16) { 576 + CONFIG.lock().unwrap().device_port = port; 577 + } 578 + 579 + #[cfg(feature = "ffi")] 580 + #[no_mangle] 581 + pub extern "C" fn pcm_chromecast_set_sample_rate(rate: u32) { 582 + CONFIG.lock().unwrap().sample_rate = rate; 583 + } 584 + 585 + #[cfg(feature = "ffi")] 586 + #[no_mangle] 587 + pub extern "C" fn pcm_chromecast_start() -> c_int { 588 + // Start the HTTP broadcast server once 589 + { 590 + let mut started = PCM_STARTED.lock().unwrap(); 591 + if !*started { 592 + let buf = get_buffer(); 593 + buf.reset(); 594 + let (http_port, sample_rate) = { 595 + let cfg = CONFIG.lock().unwrap(); 596 + (cfg.http_port, cfg.sample_rate) 597 + }; 598 + let buf_http = buf.clone(); 599 + thread::spawn(move || serve_http(http_port, sample_rate, buf_http)); 600 + *started = true; 601 + tracing::info!("chromecast/pcm: WAV stream started on :{http_port}"); 602 + } 603 + } 604 + 605 + // Spawn the Cast protocol thread if not already running 606 + let already_playing = { 607 + let mut p = CAST_PLAYING.lock().unwrap(); 608 + let was = *p; 609 + if !was { 610 + *p = true; 611 + } 612 + was 613 + }; 614 + 615 + if !already_playing { 616 + CAST_STOP.store(false, Ordering::SeqCst); 617 + let (host, device_port, http_port) = { 618 + let cfg = CONFIG.lock().unwrap(); 619 + (cfg.device_host.clone(), cfg.device_port, cfg.http_port) 620 + }; 621 + if host.is_empty() { 622 + tracing::warn!( 623 + "chromecast/pcm: no device host configured — WAV stream active but Cast disabled" 624 + ); 625 + *CAST_PLAYING.lock().unwrap() = false; 626 + } else { 627 + thread::spawn(move || cast_loop(host, device_port, http_port)); 628 + } 629 + } 630 + 631 + 0 632 + } 633 + 634 + #[cfg(feature = "ffi")] 635 + #[no_mangle] 636 + pub extern "C" fn pcm_chromecast_write(data: *const u8, len: usize) -> c_int { 637 + if data.is_null() || len == 0 { 638 + return 0; 639 + } 640 + let slice = unsafe { std::slice::from_raw_parts(data, len) }; 641 + get_buffer().push(slice); 642 + 0 643 + } 644 + 645 + #[cfg(feature = "ffi")] 646 + #[no_mangle] 647 + pub extern "C" fn pcm_chromecast_stop() { 648 + CAST_STOP.store(true, Ordering::SeqCst); 649 + } 650 + 651 + #[cfg(feature = "ffi")] 652 + #[no_mangle] 653 + pub extern "C" fn pcm_chromecast_close() { 654 + CAST_STOP.store(true, Ordering::SeqCst); 655 + let mut started = PCM_STARTED.lock().unwrap(); 656 + get_buffer().close(); 657 + *started = false; 658 + *CAST_PLAYING.lock().unwrap() = false; 659 + }
+1
crates/cli/Cargo.toml
··· 11 11 rockbox-airplay = {path = "../airplay"} 12 12 rockbox-slim = {path = "../slim"} 13 13 rockbox-upnp = {path = "../upnp", features = ["ffi"]} 14 + rockbox-chromecast = {path = "../chromecast", features = ["ffi"]} 14 15 clap = "4.5.16" 15 16 owo-colors = "4.1.0" 16 17 reqwest = { workspace = true, features = ["rustls-tls", "json"] }
+2
crates/cli/src/lib.rs
··· 5 5 use owo_colors::OwoColorize; 6 6 #[allow(unused_imports)] 7 7 use rockbox_airplay::_link_airplay as _; 8 + #[allow(unused_imports)] 9 + use rockbox_chromecast::_link_chromecast as _; 8 10 use rockbox_library::audio_scan::{save_audio_metadata, scan_audio_files}; 9 11 use rockbox_library::{create_connection_pool, repo}; 10 12 use rockbox_playlists::PlaylistStore;
+3
crates/rpc/src/lib.rs
··· 945 945 upnp_renderer_url: None, 946 946 upnp_http_port: None, 947 947 upnp_server_enabled: None, 948 + chromecast_host: None, 949 + chromecast_http_port: None, 950 + chromecast_port: None, 948 951 } 949 952 } 950 953 }
+19
crates/settings/src/lib.rs
··· 79 79 } 80 80 pcm::switch_sink(pcm::PCM_SINK_UPNP); 81 81 } 82 + Some("chromecast") => { 83 + let http_port = settings.chromecast_http_port.unwrap_or(7881); 84 + pcm::chromecast_set_http_port(http_port); 85 + if let Some(ref host) = settings.chromecast_host { 86 + let device_port = settings.chromecast_port.unwrap_or(8009); 87 + pcm::chromecast_set_device_host(host); 88 + pcm::chromecast_set_device_port(device_port); 89 + tracing::info!( 90 + "audio output: chromecast (WAV stream :{http_port}, device {}:{})", 91 + host, 92 + device_port 93 + ); 94 + } else { 95 + tracing::warn!( 96 + "audio output: chromecast selected but no chromecast_host configured" 97 + ); 98 + } 99 + pcm::switch_sink(pcm::PCM_SINK_CHROMECAST); 100 + } 82 101 Some("builtin") | None => { 83 102 tracing::info!("audio output: builtin (SDL)"); 84 103 }
+3
crates/sys/src/lib.rs
··· 1156 1156 fn pcm_upnp_set_http_port(port: c_ushort); 1157 1157 fn pcm_upnp_set_renderer_url(url: *const c_char); 1158 1158 fn pcm_upnp_set_sample_rate(rate: c_uint); 1159 + fn pcm_chromecast_set_http_port(port: c_ushort); 1160 + fn pcm_chromecast_set_device_host(host: *const c_char); 1161 + fn pcm_chromecast_set_device_port(port: c_ushort); 1159 1162 fn beep_play(frequency: c_uint, duration: c_uint, amplitude: c_uint); 1160 1163 fn dsp_set_crossfeed_type(r#type: c_int); 1161 1164 fn dsp_eq_enable(enable: c_uchar);
+15
crates/sys/src/sound/pcm.rs
··· 7 7 pub const PCM_SINK_AIRPLAY: i32 = 2; 8 8 pub const PCM_SINK_SQUEEZELITE: i32 = 3; 9 9 pub const PCM_SINK_UPNP: i32 = 4; 10 + pub const PCM_SINK_CHROMECAST: i32 = 5; 10 11 11 12 pub fn apply_settings() { 12 13 unsafe { ··· 99 100 pub fn upnp_clear_renderer_url() { 100 101 unsafe { crate::pcm_upnp_set_renderer_url(std::ptr::null()) } 101 102 } 103 + 104 + pub fn chromecast_set_http_port(port: u16) { 105 + unsafe { crate::pcm_chromecast_set_http_port(port) } 106 + } 107 + 108 + pub fn chromecast_set_device_host(host: &str) { 109 + let chost = std::ffi::CString::new(host).expect("host must not contain null bytes"); 110 + unsafe { crate::pcm_chromecast_set_device_host(chost.as_ptr()) } 111 + std::mem::forget(chost); 112 + } 113 + 114 + pub fn chromecast_set_device_port(port: u16) { 115 + unsafe { crate::pcm_chromecast_set_device_port(port) } 116 + }
+9
crates/sys/src/types/user_settings.rs
··· 715 715 pub upnp_renderer_enabled: Option<bool>, 716 716 /// HTTP port for the UPnP renderer (default: 7880) 717 717 pub upnp_renderer_port: Option<u16>, 718 + /// IP address of the Chromecast device (required for chromecast output) 719 + pub chromecast_host: Option<String>, 720 + /// Cast protocol port on the Chromecast device (default: 8009) 721 + pub chromecast_port: Option<u16>, 722 + /// HTTP port for the Chromecast WAV stream (default: 7881) 723 + pub chromecast_http_port: Option<u16>, 718 724 } 719 725 720 726 impl From<UserSettings> for NewGlobalSettings { ··· 762 768 upnp_renderer_url: None, 763 769 upnp_renderer_enabled: None, 764 770 upnp_renderer_port: None, 771 + chromecast_host: None, 772 + chromecast_port: None, 773 + chromecast_http_port: None, 765 774 } 766 775 } 767 776 }
+1
firmware/SOURCES
··· 540 540 target/hosted/pcm-airplay.c 541 541 target/hosted/pcm-squeezelite.c 542 542 target/hosted/pcm-upnp.c 543 + target/hosted/pcm-chromecast.c 543 544 #endif 544 545 #ifdef HAVE_SW_VOLUME_CONTROL 545 546 pcm_sw_volume.c
+7
firmware/export/pcm_sink.h
··· 57 57 PCM_SINK_AIRPLAY, 58 58 PCM_SINK_SQUEEZELITE, 59 59 PCM_SINK_UPNP, 60 + PCM_SINK_CHROMECAST, 60 61 #endif 61 62 PCM_SINK_NUM 62 63 }; ··· 79 80 extern struct pcm_sink upnp_pcm_sink; 80 81 void pcm_upnp_set_http_port(uint16_t port); 81 82 void pcm_upnp_set_renderer_url(const char *url); 83 + 84 + /* Chromecast sink — streams WAV over HTTP and loads via Cast protocol */ 85 + extern struct pcm_sink chromecast_pcm_sink; 86 + void pcm_chromecast_set_http_port(uint16_t port); 87 + void pcm_chromecast_set_device_host(const char *host); 88 + void pcm_chromecast_set_device_port(uint16_t port); 82 89 #endif
+1
firmware/pcm.c
··· 85 85 [PCM_SINK_AIRPLAY] = &airplay_pcm_sink, 86 86 [PCM_SINK_SQUEEZELITE] = &squeezelite_pcm_sink, 87 87 [PCM_SINK_UPNP] = &upnp_pcm_sink, 88 + [PCM_SINK_CHROMECAST] = &chromecast_pcm_sink, 88 89 #endif 89 90 }; 90 91 static enum pcm_sink_ids cur_sink = PCM_SINK_BUILTIN;
+212
firmware/target/hosted/pcm-chromecast.c
··· 1 + /*************************************************************************** 2 + * PCM sink that streams raw S16LE stereo PCM to Chromecast devices as a 3 + * continuous WAV stream over HTTP (port 7881 by default), and tells the 4 + * Chromecast to load that URL via the Cast Media protocol. 5 + * 6 + * Usage: 7 + * pcm_chromecast_set_http_port(7881); // optional, this is the default 8 + * pcm_chromecast_set_device_host("192.168.1.x"); // Chromecast device IP 9 + * pcm_chromecast_set_device_port(8009); // optional, default 8009 10 + * pcm_switch_sink(PCM_SINK_CHROMECAST); 11 + * 12 + * Copyright (C) 2026 Rockbox contributors 13 + * 14 + * This program is free software; you can redistribute it and/or 15 + * modify it under the terms of the GNU General Public License 16 + * as published by the Free Software Foundation; either version 2 17 + * of the License, or (at your option) any later version. 18 + * 19 + * This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY 20 + * KIND, either express or implied. 21 + * 22 + ****************************************************************************/ 23 + 24 + #include "autoconf.h" 25 + #include "config.h" 26 + 27 + #include <pthread.h> 28 + #include <stdbool.h> 29 + #include <stddef.h> 30 + #include <stdint.h> 31 + #include <time.h> 32 + #include <unistd.h> 33 + 34 + #include "pcm.h" 35 + #include "pcm-internal.h" 36 + #include "pcm_mixer.h" 37 + #include "pcm_sampr.h" 38 + #include "pcm_sink.h" 39 + 40 + #define LOGF_ENABLE 41 + #include "logf.h" 42 + 43 + /* Rust C API — symbols provided by the rockbox-chromecast crate via librockbox_cli.a */ 44 + extern void pcm_chromecast_set_http_port(uint16_t port); 45 + extern void pcm_chromecast_set_device_host(const char *host); 46 + extern void pcm_chromecast_set_device_port(uint16_t port); 47 + extern void pcm_chromecast_set_sample_rate(uint32_t rate); 48 + extern int pcm_chromecast_start(void); 49 + extern int pcm_chromecast_write(const uint8_t *data, size_t len); 50 + extern void pcm_chromecast_stop(void); 51 + extern void pcm_chromecast_close(void); 52 + 53 + static const void *pcm_data = NULL; 54 + static size_t pcm_size = 0; 55 + 56 + static pthread_mutex_t chromecast_mtx; 57 + static pthread_t chromecast_tid; 58 + static volatile bool chromecast_running = false; 59 + static volatile bool chromecast_stop = false; 60 + 61 + /* Real-time pacing — reset on every sink_dma_start(). */ 62 + static struct timespec play_start; 63 + static uint64_t play_bytes; 64 + 65 + /* Actual sample rate set by sink_set_freq(); defaults to 44100. 66 + * bytes_per_sec = sample_rate * 2 channels * 2 bytes/sample */ 67 + static unsigned long current_sample_rate = 44100; 68 + 69 + static void *chromecast_thread(void *arg) 70 + { 71 + (void)arg; 72 + 73 + while (!chromecast_stop) { 74 + pthread_mutex_lock(&chromecast_mtx); 75 + const void *data = pcm_data; 76 + size_t size = pcm_size; 77 + pcm_data = NULL; 78 + pcm_size = 0; 79 + pthread_mutex_unlock(&chromecast_mtx); 80 + 81 + if (data && size > 0) { 82 + if (pcm_chromecast_write((const uint8_t *)data, size) < 0) { 83 + logf("pcm-chromecast: write error"); 84 + chromecast_stop = true; 85 + break; 86 + } 87 + 88 + /* Pace to real-time so the DMA loop does not drain the entire 89 + * track instantly. Same technique as pcm-upnp.c — use 90 + * signed int64_t for the nanosecond diff to avoid uint wrap. */ 91 + play_bytes += size; 92 + uint64_t bps = (uint64_t)current_sample_rate * 4; 93 + uint64_t expected_us = play_bytes * 1000000ULL / bps; 94 + 95 + struct timespec now; 96 + clock_gettime(CLOCK_MONOTONIC, &now); 97 + int64_t elapsed_us = 98 + (int64_t)(now.tv_sec - play_start.tv_sec) * 1000000LL + 99 + ((int64_t)now.tv_nsec - (int64_t)play_start.tv_nsec) / 1000LL; 100 + 101 + if (elapsed_us >= 0 && expected_us > (uint64_t)elapsed_us) { 102 + usleep((useconds_t)(expected_us - (uint64_t)elapsed_us)); 103 + } 104 + } 105 + 106 + if (chromecast_stop) 107 + break; 108 + 109 + pthread_mutex_lock(&chromecast_mtx); 110 + bool got_more = pcm_play_dma_complete_callback(PCM_DMAST_OK, 111 + &pcm_data, &pcm_size); 112 + pthread_mutex_unlock(&chromecast_mtx); 113 + 114 + if (!got_more) { 115 + logf("pcm-chromecast: no more PCM data"); 116 + break; 117 + } 118 + 119 + pcm_play_dma_status_callback(PCM_DMAST_STARTED); 120 + } 121 + 122 + chromecast_running = false; 123 + return NULL; 124 + } 125 + 126 + static void sink_dma_init(void) 127 + { 128 + pthread_mutexattr_t attr; 129 + pthread_mutexattr_init(&attr); 130 + pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE); 131 + pthread_mutex_init(&chromecast_mtx, &attr); 132 + pthread_mutexattr_destroy(&attr); 133 + } 134 + 135 + static void sink_dma_postinit(void) 136 + { 137 + } 138 + 139 + static void sink_set_freq(uint16_t freq) 140 + { 141 + current_sample_rate = hw_freq_sampr[freq]; 142 + pcm_chromecast_set_sample_rate((uint32_t)current_sample_rate); 143 + logf("pcm-chromecast: sample rate %lu Hz", current_sample_rate); 144 + } 145 + 146 + static void sink_lock(void) 147 + { 148 + pthread_mutex_lock(&chromecast_mtx); 149 + } 150 + 151 + static void sink_unlock(void) 152 + { 153 + pthread_mutex_unlock(&chromecast_mtx); 154 + } 155 + 156 + static void sink_dma_start(const void *addr, size_t size) 157 + { 158 + logf("pcm-chromecast: start (%p, %zu)", addr, size); 159 + 160 + if (pcm_chromecast_start() < 0) { 161 + logf("pcm-chromecast: server start failed"); 162 + return; 163 + } 164 + 165 + clock_gettime(CLOCK_MONOTONIC, &play_start); 166 + play_bytes = 0; 167 + 168 + pthread_mutex_lock(&chromecast_mtx); 169 + pcm_data = addr; 170 + pcm_size = size; 171 + pthread_mutex_unlock(&chromecast_mtx); 172 + 173 + chromecast_stop = false; 174 + chromecast_running = true; 175 + pthread_create(&chromecast_tid, NULL, chromecast_thread, NULL); 176 + } 177 + 178 + static void sink_dma_stop(void) 179 + { 180 + logf("pcm-chromecast: stop"); 181 + 182 + chromecast_stop = true; 183 + 184 + if (chromecast_running) { 185 + pthread_join(chromecast_tid, NULL); 186 + chromecast_running = false; 187 + } 188 + 189 + pthread_mutex_lock(&chromecast_mtx); 190 + pcm_data = NULL; 191 + pcm_size = 0; 192 + pthread_mutex_unlock(&chromecast_mtx); 193 + 194 + pcm_chromecast_stop(); 195 + } 196 + 197 + struct pcm_sink chromecast_pcm_sink = { 198 + .caps = { 199 + .samprs = hw_freq_sampr, 200 + .num_samprs = HW_NUM_FREQ, 201 + .default_freq = HW_FREQ_DEFAULT, 202 + }, 203 + .ops = { 204 + .init = sink_dma_init, 205 + .postinit = sink_dma_postinit, 206 + .set_freq = sink_set_freq, 207 + .lock = sink_lock, 208 + .unlock = sink_unlock, 209 + .play = sink_dma_start, 210 + .stop = sink_dma_stop, 211 + }, 212 + };