766 lines
28 KiB
Rust
766 lines
28 KiB
Rust
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};
|
|
// 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};
|
|
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;
|
|
use base64::{engine::general_purpose, Engine as _};
|
|
|
|
pub mod player;
|
|
use player::{PlayerCommand, PlayerController, PlayerShared, PlayerState};
|
|
|
|
struct SidecarState {
|
|
child: Mutex<Option<CommandChild>>,
|
|
}
|
|
|
|
struct AppState {
|
|
known_devices: Arc<TokioRwLock<HashMap<String, DeviceInfo>>>,
|
|
}
|
|
|
|
struct DeviceInfo {
|
|
ip: String,
|
|
last_seen: std::time::Instant,
|
|
}
|
|
|
|
struct CastProxy {
|
|
child: Child,
|
|
}
|
|
|
|
struct CastProxyState {
|
|
inner: Mutex<Option<CastProxy>>,
|
|
}
|
|
|
|
#[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: Arc<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<IpAddr, String> {
|
|
// 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) -> 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 true;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(20));
|
|
}
|
|
false
|
|
}
|
|
|
|
fn stop_cast_proxy_locked(lock: &mut Option<CastProxy>) {
|
|
if let Some(mut proxy) = lock.take() {
|
|
let _ = proxy.child.kill();
|
|
let _ = proxy.child.wait();
|
|
info!("Cast proxy stopped");
|
|
}
|
|
}
|
|
|
|
fn spawn_standalone_cast_proxy(url: String, port: u16) -> Result<Child, String> {
|
|
// 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<Child, String> {
|
|
Command::new(&ffmpeg)
|
|
.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() {
|
|
warn!("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<CastProxyStartResult, String> {
|
|
// Make sure ffmpeg exists before we try to cast.
|
|
player::preflight_ffmpeg_only()?;
|
|
|
|
let device_ip_str = {
|
|
let devices = state.known_devices.read().await;
|
|
devices
|
|
.get(&device_name)
|
|
.map(|d| d.ip.clone())
|
|
.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)?;
|
|
|
|
// 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())?;
|
|
}
|
|
|
|
// 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);
|
|
|
|
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,
|
|
})
|
|
.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;
|
|
}
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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]
|
|
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<PlayerState, String> {
|
|
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<Vec<String>, String> {
|
|
let devices = state.known_devices.read().await;
|
|
let mut list: Vec<String> = 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,
|
|
title: Option<String>,
|
|
artist: Option<String>,
|
|
image: Option<String>,
|
|
) -> Result<(), String> {
|
|
// Resolve device name -> ip with diagnostics on failure
|
|
let ip = {
|
|
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<String> = 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();
|
|
|
|
// Get or spawn child
|
|
let child = if let Some(ref mut child) = *lock {
|
|
child
|
|
} else {
|
|
info!("Spawning new sidecar...");
|
|
// Use the packaged sidecar binary (radiocast-sidecar-<target>.exe)
|
|
let sidecar_command = app
|
|
.shell()
|
|
.sidecar("radiocast-sidecar")
|
|
.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 {
|
|
match event {
|
|
CommandEvent::Stdout(line) => {
|
|
info!("Sidecar: {}", String::from_utf8_lossy(&line))
|
|
}
|
|
CommandEvent::Stderr(line) => {
|
|
error!("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,
|
|
"metadata": {
|
|
"title": title,
|
|
"artist": artist,
|
|
"image": image
|
|
}
|
|
}
|
|
});
|
|
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(())
|
|
}
|
|
|
|
#[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<String, String> {
|
|
// 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<String, String> {
|
|
// 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::<PlayerRuntime>();
|
|
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::<CastProxyState>();
|
|
let mut lock = proxy_state.inner.lock().unwrap();
|
|
stop_cast_proxy_locked(&mut lock);
|
|
}
|
|
})
|
|
.setup(|app| {
|
|
app.manage(AppState {
|
|
known_devices: Arc::new(TokioRwLock::new(HashMap::new())),
|
|
});
|
|
app.manage(SidecarState {
|
|
child: Mutex::new(None),
|
|
});
|
|
app.manage(CastProxyState {
|
|
inner: Mutex::new(None),
|
|
});
|
|
|
|
// 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(Arc::clone(&shared));
|
|
app.manage(PlayerRuntime { shared, controller });
|
|
|
|
let handle = app.handle().clone();
|
|
|
|
// 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::<AppState>();
|
|
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;
|
|
}
|
|
}
|
|
});
|
|
|
|
// 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::<agnostic_mdns::worksteal::ServiceEntry>();
|
|
|
|
// 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;
|
|
});
|
|
|
|
// 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<String> = 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::<AppState>();
|
|
let mut devices = state.known_devices.write().await;
|
|
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
|
|
}
|
|
});
|
|
for name in removed {
|
|
let _ = gc_handle.emit("cast-device-removed", json!({"name": name}));
|
|
}
|
|
}
|
|
});
|
|
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");
|
|
}
|