From ed2e660d3466aa987124a68414332605f3d9b5c4 Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Wed, 14 Jan 2026 17:55:18 +0100 Subject: [PATCH] fixed cast play --- sidecar/index.js | 30 +-- src-tauri/Cargo.toml | 5 +- src-tauri/src/lib.rs | 374 ++++++++++++++++++++++++-------------- src-tauri/src/player.rs | 157 +++++++++++++++- src-tauri/tauri.conf.json | 2 +- src/main.js | 7 + tools/copy-binaries.js | 33 +++- 7 files changed, 438 insertions(+), 170 deletions(-) diff --git a/sidecar/index.js b/sidecar/index.js index f519871..82d6720 100644 --- a/sidecar/index.js +++ b/sidecar/index.js @@ -23,15 +23,21 @@ function stopSessions(client, sessions, cb) { const session = remaining.shift(); if (!session) return cb(); - client.stop(session, (err) => { - if (err) { - log(`Stop session failed (${session.appId || 'unknown app'}): ${err.message || String(err)}`); - } else { - log(`Stopped session (${session.appId || 'unknown app'})`); - } - // Continue regardless; best-effort. + try { + client.stop(session, (err) => { + if (err) { + log(`Stop session failed (${session.appId || 'unknown app'}): ${err.message || String(err)}`); + } else { + log(`Stopped session (${session.appId || 'unknown app'})`); + } + // Continue regardless; best-effort. + stopNext(); + }); + } catch (err) { + // Some devices/library versions may throw synchronously; just log and continue. + log(`Stop session threw (${session.appId || 'unknown app'}): ${err.message || String(err)}`); stopNext(); - }); + } }; stopNext(); @@ -107,13 +113,11 @@ function play(ip, url) { } }); } else { - // If another app is running, stop it first to avoid NOT_ALLOWED. + // Backdrop or other non-media session present: skip stopping to avoid platform sender crash, just launch. if (sessions.length > 0) { - log('Non-media session detected, stopping before launch...'); - stopSessions(activeClient, sessions, () => launchPlayer(url, /*didStopFirst*/ true)); - } else { - launchPlayer(url, /*didStopFirst*/ false); + log('Non-media session detected; skipping stop and launching DefaultMediaReceiver...'); } + launchPlayer(url, /*didStopFirst*/ false); } }); }); diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 43e36a0..603b563 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -25,7 +25,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" rust_cast = "0.19.0" mdns-sd = "0.17.1" -agnostic-mdns = { version = "0.4", features = ["tokio"] } +agnostic-mdns = { version = "0.4", features = ["tokio"], optional = true } async-channel = "2.5.0" tokio = { version = "1.48.0", features = ["full"] } tauri-plugin-shell = "2.3.3" @@ -36,3 +36,6 @@ ringbuf = "0.3" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } +[features] +use_agnostic_mdns = ["agnostic-mdns"] + diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0f3b273..78479bc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,10 +2,12 @@ use std::collections::HashMap; use std::io::{BufRead, BufReader}; use std::net::{IpAddr, SocketAddr, TcpListener, TcpStream, UdpSocket}; use std::process::{Child, Command, Stdio}; -use std::sync::{Mutex, Arc, RwLock}; -use std::thread; +use std::sync::{Mutex, Arc}; +// thread usage replaced by async tasks; remove direct std::thread import use std::time::Duration; +use tokio::sync::{RwLock as TokioRwLock, mpsc}; +#[cfg(not(feature = "use_agnostic_mdns"))] use mdns_sd::{ServiceDaemon, ServiceEvent}; use serde_json::json; use tauri::{AppHandle, Manager, State}; @@ -17,7 +19,7 @@ use tauri_plugin_shell::ShellExt; use reqwest; use base64::{engine::general_purpose, Engine as _}; -mod player; +pub mod player; use player::{PlayerCommand, PlayerController, PlayerShared, PlayerState}; struct SidecarState { @@ -25,7 +27,7 @@ struct SidecarState { } struct AppState { - known_devices: Arc>>, + known_devices: Arc>>, } struct DeviceInfo { @@ -87,15 +89,17 @@ fn local_ip_for_peer(peer_ip: IpAddr) -> Result { Ok(sock.local_addr().map_err(|e| e.to_string())?.ip()) } -fn wait_for_listen(ip: IpAddr, port: u16) { +fn wait_for_listen(ip: IpAddr, port: u16) -> bool { // Best-effort: give ffmpeg a moment to bind before we tell the Chromecast. + // Returns true if a listener accepted a connection during the wait window. let addr = SocketAddr::new(ip, port); for _ in 0..50 { if TcpStream::connect_timeout(&addr, Duration::from_millis(30)).is_ok() { - return; + return true; } std::thread::sleep(Duration::from_millis(20)); } + false } fn stop_cast_proxy_locked(lock: &mut Option) { @@ -172,7 +176,7 @@ async fn cast_proxy_start( player::preflight_ffmpeg_only()?; let device_ip_str = { - let devices = state.known_devices.read().unwrap(); + let devices = state.known_devices.read().await; devices .get(&device_name) .map(|d| d.ip.clone()) @@ -183,14 +187,6 @@ async fn cast_proxy_start( .map_err(|_| format!("Invalid device IP: {device_ip_str}"))?; let local_ip = local_ip_for_peer(device_ip)?; - // Pick an ephemeral port. - let listener = TcpListener::bind("0.0.0.0:0").map_err(|e| e.to_string())?; - let port = listener.local_addr().map_err(|e| e.to_string())?.port(); - drop(listener); - - let host = format_http_host(local_ip); - let proxy_url = format!("http://{host}:{port}/stream.mp3"); - // Stop any existing standalone proxy first. { let mut lock = proxy_state.inner.lock().unwrap(); @@ -210,63 +206,82 @@ async fn cast_proxy_start( .map_err(|e| e.to_string())?; } - let (reply_tx, reply_rx) = std::sync::mpsc::channel(); - let _ = player - .controller - .tx - .send(PlayerCommand::CastTapStart { - port, - reply: reply_tx, - }) - .map_err(|e| e.to_string())?; + // Try starting the TAP on several ephemeral ports before falling back. + let host = format_http_host(local_ip); + let max_attempts = 5usize; + for attempt in 0..max_attempts { + // Pick an ephemeral port. + let listener = TcpListener::bind("0.0.0.0:0").map_err(|e| e.to_string())?; + let port = listener.local_addr().map_err(|e| e.to_string())?.port(); + drop(listener); - match reply_rx.recv_timeout(Duration::from_secs(2)) { - Ok(Ok(())) => { - wait_for_listen(local_ip, port); - Ok(CastProxyStartResult { - url: proxy_url, - mode: "tap".to_string(), + let proxy_url = format!("http://{host}:{port}/stream.mp3"); + + let (reply_tx, reply_rx) = std::sync::mpsc::channel(); + let _ = player + .controller + .tx + .send(PlayerCommand::CastTapStart { + port, + bind_host: host.clone(), + reply: reply_tx, }) - } - Ok(Err(e)) => { - warn!("Cast tap start failed; falling back to standalone proxy: {e}"); - let mut child = spawn_standalone_cast_proxy(url, port)?; - if let Some(stderr) = child.stderr.take() { - std::thread::spawn(move || { - let reader = BufReader::new(stderr); - for line in reader.lines().flatten() { - warn!("[cast-proxy ffmpeg] {line}"); - } - }); + .map_err(|e| e.to_string())?; + + match reply_rx.recv_timeout(Duration::from_secs(2)) { + Ok(Ok(())) => { + if wait_for_listen(local_ip, port) { + info!("Cast proxy started in TAP mode: {}", proxy_url); + return Ok(CastProxyStartResult { + url: proxy_url, + mode: "tap".to_string(), + }); + } else { + warn!("Cast tap did not start listening on port {port}; attempt {}/{}", attempt+1, max_attempts); + let _ = player.controller.tx.send(PlayerCommand::CastTapStop); + std::thread::sleep(Duration::from_millis(100)); + continue; + } } - wait_for_listen(local_ip, port); - let mut lock = proxy_state.inner.lock().unwrap(); - *lock = Some(CastProxy { child }); - Ok(CastProxyStartResult { - url: proxy_url, - mode: "proxy".to_string(), - }) - } - Err(_) => { - warn!("Cast tap start timed out; falling back to standalone proxy"); - let mut child = spawn_standalone_cast_proxy(url, port)?; - if let Some(stderr) = child.stderr.take() { - std::thread::spawn(move || { - let reader = BufReader::new(stderr); - for line in reader.lines().flatten() { - warn!("[cast-proxy ffmpeg] {line}"); - } - }); + Ok(Err(e)) => { + warn!("Cast tap start failed on attempt {}/{}: {e}", attempt+1, max_attempts); + let _ = player.controller.tx.send(PlayerCommand::CastTapStop); + std::thread::sleep(Duration::from_millis(100)); + continue; + } + Err(_) => { + warn!("Cast tap start timed out on attempt {}/{}", attempt+1, max_attempts); + let _ = player.controller.tx.send(PlayerCommand::CastTapStop); + std::thread::sleep(Duration::from_millis(100)); + continue; } - wait_for_listen(local_ip, port); - let mut lock = proxy_state.inner.lock().unwrap(); - *lock = Some(CastProxy { child }); - Ok(CastProxyStartResult { - url: proxy_url, - mode: "proxy".to_string(), - }) } } + + // All TAP attempts failed; fall back to standalone proxy on a fresh ephemeral port. + warn!("All TAP attempts failed; falling back to standalone proxy"); + let listener = TcpListener::bind("0.0.0.0:0").map_err(|e| e.to_string())?; + let port = listener.local_addr().map_err(|e| e.to_string())?.port(); + drop(listener); + let proxy_url = format!("http://{host}:{port}/stream.mp3"); + let mut child = spawn_standalone_cast_proxy(url, port)?; + if let Some(stderr) = child.stderr.take() { + std::thread::spawn(move || { + let reader = BufReader::new(stderr); + for line in reader.lines().flatten() { + warn!("[cast-proxy ffmpeg] {line}"); + } + }); + } + // best-effort wait for standalone proxy + let _ = wait_for_listen(local_ip, port); + let mut lock = proxy_state.inner.lock().unwrap(); + *lock = Some(CastProxy { child }); + info!("Cast proxy started in STANDALONE mode (after TAP attempts): {}", proxy_url); + Ok(CastProxyStartResult { + url: proxy_url, + mode: "proxy".to_string(), + }) } #[tauri::command] @@ -346,7 +361,7 @@ async fn player_stop(player: State<'_, PlayerRuntime>) -> Result<(), String> { #[tauri::command] async fn list_cast_devices(state: State<'_, AppState>) -> Result, String> { - let devices = state.known_devices.read().unwrap(); + let devices = state.known_devices.read().await; let mut list: Vec = devices.keys().cloned().collect(); list.sort(); Ok(list) @@ -360,12 +375,18 @@ async fn cast_play( device_name: String, url: String, ) -> Result<(), String> { + // Resolve device name -> ip with diagnostics on failure let ip = { - let devices = state.known_devices.read().unwrap(); - devices - .get(&device_name) - .map(|d| d.ip.clone()) - .ok_or("Device not found")? + let devices = state.known_devices.read().await; + if let Some(d) = devices.get(&device_name) { + info!("cast_play: resolved device '{}' -> {}", device_name, d.ip); + d.ip.clone() + } else { + // Log known device keys for debugging + let keys: Vec = devices.keys().cloned().collect(); + warn!("cast_play: device '{}' not found; known: {:?}", device_name, keys); + return Err(format!("Device not found: {} (known: {:?})", device_name, keys)); + } }; let mut lock = sidecar_state.child.lock().unwrap(); @@ -375,11 +396,25 @@ async fn cast_play( child } else { info!("Spawning new sidecar..."); + // Use the packaged sidecar binary (radiocast-sidecar-.exe) let sidecar_command = app .shell() .sidecar("radiocast-sidecar") - .map_err(|e| e.to_string())?; - let (mut rx, child) = sidecar_command.spawn().map_err(|e| e.to_string())?; + .map_err(|e| { + error!("Sidecar command creation failed: {}", e); + e.to_string() + })?; + let spawn_result = sidecar_command.spawn(); + let (mut rx, child) = match spawn_result { + Ok(res) => { + info!("Sidecar spawned successfully"); + res + } + Err(e) => { + error!("Sidecar spawn failed: {}", e); + return Err(e.to_string()); + } + }; tauri::async_runtime::spawn(async move { while let Some(event) = rx.recv().await { @@ -403,10 +438,15 @@ async fn cast_play( "command": "play", "args": { "ip": ip, "url": url } }); - - child - .write(format!("{}\n", play_cmd.to_string()).as_bytes()) - .map_err(|e| e.to_string())?; + let play_payload = format!("{}\n", play_cmd.to_string()); + info!("Sending cast URL to device '{}': {}", device_name, url); + match child.write(play_payload.as_bytes()) { + Ok(()) => info!("Sidecar write OK"), + Err(e) => { + error!("Sidecar write failed: {}", e); + return Err(e.to_string()); + } + } Ok(()) } @@ -539,7 +579,7 @@ pub fn run() { }) .setup(|app| { app.manage(AppState { - known_devices: Arc::new(RwLock::new(HashMap::new())), + known_devices: Arc::new(TokioRwLock::new(HashMap::new())), }); app.manage(SidecarState { child: Mutex::new(None), @@ -560,69 +600,131 @@ pub fn run() { app.manage(PlayerRuntime { shared, controller }); let handle = app.handle().clone(); - let mdns_handle = handle.clone(); - thread::spawn(move || { - let mdns = ServiceDaemon::new().expect("Failed to create daemon"); - let receiver = mdns - .browse("_googlecast._tcp.local.") - .expect("Failed to browse"); - while let Ok(event) = receiver.recv() { - match event { - ServiceEvent::ServiceResolved(info) => { - let name = info - .get_property_val_str("fn") - .or_else(|| Some(info.get_fullname())) - .unwrap() - .to_string(); - let addresses = info.get_addresses(); - let ip = addresses - .iter() - .find(|ip| ip.is_ipv4()) - .or_else(|| addresses.iter().next()); - if let Some(ip) = ip { - let state = mdns_handle.state::(); - let mut devices = state.known_devices.write().unwrap(); - let ip_str = ip.to_string(); - let now = std::time::Instant::now(); - if !devices.contains_key(&name) { - // new device discovered - let info = DeviceInfo { ip: ip_str.clone(), last_seen: now }; - devices.insert(name.clone(), info); - let _ = mdns_handle.emit("cast-device-discovered", json!({"name": name, "ip": ip_str})); - } else { - // update last_seen and possibly IP - if let Some(d) = devices.get_mut(&name) { - d.last_seen = now; - d.ip = ip_str; - } - } - } - } - _ => {} + // Bridge blocking mdns-sd into async device handling via an unbounded channel. + let mdns_handle = handle.clone(); + let (mdns_tx, mut mdns_rx) = mpsc::unbounded_channel::<(String, String)>(); + + // Task: consume events from the channel and update `known_devices` asynchronously. + let consumer_handle = mdns_handle.clone(); + tauri::async_runtime::spawn(async move { + while let Some((name, ip_str)) = mdns_rx.recv().await { + let state = consumer_handle.state::(); + let mut devices = state.known_devices.write().await; + let now = std::time::Instant::now(); + if !devices.contains_key(&name) { + let info = DeviceInfo { ip: ip_str.clone(), last_seen: now }; + devices.insert(name.clone(), info); + let _ = consumer_handle.emit("cast-device-discovered", json!({"name": name, "ip": ip_str})); + } else if let Some(d) = devices.get_mut(&name) { + d.last_seen = now; + d.ip = ip_str; } } }); - // Spawn a GC thread to drop stale devices and notify frontend - let gc_handle = handle.clone(); - thread::spawn(move || { - let stale_after = Duration::from_secs(30); - loop { - std::thread::sleep(Duration::from_secs(10)); - let state = gc_handle.state::(); - let mut devices = state.known_devices.write().unwrap(); - let now = std::time::Instant::now(); - let mut removed: Vec = Vec::new(); - devices.retain(|name, info| { - if now.duration_since(info.last_seen) > stale_after { - removed.push(name.clone()); - false - } else { - true - } + // Probe implementation: + // - If the feature `use_agnostic_mdns` is enabled, use the async `agnostic-mdns` API. + // - Otherwise keep the existing blocking `mdns-sd` browse running in a blocking task. + let probe_tx = mdns_tx.clone(); + + #[cfg(feature = "use_agnostic_mdns")] + { + // Use agnostic-mdns async API (tokio) to query for Google Cast services + tauri::async_runtime::spawn(async move { + // Create the async channel expected by agnostic-mdns query + let (tx, rx) = agnostic_mdns::tokio::channel::unbounded::(); + + // Build query params for _googlecast._tcp in the local domain. + let params = agnostic_mdns::QueryParam::new("_googlecast._tcp".into()) + .with_domain("local.".into()); + + // Spawn the query task which will send ServiceEntry values into `tx`. + let _ = tokio::spawn(async move { + let _ = agnostic_mdns::tokio::query(params, tx).await; }); - drop(devices); + + // Consume ServiceEntry results and forward (name, ip) into the probe channel. + let rx = rx; + while let Ok(entry) = rx.recv().await { + // Try TXT records for friendly name: entries like "fn=Living Room". + let mut friendly: Option = None; + for s in entry.txt() { + let s_str = s.to_string(); + if let Some(rest) = s_str.strip_prefix("fn=") { + friendly = Some(rest.to_string()); + break; + } + } + + // Fallback: use debug-formatted entry name if TXT 'fn' not present. + // This avoids depending on the concrete return type of `name()`. + let name = friendly.unwrap_or_else(|| format!("{:?}", entry.name())); + + // Prefer IPv4, then IPv6. + let ip_opt = entry + .ipv4_addr() + .map(|a| a.to_string()) + .or_else(|| entry.ipv6_addr().map(|a| a.to_string())); + + if let Some(ip_str) = ip_opt { + let _ = probe_tx.send((name, ip_str)); + } + } + }); + } + + #[cfg(not(feature = "use_agnostic_mdns"))] + { + // Offload blocking mdns-sd browse loop to a blocking thread and forward events over the channel. + tauri::async_runtime::spawn(async move { + let _ = tokio::task::spawn_blocking(move || { + let mdns = ServiceDaemon::new().expect("Failed to create daemon"); + let receiver = mdns + .browse("_googlecast._tcp.local.") + .expect("Failed to browse"); + while let Ok(event) = receiver.recv() { + if let ServiceEvent::ServiceResolved(info) = event { + let name = info + .get_property_val_str("fn") + .or_else(|| Some(info.get_fullname())) + .unwrap() + .to_string(); + let addresses = info.get_addresses(); + let ip = addresses + .iter() + .find(|ip| ip.is_ipv4()) + .or_else(|| addresses.iter().next()); + if let Some(ip) = ip { + let ip_str = ip.to_string(); + // Best-effort send into the async channel; ignore if receiver dropped. + let _ = probe_tx.send((name, ip_str)); + } + } + } + }).await; + }); + } + + // Spawn an async GC task to drop stale devices and notify frontend + let gc_handle = handle.clone(); + tauri::async_runtime::spawn(async move { + let stale_after = Duration::from_secs(30); + let mut interval = tokio::time::interval(Duration::from_secs(10)); + loop { + interval.tick().await; + let state = gc_handle.state::(); + let mut devices = state.known_devices.write().await; + let now = std::time::Instant::now(); + let mut removed: Vec = Vec::new(); + devices.retain(|name, info| { + if now.duration_since(info.last_seen) > stale_after { + removed.push(name.clone()); + false + } else { + true + } + }); for name in removed { let _ = gc_handle.emit("cast-device-removed", json!({"name": name})); } diff --git a/src-tauri/src/player.rs b/src-tauri/src/player.rs index ab1a511..d69bf89 100644 --- a/src-tauri/src/player.rs +++ b/src-tauri/src/player.rs @@ -75,6 +75,7 @@ pub enum PlayerCommand { SetVolume { volume: f32 }, CastTapStart { port: u16, + bind_host: String, reply: mpsc::Sender>, }, CastTapStop, @@ -206,6 +207,8 @@ enum PipelineMode { struct CastTapProc { child: std::process::Child, writer_join: Option>, + server_join: Option>, + stop_flag: Arc, } struct Pipeline { @@ -561,16 +564,18 @@ impl Pipeline { }) } - fn start_cast_tap(&mut self, port: u16, sample_rate: u32, channels: u16) -> Result<(), String> { + fn start_cast_tap(&mut self, port: u16, bind_host: &str, sample_rate: u32, channels: u16) -> Result<(), String> { // Stop existing tap first. self.stop_cast_tap(); let ffmpeg = ffmpeg_command(); let ffmpeg_disp = ffmpeg.to_string_lossy(); + let bind_host = bind_host.to_owned(); let spawn = |codec: &str| -> Result { command_hidden(&ffmpeg) .arg("-nostdin") + .arg("-re") .arg("-hide_banner") .arg("-loglevel") .arg("warning") @@ -589,13 +594,9 @@ impl Pipeline { .arg("128k") .arg("-f") .arg("mp3") - .arg("-content_type") - .arg("audio/mpeg") - .arg("-listen") - .arg("1") - .arg(format!("http://0.0.0.0:{port}/stream.mp3")) + .arg("-") .stdin(Stdio::piped()) - .stdout(Stdio::null()) + .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .map_err(|e| { @@ -619,6 +620,22 @@ impl Pipeline { .take() .ok_or_else(|| "ffmpeg cast tap stdin not available".to_string())?; + let stdout = child + .stdout + .take() + .ok_or_else(|| "ffmpeg cast tap stdout not available".to_string())?; + + // Log stderr for debugging tap failures + if let Some(stderr) = child.stderr.take() { + std::thread::spawn(move || { + use std::io::BufRead; + let reader = std::io::BufReader::new(stderr); + for line in reader.lines().flatten() { + eprintln!("[cast-tap ffmpeg] {}", line); + } + }); + } + let (tx, rx) = mpsc::sync_channel::>(256); *self.cast_tx.lock().unwrap() = Some(tx); @@ -636,9 +653,127 @@ impl Pipeline { let _ = stdin.flush(); }); + // Spawn simple HTTP server to serve ffmpeg stdout + let server_stop = Arc::new(AtomicBool::new(false)); + let server_stop_clone = Arc::clone(&server_stop); + + // Use Arc>> for broadcasting to multiple clients + let clients: Arc>>>> = Arc::new(Mutex::new(Vec::new())); + let clients_reader = Arc::clone(&clients); + + // Reader thread: reads from ffmpeg stdout and broadcasts to all subscribers + let reader_stop = Arc::clone(&server_stop); + std::thread::spawn(move || { + use std::io::Read; + let mut ffmpeg_out = stdout; + let mut buffer = vec![0u8; 8192]; + + loop { + if reader_stop.load(Ordering::SeqCst) { + break; + } + + match ffmpeg_out.read(&mut buffer) { + Ok(0) => break, + Ok(n) => { + let chunk = buffer[..n].to_vec(); + let mut clients_lock = clients_reader.lock().unwrap(); + clients_lock.retain(|tx| tx.try_send(chunk.clone()).is_ok()); + } + Err(_) => break, + } + } + }); + + let clients_server = Arc::clone(&clients); + let server_join = std::thread::spawn(move || { + use std::io::{BufRead, BufReader, Write}; + use std::net::TcpListener; + + let listener = match TcpListener::bind(format!("{bind_host}:{port}")) { + Ok(l) => l, + Err(e) => { + eprintln!("[cast-tap server] Failed to bind: {e}"); + return; + } + }; + if let Err(e) = listener.set_nonblocking(true) { + eprintln!("[cast-tap server] Failed to set nonblocking: {e}"); + return; + } + + loop { + if server_stop_clone.load(Ordering::SeqCst) { + break; + } + + // Accept client connections + let stream = match listener.accept() { + Ok((s, addr)) => { + eprintln!("[cast-tap server] Client connected: {addr}"); + s + }, + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(50)); + continue; + } + Err(e) => { + eprintln!("[cast-tap server] Accept error: {e}"); + break; + } + }; + + // Spawn handler for each client + let stop_flag = Arc::clone(&server_stop_clone); + let (client_tx, client_rx) = mpsc::sync_channel::>(256); + + // Subscribe this client + clients_server.lock().unwrap().push(client_tx); + + std::thread::spawn(move || { + // Read and discard HTTP request headers + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + loop { + line.clear(); + if reader.read_line(&mut line).is_err() || line == "\r\n" || line == "\n" { + break; + } + } + + // Send HTTP response headers + let mut writer = stream; + let headers = b"HTTP/1.1 200 OK\r\nContent-Type: audio/mpeg\r\nConnection: close\r\nCache-Control: no-cache\r\n\r\n"; + if writer.write_all(headers).is_err() { + return; + } + + // Stream chunks to client + loop { + if stop_flag.load(Ordering::SeqCst) { + break; + } + + match client_rx.recv_timeout(Duration::from_millis(100)) { + Ok(chunk) => { + if writer.write_all(&chunk).is_err() { + break; + } + } + Err(mpsc::RecvTimeoutError::Timeout) => continue, + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } + eprintln!("[cast-tap server] Client disconnected"); + }); + } + }); + self.cast_proc = Some(CastTapProc { child, writer_join: Some(writer_join), + server_join: Some(server_join), + stop_flag: server_stop, }); Ok(()) @@ -647,11 +782,15 @@ impl Pipeline { fn stop_cast_tap(&mut self) { *self.cast_tx.lock().unwrap() = None; if let Some(mut proc) = self.cast_proc.take() { + proc.stop_flag.store(true, Ordering::SeqCst); let _ = proc.child.kill(); let _ = proc.child.wait(); if let Some(j) = proc.writer_join.take() { let _ = j.join(); } + if let Some(j) = proc.server_join.take() { + let _ = j.join(); + } } } @@ -749,10 +888,10 @@ fn player_thread(shared: std::sync::Arc, rx: mpsc::Receiver { + PlayerCommand::CastTapStart { port, bind_host, reply } => { if let Some(p) = pipeline.as_mut() { // Current pipeline sample format is always s16le. - let res = p.start_cast_tap(port, p.sample_rate, p.channels); + let res = p.start_cast_tap(port, &bind_host, p.sample_rate, p.channels); let _ = reply.send(res); } else { let _ = reply.send(Err("No active decoder pipeline".to_string())); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9a0c230..31aec04 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -26,7 +26,7 @@ "active": true, "targets": "all", "externalBin": [ - "binaries/RadioPlayer" + "binaries/radiocast-sidecar" ], "resources": [ "resources/*" diff --git a/src/main.js b/src/main.js index 5e99e9b..a12e6e7 100644 --- a/src/main.js +++ b/src/main.js @@ -1059,6 +1059,13 @@ function setupEventListeners() { refreshCastDeviceList(); } }); + // Notify UI when a device is removed so the list can update + window.__TAURI__.event.listen('cast-device-removed', (event) => { + console.log('Cast device removed:', event.payload); + if (!castOverlay.classList.contains('hidden')) { + refreshCastDeviceList(); + } + }); } // Menu button - explicit functionality or placeholder? diff --git a/tools/copy-binaries.js b/tools/copy-binaries.js index dcdd4f5..779028b 100644 --- a/tools/copy-binaries.js +++ b/tools/copy-binaries.js @@ -1,18 +1,37 @@ #!/usr/bin/env node import fs from 'fs'; import path from 'path'; +import { execSync } from 'child_process'; const repoRoot = process.cwd(); const binariesDir = path.join(repoRoot, 'src-tauri', 'binaries'); -// Existing filename and expected name (Windows x86_64 triple) +// No rename needed; ensure the sidecar exists. const existing = 'radiocast-sidecar-x86_64-pc-windows-msvc.exe'; -const expected = 'RadioPlayer-x86_64-pc-windows-msvc.exe'; +const expected = existing; const src = path.join(binariesDir, existing); const dst = path.join(binariesDir, expected); +// On Windows the running sidecar process can lock the binary and prevent rebuilds. +// Try to kill any leftover sidecar processes before proceeding. This is best-effort +// and will silently continue if no process is found or the kill fails. +function tryKillSidecar() { + if (process.platform !== 'win32') return; + const candidates = ['radiocast-sidecar.exe', 'radiocast-sidecar-x86_64-pc-windows-msvc.exe', 'radiocast-sidecar']; + for (const name of candidates) { + try { + execSync(`taskkill /IM ${name} /F`, { stdio: 'ignore' }); + console.log(`Killed leftover sidecar process: ${name}`); + } catch (e) { + // ignore errors; likely means the process wasn't running + } + } +} + try { + tryKillSidecar(); + if (!fs.existsSync(binariesDir)) { console.warn('binaries directory not found, skipping copy'); process.exit(0); @@ -23,14 +42,8 @@ try { process.exit(0); } - if (fs.existsSync(dst)) { - console.log(`Expected binary already present: ${dst}`); - process.exit(0); - } - - fs.copyFileSync(src, dst); - console.log(`Copied ${existing} -> ${expected}`); + console.log(`Sidecar binary present: ${dst}`); } catch (e) { - console.error('Failed to copy binary:', e); + console.error('Failed to prepare binary:', e); process.exit(1); }