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.
This commit is contained in:
@@ -1,6 +1,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::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;
|
||||
@@ -21,6 +31,21 @@ 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 {
|
||||
@@ -40,6 +65,221 @@ fn clamp01(v: f32) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
@@ -177,8 +417,18 @@ async fn cast_play(
|
||||
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": {} });
|
||||
@@ -282,6 +532,12 @@ pub fn run() {
|
||||
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| {
|
||||
@@ -291,6 +547,9 @@ pub fn run() {
|
||||
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.
|
||||
@@ -342,6 +601,8 @@ pub fn run() {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user