chore: refactor async device discovery, tracing, and player Arc state

This commit is contained in:
2026-01-13 17:15:59 +01:00
parent ab3a86041a
commit efdba35b77
4 changed files with 181 additions and 58 deletions

View File

@@ -2,13 +2,16 @@ 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::sync::{Mutex, Arc, RwLock};
use std::thread;
use std::time::Duration;
use mdns_sd::{ServiceDaemon, ServiceEvent};
use serde_json::json;
use tauri::{AppHandle, Manager, State};
use tauri::Emitter;
use tracing::{info, warn, error};
use tracing_subscriber;
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_shell::ShellExt;
use reqwest;
@@ -22,7 +25,12 @@ struct SidecarState {
}
struct AppState {
known_devices: Mutex<HashMap<String, String>>,
known_devices: Arc<RwLock<HashMap<String, DeviceInfo>>>,
}
struct DeviceInfo {
ip: String,
last_seen: std::time::Instant,
}
struct CastProxy {
@@ -43,7 +51,7 @@ struct CastProxyStartResult {
// Native (non-WebView) audio player state.
// Step 1: state machine + command interface only (no decoding/output yet).
struct PlayerRuntime {
shared: &'static PlayerShared,
shared: Arc<PlayerShared>,
controller: PlayerController,
}
@@ -94,7 +102,7 @@ fn stop_cast_proxy_locked(lock: &mut Option<CastProxy>) {
if let Some(mut proxy) = lock.take() {
let _ = proxy.child.kill();
let _ = proxy.child.wait();
println!("Cast proxy stopped");
info!("Cast proxy stopped");
}
}
@@ -144,7 +152,7 @@ fn spawn_standalone_cast_proxy(url: String, port: u16) -> Result<Child, String>
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");
warn!("Standalone cast proxy exited early; retrying with -c:a mp3");
child = spawn("mp3")?;
}
}
@@ -164,10 +172,10 @@ async fn cast_proxy_start(
player::preflight_ffmpeg_only()?;
let device_ip_str = {
let devices = state.known_devices.lock().unwrap();
let devices = state.known_devices.read().unwrap();
devices
.get(&device_name)
.cloned()
.map(|d| d.ip.clone())
.ok_or("Device not found")?
};
let device_ip: IpAddr = device_ip_str
@@ -221,13 +229,13 @@ async fn cast_proxy_start(
})
}
Ok(Err(e)) => {
eprintln!("Cast tap start failed; falling back to standalone proxy: {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() {
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}");
warn!("[cast-proxy ffmpeg] {line}");
}
});
}
@@ -240,13 +248,13 @@ async fn cast_proxy_start(
})
}
Err(_) => {
eprintln!("Cast tap start timed out; falling back to standalone proxy");
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() {
eprintln!("[cast-proxy ffmpeg] {line}");
warn!("[cast-proxy ffmpeg] {line}");
}
});
}
@@ -338,7 +346,7 @@ async fn player_stop(player: State<'_, PlayerRuntime>) -> Result<(), String> {
#[tauri::command]
async fn list_cast_devices(state: State<'_, AppState>) -> Result<Vec<String>, String> {
let devices = state.known_devices.lock().unwrap();
let devices = state.known_devices.read().unwrap();
let mut list: Vec<String> = devices.keys().cloned().collect();
list.sort();
Ok(list)
@@ -353,10 +361,10 @@ async fn cast_play(
url: String,
) -> Result<(), String> {
let ip = {
let devices = state.known_devices.lock().unwrap();
let devices = state.known_devices.read().unwrap();
devices
.get(&device_name)
.cloned()
.map(|d| d.ip.clone())
.ok_or("Device not found")?
};
@@ -366,7 +374,7 @@ async fn cast_play(
let child = if let Some(ref mut child) = *lock {
child
} else {
println!("Spawning new sidecar...");
info!("Spawning new sidecar...");
let sidecar_command = app
.shell()
.sidecar("radiocast-sidecar")
@@ -377,10 +385,10 @@ async fn cast_play(
while let Some(event) = rx.recv().await {
match event {
CommandEvent::Stdout(line) => {
println!("Sidecar: {}", String::from_utf8_lossy(&line))
info!("Sidecar: {}", String::from_utf8_lossy(&line))
}
CommandEvent::Stderr(line) => {
eprintln!("Sidecar Error: {}", String::from_utf8_lossy(&line))
error!("Sidecar Error: {}", String::from_utf8_lossy(&line))
}
_ => {}
}
@@ -531,7 +539,7 @@ pub fn run() {
})
.setup(|app| {
app.manage(AppState {
known_devices: Mutex::new(HashMap::new()),
known_devices: Arc::new(RwLock::new(HashMap::new())),
});
app.manage(SidecarState {
child: Mutex::new(None),
@@ -540,16 +548,19 @@ pub fn run() {
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 {
// Initialize tracing subscriber for structured logging. Honor RUST_LOG if set.
tracing_subscriber::fmt::init();
// Player scaffolding: create shared state behind an Arc and spawn the
// player thread with a cloned Arc (avoids leaking memory).
let shared = Arc::new(PlayerShared {
state: Mutex::new(PlayerState::default()),
}));
let controller = player::spawn_player_thread(shared);
});
let controller = player::spawn_player_thread(Arc::clone(&shared));
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
@@ -570,12 +581,21 @@ pub fn run() {
.or_else(|| addresses.iter().next());
if let Some(ip) = ip {
let state = handle.state::<AppState>();
let mut devices = state.known_devices.lock().unwrap();
let state = mdns_handle.state::<AppState>();
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) {
//println!("Discovered Cast Device: {} at {}", name, ip_str);
devices.insert(name, ip_str);
// 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;
}
}
}
}
@@ -583,6 +603,31 @@ pub fn run() {
}
}
});
// 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::<AppState>();
let mut devices = state.known_devices.write().unwrap();
let now = std::time::Instant::now();
let mut removed: Vec<String> = Vec::new();
devices.retain(|name, info| {
if now.duration_since(info.last_seen) > stale_after {
removed.push(name.clone());
false
} else {
true
}
});
drop(devices);
for name in removed {
let _ = gc_handle.emit("cast-device-removed", json!({"name": name}));
}
}
});
Ok(())
})
.invoke_handler(tauri::generate_handler![