Files
RadioPlayer/src-tauri/src/lib.rs
Gregor Klevze 694f335408 tools: add sync-version.js to sync package.json -> Tauri files
- Add tools/sync-version.js script to read root package.json version
  and update src-tauri/tauri.conf.json and src-tauri/Cargo.toml.
- Update only the [package] version line in Cargo.toml to preserve formatting.
- Include JSON read/write helpers and basic error handling/reporting.
2026-01-13 07:21:51 +01:00

619 lines
20 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;
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<Option<CommandChild>>,
}
struct AppState {
known_devices: Mutex<HashMap<String, String>>,
}
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: &'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<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) {
// 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<CastProxy>) {
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<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> {
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<CastProxyStartResult, String> {
// 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<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.lock().unwrap();
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,
) -> 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<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: 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::<AppState>();
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");
}