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; use std::thread; use std::time::Duration; #[cfg(windows)] use std::os::windows::process::CommandExt; #[cfg(windows)] const CREATE_NO_WINDOW: u32 = 0x08000000; use mdns_sd::{ServiceDaemon, ServiceEvent}; use serde_json::json; use tauri::{AppHandle, Manager, State}; use tauri_plugin_shell::process::{CommandChild, CommandEvent}; use tauri_plugin_shell::ShellExt; use reqwest; use base64::{engine::general_purpose, Engine as _}; mod player; use player::{PlayerCommand, PlayerController, PlayerShared, PlayerState}; struct SidecarState { child: Mutex>, } struct AppState { known_devices: Mutex>, } struct CastProxy { child: Child, } struct CastProxyState { inner: Mutex>, } #[derive(serde::Serialize)] struct CastProxyStartResult { url: String, // "tap" | "proxy" mode: String, } // Native (non-WebView) audio player state. // Step 1: state machine + command interface only (no decoding/output yet). struct PlayerRuntime { shared: &'static PlayerShared, controller: PlayerController, } fn clamp01(v: f32) -> f32 { if v.is_nan() { 0.0 } else if v < 0.0 { 0.0 } else if v > 1.0 { 1.0 } else { v } } fn format_http_host(ip: IpAddr) -> String { match ip { IpAddr::V4(v4) => v4.to_string(), IpAddr::V6(v6) => format!("[{v6}]"), } } fn local_ip_for_peer(peer_ip: IpAddr) -> Result { // Trick: connect a UDP socket to the peer and read the chosen local address. // Port number is irrelevant; no packets are sent for UDP connect(). let peer = SocketAddr::new(peer_ip, 9); let bind_addr = match peer_ip { IpAddr::V4(_) => "0.0.0.0:0", IpAddr::V6(_) => "[::]:0", }; let sock = UdpSocket::bind(bind_addr).map_err(|e| e.to_string())?; sock.connect(peer).map_err(|e| e.to_string())?; Ok(sock.local_addr().map_err(|e| e.to_string())?.ip()) } fn wait_for_listen(ip: IpAddr, port: u16) { // Best-effort: give ffmpeg a moment to bind before we tell the Chromecast. let addr = SocketAddr::new(ip, port); for _ in 0..50 { if TcpStream::connect_timeout(&addr, Duration::from_millis(30)).is_ok() { return; } std::thread::sleep(Duration::from_millis(20)); } } fn stop_cast_proxy_locked(lock: &mut Option) { if let Some(mut proxy) = lock.take() { let _ = proxy.child.kill(); let _ = proxy.child.wait(); println!("Cast proxy stopped"); } } fn spawn_standalone_cast_proxy(url: String, port: u16) -> Result { // Standalone path (fallback): FFmpeg pulls the station URL and serves MP3 over HTTP. // Try libmp3lame first, then fall back to the built-in "mp3" encoder if needed. let ffmpeg = player::ffmpeg_command(); let ffmpeg_disp = ffmpeg.to_string_lossy(); let spawn = |codec: &str| -> Result { let mut cmd = Command::new(&ffmpeg); #[cfg(windows)] { cmd.creation_flags(CREATE_NO_WINDOW); } cmd .arg("-nostdin") .arg("-hide_banner") .arg("-loglevel") .arg("warning") .arg("-reconnect") .arg("1") .arg("-reconnect_streamed") .arg("1") .arg("-reconnect_delay_max") .arg("5") .arg("-i") .arg(&url) .arg("-vn") .arg("-c:a") .arg(codec) .arg("-b:a") .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")) .stdout(Stdio::null()) .stderr(Stdio::piped()) .spawn() .map_err(|e| { format!( "Failed to start ffmpeg cast proxy ({ffmpeg_disp}): {e}. Set RADIOPLAYER_FFMPEG, bundle ffmpeg next to the app, or install ffmpeg on PATH." ) }) }; let mut child = spawn("libmp3lame")?; std::thread::sleep(Duration::from_millis(150)); if let Ok(Some(status)) = child.try_wait() { if !status.success() { eprintln!("Standalone cast proxy exited early; retrying with -c:a mp3"); child = spawn("mp3")?; } } Ok(child) } #[tauri::command] async fn cast_proxy_start( state: State<'_, AppState>, proxy_state: State<'_, CastProxyState>, player: State<'_, PlayerRuntime>, device_name: String, url: String, ) -> Result { // Make sure ffmpeg exists before we try to cast. player::preflight_ffmpeg_only()?; let device_ip_str = { let devices = state.known_devices.lock().unwrap(); devices .get(&device_name) .cloned() .ok_or("Device not found")? }; let device_ip: IpAddr = device_ip_str .parse() .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(); stop_cast_proxy_locked(&mut lock); } // Prefer reusing the native decoder PCM when possible. // If the currently playing URL differs (or nothing is playing), start a headless decoder. let snapshot = player.shared.snapshot(); let is_same_url = snapshot.url.as_deref() == Some(url.as_str()); let is_decoding = matches!(snapshot.status, player::PlayerStatus::Playing | player::PlayerStatus::Buffering); if !(is_same_url && is_decoding) { player .controller .tx .send(PlayerCommand::PlayCast { url: url.clone() }) .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())?; 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(), }) } Ok(Err(e)) => { eprintln!("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() { eprintln!("[cast-proxy ffmpeg] {line}"); } }); } 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(_) => { eprintln!("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() { eprintln!("[cast-proxy ffmpeg] {line}"); } }); } 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(), }) } } } #[tauri::command] async fn cast_proxy_stop(proxy_state: State<'_, CastProxyState>, player: State<'_, PlayerRuntime>) -> Result<(), String> { let _ = player.controller.tx.send(PlayerCommand::CastTapStop); let mut lock = proxy_state.inner.lock().unwrap(); stop_cast_proxy_locked(&mut lock); Ok(()) } #[tauri::command] async fn player_get_state(player: State<'_, PlayerRuntime>) -> Result { Ok(player.shared.snapshot()) } #[tauri::command] async fn player_set_volume( player: State<'_, PlayerRuntime>, volume: f32, ) -> Result<(), String> { let volume = clamp01(volume); { let mut s = player.shared.state.lock().unwrap(); s.volume = volume; } player .controller .tx .send(PlayerCommand::SetVolume { volume }) .map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] async fn player_play(player: State<'_, PlayerRuntime>, url: String) -> Result<(), String> { // Fail fast if audio output or ffmpeg is not available. // This keeps UX predictable: JS can show an error without flipping to "playing". if let Err(e) = player::preflight_check() { { let mut s = player.shared.state.lock().unwrap(); s.status = player::PlayerStatus::Error; s.error = Some(e.clone()); } return Err(e); } { let mut s = player.shared.state.lock().unwrap(); s.error = None; s.url = Some(url.clone()); // Step 1: report buffering immediately; the engine thread will progress. s.status = player::PlayerStatus::Buffering; } player .controller .tx .send(PlayerCommand::Play { url }) .map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] async fn player_stop(player: State<'_, PlayerRuntime>) -> Result<(), String> { { let mut s = player.shared.state.lock().unwrap(); s.error = None; s.status = player::PlayerStatus::Stopped; } player .controller .tx .send(PlayerCommand::Stop) .map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] async fn list_cast_devices(state: State<'_, AppState>) -> Result, String> { let devices = state.known_devices.lock().unwrap(); let mut list: Vec = devices.keys().cloned().collect(); list.sort(); Ok(list) } #[tauri::command] async fn cast_play( app: AppHandle, state: State<'_, AppState>, sidecar_state: State<'_, SidecarState>, device_name: String, url: String, ) -> Result<(), String> { let ip = { let devices = state.known_devices.lock().unwrap(); devices .get(&device_name) .cloned() .ok_or("Device not found")? }; let mut lock = sidecar_state.child.lock().unwrap(); // Get or spawn child let child = if let Some(ref mut child) = *lock { child } else { println!("Spawning new sidecar..."); 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())?; tauri::async_runtime::spawn(async move { while let Some(event) = rx.recv().await { match event { CommandEvent::Stdout(line) => { println!("Sidecar: {}", String::from_utf8_lossy(&line)) } CommandEvent::Stderr(line) => { eprintln!("Sidecar Error: {}", String::from_utf8_lossy(&line)) } _ => {} } } }); *lock = Some(child); lock.as_mut().unwrap() }; let play_cmd = json!({ "command": "play", "args": { "ip": ip, "url": url } }); child .write(format!("{}\n", play_cmd.to_string()).as_bytes()) .map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] async fn cast_stop( _app: AppHandle, sidecar_state: State<'_, SidecarState>, proxy_state: State<'_, CastProxyState>, player: State<'_, PlayerRuntime>, _device_name: String, ) -> Result<(), String> { { let mut lock = proxy_state.inner.lock().unwrap(); stop_cast_proxy_locked(&mut lock); } // Safety net: stop any active tap too. let _ = player.controller.tx.send(PlayerCommand::CastTapStop); let mut lock = sidecar_state.child.lock().unwrap(); if let Some(ref mut child) = *lock { let stop_cmd = json!({ "command": "stop", "args": {} }); child .write(format!("{}\n", stop_cmd.to_string()).as_bytes()) .map_err(|e| e.to_string())?; } Ok(()) } #[tauri::command] async fn cast_set_volume( _app: AppHandle, sidecar_state: State<'_, SidecarState>, _device_name: String, volume: f32, ) -> Result<(), String> { let mut lock = sidecar_state.child.lock().unwrap(); if let Some(ref mut child) = *lock { let vol_cmd = json!({ "command": "volume", "args": { "level": volume } }); child .write(format!("{}\n", vol_cmd.to_string()).as_bytes()) .map_err(|e| e.to_string())?; } Ok(()) } #[tauri::command] async fn fetch_url(_app: AppHandle, url: String) -> Result { // Simple GET with default client, return body text. Errors are stringified for frontend. match reqwest::Client::new().get(&url).send().await { Ok(resp) => { let status = resp.status(); if !status.is_success() { return Err(format!("HTTP {} while fetching {}", status, url)); } match resp.text().await { Ok(t) => Ok(t), Err(e) => Err(e.to_string()), } } Err(e) => Err(e.to_string()), } } #[tauri::command] async fn fetch_image_data_url(url: String) -> Result { // Fetch remote images via backend and return a data: URL. // This helps when WebView blocks http images (mixed-content) or some hosts block hotlinking. let parsed = reqwest::Url::parse(&url).map_err(|e| e.to_string())?; match parsed.scheme() { "http" | "https" => {} _ => return Err("Only http/https URLs are allowed".to_string()), } let resp = reqwest::Client::new() .get(parsed) .header(reqwest::header::USER_AGENT, "RadioPlayer/1.0") .send() .await .map_err(|e| e.to_string())?; let status = resp.status(); if !status.is_success() { return Err(format!("HTTP {} while fetching image", status)); } let content_type = resp .headers() .get(reqwest::header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .map(|s| s.split(';').next().unwrap_or(s).trim().to_string()) .unwrap_or_else(|| "application/octet-stream".to_string()); let bytes = resp.bytes().await.map_err(|e| e.to_string())?; const MAX_BYTES: usize = 2 * 1024 * 1024; if bytes.len() > MAX_BYTES { return Err("Image too large".to_string()); } // Be conservative: prefer image/* content types, but allow svg even if mislabelled. let looks_like_image = content_type.starts_with("image/") || content_type == "application/svg+xml" || url.to_lowercase().ends_with(".svg"); if !looks_like_image { return Err(format!("Not an image content-type: {}", content_type)); } let b64 = general_purpose::STANDARD.encode(bytes); Ok(format!("data:{};base64,{}", content_type, b64)) } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_opener::init()) .on_window_event(|window, event| { // Ensure native audio shuts down on app close. // We do not prevent the close; this is best-effort cleanup. if matches!(event, tauri::WindowEvent::CloseRequested { .. }) { let player = window.app_handle().state::(); let _ = player.controller.tx.send(PlayerCommand::Shutdown); // Also stop any active cast tap/proxy so we don't leave processes behind. let _ = player.controller.tx.send(PlayerCommand::CastTapStop); let proxy_state = window.app_handle().state::(); let mut lock = proxy_state.inner.lock().unwrap(); stop_cast_proxy_locked(&mut lock); } }) .setup(|app| { app.manage(AppState { known_devices: Mutex::new(HashMap::new()), }); app.manage(SidecarState { child: Mutex::new(None), }); app.manage(CastProxyState { inner: Mutex::new(None), }); // Player scaffolding: leak shared state to get a 'static reference for the // long-running thread without complex lifetime plumbing. // Later refactors can move this to Arc<...> when the engine grows. let shared: &'static PlayerShared = Box::leak(Box::new(PlayerShared { state: Mutex::new(PlayerState::default()), })); let controller = player::spawn_player_thread(shared); app.manage(PlayerRuntime { shared, controller }); let handle = app.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 = handle.state::(); let mut devices = state.known_devices.lock().unwrap(); let ip_str = ip.to_string(); if !devices.contains_key(&name) { println!("Discovered Cast Device: {} at {}", name, ip_str); devices.insert(name, ip_str); } } } _ => {} } } }); Ok(()) }) .invoke_handler(tauri::generate_handler![ list_cast_devices, cast_play, cast_stop, cast_set_volume, cast_proxy_start, cast_proxy_stop, // allow frontend to request arbitrary URLs via backend (bypass CORS) fetch_url, // fetch remote images via backend (data: URL), helps with mixed-content fetch_image_data_url, // native player commands (step 1 scaffold) player_play, player_stop, player_set_volume, player_get_state ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); }