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.

Merge pull request #153 from tsirysndr/feat/chromecast-sink

Enhance Chromecast WAV header accuracy and art caching

authored by

Tsiry Sandratraina and committed by
GitHub
3a274fbf 227ac144

+67 -22
+67 -22
crates/chromecast/src/pcm.rs
··· 22 22 use std::io::Write as _; 23 23 use std::net::TcpListener; 24 24 use std::path::Path; 25 + use std::str::FromStr; 25 26 use std::sync::atomic::{AtomicBool, Ordering}; 26 27 use std::sync::{Arc, Condvar, Mutex, OnceLock}; 27 28 use std::thread; ··· 196 197 // Minimal HTTP server — WAV stream + album art endpoint 197 198 // --------------------------------------------------------------------------- 198 199 199 - fn wav_header(sample_rate: u32) -> [u8; 44] { 200 + // `data_size` = number of PCM bytes for this track (2ch * 2B * samples). 201 + // Chromecast reads the WAV RIFF/data sizes to derive the duration for its 202 + // progress bar — an 0xFFFFFFFF sentinel causes it to display ∞. 203 + fn wav_header(sample_rate: u32, data_size: u32) -> [u8; 44] { 200 204 let channels: u32 = 2; 201 205 let bits: u32 = 16; 202 206 let byte_rate = sample_rate * channels * bits / 8; 203 207 let block_align = (channels * bits / 8) as u16; 204 208 let mut h = [0u8; 44]; 205 209 h[0..4].copy_from_slice(b"RIFF"); 206 - h[4..8].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); 210 + h[4..8].copy_from_slice(&data_size.saturating_add(36).to_le_bytes()); 207 211 h[8..12].copy_from_slice(b"WAVE"); 208 212 h[12..16].copy_from_slice(b"fmt "); 209 213 h[16..20].copy_from_slice(&16u32.to_le_bytes()); ··· 214 218 h[32..34].copy_from_slice(&block_align.to_le_bytes()); 215 219 h[34..36].copy_from_slice(&(bits as u16).to_le_bytes()); 216 220 h[36..40].copy_from_slice(b"data"); 217 - h[40..44].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); 221 + h[40..44].copy_from_slice(&data_size.to_le_bytes()); 218 222 h 219 223 } 220 224 ··· 296 300 } 297 301 }; 298 302 299 - match path.as_str() { 303 + // Strip query string before matching so ?t=N cache-busting 304 + // parameters on the art URL are ignored by the router. 305 + let path_clean = path.split('?').next().unwrap_or(&path); 306 + match path_clean { 300 307 "/now-playing/art" | "/now-playing/art.jpg" | "/now-playing/art.png" => { 301 308 serve_art(&mut tcp); 302 309 } ··· 338 345 peer: &str, 339 346 ) { 340 347 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); 348 + 349 + // Compute the exact PCM byte count from the track length so the Chromecast 350 + // can display an accurate progress bar. Falls back to 0xFFFFFFFF only when 351 + // no track info is available (pre-playback probe connections, etc.). 352 + let duration_ms = rockbox_sys::playback::current_track() 353 + .map(|t| t.length) 354 + .filter(|&l| l > 0) 355 + .unwrap_or(0); 356 + let byte_rate = sample_rate as u64 * 4; // 2 channels × 2 bytes/sample 357 + let data_size = if duration_ms > 0 { 358 + ((duration_ms * byte_rate) / 1000).min(u32::MAX as u64) as u32 359 + } else { 360 + 0xFFFF_FFFF 361 + }; 362 + 363 + let wav_hdr = wav_header(sample_rate, data_size); 342 364 if stream.write_all(hdr.as_bytes()).is_err() || stream.write_all(&wav_hdr).is_err() { 343 365 return; 344 366 } ··· 362 384 // Cast protocol thread — connects, loads stream, monitors track changes 363 385 // --------------------------------------------------------------------------- 364 386 365 - fn build_media(stream_url: &str, art_url: &str) -> Media { 387 + // `art_seq` is incremented on every track change so the Chromecast sees a 388 + // distinct URL and re-fetches the cover art rather than using its cached copy. 389 + fn build_media(stream_url: &str, art_url_base: &str, art_seq: u64) -> Media { 366 390 let track = rockbox_sys::playback::current_track(); 367 391 let (title, artist, album) = match &track { 368 392 Some(t) => { ··· 384 408 None => ("Live Stream".to_string(), String::new(), String::new()), 385 409 }; 386 410 387 - let images = if art_url.is_empty() { 411 + let images = if art_url_base.is_empty() { 388 412 vec![] 389 413 } else { 390 414 vec![Image { 391 - url: art_url.to_string(), 415 + url: format!("{}?t={}", art_url_base, art_seq), 392 416 dimensions: None, 393 417 }] 394 418 }; 395 419 420 + // Duration in seconds for the Cast receiver UI. Chromecast shows ∞/NaN when 421 + // this is None with StreamType::Live, so we provide the real track length. 422 + let duration_secs = track.as_ref().and_then(|t| { 423 + if t.length > 0 { 424 + Some(t.length as f32 / 1000.0) 425 + } else { 426 + None 427 + } 428 + }); 429 + 396 430 Media { 397 431 content_id: stream_url.to_string(), 398 432 content_type: "audio/wav".to_string(), 399 - stream_type: StreamType::Live, 400 - duration: None, 433 + stream_type: if duration_secs.is_some() { 434 + StreamType::Buffered 435 + } else { 436 + StreamType::Live 437 + }, 438 + duration: duration_secs, 401 439 metadata: Some(Metadata::MusicTrack(MusicTrackMediaMetadata { 402 440 title: Some(title), 403 441 artist: Some(artist), ··· 412 450 } 413 451 } 414 452 415 - fn cast_session(host: &str, device_port: u16, stream_url: &str, art_url: &str) -> bool { 453 + const ROCKBOX_APP_ID: &str = "88DCBD57"; 454 + 455 + fn cast_session(host: &str, device_port: u16, stream_url: &str, art_url_base: &str) -> bool { 416 456 let cast_device = match CastDevice::connect_without_host_verification(host, device_port) { 417 457 Ok(d) => d, 418 458 Err(e) => { ··· 439 479 return false; 440 480 } 441 481 442 - let app = match cast_device 443 - .receiver 444 - .launch_app(&CastDeviceApp::DefaultMediaReceiver) 445 - { 482 + let app_to_run = CastDeviceApp::from_str(ROCKBOX_APP_ID).unwrap(); 483 + let app = match cast_device.receiver.launch_app(&app_to_run) { 446 484 Ok(app) => app, 447 485 Err(e) => { 448 - tracing::error!("chromecast/pcm: launch_app failed: {}", e); 486 + tracing::error!( 487 + "chromecast/pcm: launch_app({}) failed: {}", 488 + ROCKBOX_APP_ID, 489 + e 490 + ); 449 491 return false; 450 492 } 451 493 }; ··· 459 501 return false; 460 502 } 461 503 462 - let media = build_media(stream_url, art_url); 504 + let mut art_seq: u64 = 0; 505 + let media = build_media(stream_url, art_url_base, art_seq); 463 506 let track_title = media 464 507 .metadata 465 508 .as_ref() ··· 506 549 507 550 if !current_path.is_empty() && current_path != last_track_path { 508 551 last_track_path = current_path; 509 - let updated = build_media(stream_url, art_url); 552 + // Increment art_seq so the Chromecast re-fetches cover art 553 + art_seq += 1; 554 + let updated = build_media(stream_url, art_url_base, art_seq); 510 555 let new_title = updated 511 556 .metadata 512 557 .as_ref() ··· 516 561 }) 517 562 .unwrap_or_default(); 518 563 519 - // Reload with new metadata; brief HTTP reconnect is acceptable 520 564 if let Err(e) = cast_device 521 565 .media 522 566 .load(app.transport_id.as_str(), "", &updated) ··· 532 576 fn cast_loop(host: String, device_port: u16, http_port: u16) { 533 577 let local_ip = get_local_ip(); 534 578 let stream_url = format!("http://{}:{}/stream.wav", local_ip, http_port); 535 - let art_url = format!("http://{}:{}/now-playing/art", local_ip, http_port); 579 + let art_url_base = format!("http://{}:{}/now-playing/art", local_ip, http_port); 536 580 537 581 while !CAST_STOP.load(Ordering::SeqCst) { 538 - let ok = cast_session(&host, device_port, &stream_url, &art_url); 582 + let ok = cast_session(&host, device_port, &stream_url, &art_url_base); 539 583 if ok || CAST_STOP.load(Ordering::SeqCst) { 540 584 break; 541 585 } ··· 645 689 #[cfg(feature = "ffi")] 646 690 #[no_mangle] 647 691 pub extern "C" fn pcm_chromecast_stop() { 648 - CAST_STOP.store(true, Ordering::SeqCst); 692 + // No-op: the Cast session stays connected during pause so the Chromecast 693 + // player UI remains open and resumes seamlessly when data flows again. 649 694 } 650 695 651 696 #[cfg(feature = "ffi")]