Compare commits
3 Commits
ab3a86041a
...
83c9bcf12e
| Author | SHA1 | Date | |
|---|---|---|---|
| 83c9bcf12e | |||
| ed2e660d34 | |||
| efdba35b77 |
@@ -23,6 +23,7 @@ function stopSessions(client, sessions, cb) {
|
|||||||
const session = remaining.shift();
|
const session = remaining.shift();
|
||||||
if (!session) return cb();
|
if (!session) return cb();
|
||||||
|
|
||||||
|
try {
|
||||||
client.stop(session, (err) => {
|
client.stop(session, (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
log(`Stop session failed (${session.appId || 'unknown app'}): ${err.message || String(err)}`);
|
log(`Stop session failed (${session.appId || 'unknown app'}): ${err.message || String(err)}`);
|
||||||
@@ -32,6 +33,11 @@ function stopSessions(client, sessions, cb) {
|
|||||||
// Continue regardless; best-effort.
|
// Continue regardless; best-effort.
|
||||||
stopNext();
|
stopNext();
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Some devices/library versions may throw synchronously; just log and continue.
|
||||||
|
log(`Stop session threw (${session.appId || 'unknown app'}): ${err.message || String(err)}`);
|
||||||
|
stopNext();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
stopNext();
|
stopNext();
|
||||||
@@ -52,7 +58,7 @@ rl.on('line', (line) => {
|
|||||||
|
|
||||||
switch (command) {
|
switch (command) {
|
||||||
case 'play':
|
case 'play':
|
||||||
play(args.ip, args.url);
|
play(args.ip, args.url, args.metadata);
|
||||||
break;
|
break;
|
||||||
case 'stop':
|
case 'stop':
|
||||||
stop();
|
stop();
|
||||||
@@ -68,12 +74,13 @@ rl.on('line', (line) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function play(ip, url) {
|
function play(ip, url, metadata) {
|
||||||
if (activeClient) {
|
if (activeClient) {
|
||||||
try { activeClient.close(); } catch (e) { }
|
try { activeClient.close(); } catch (e) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
activeClient = new Client();
|
activeClient = new Client();
|
||||||
|
activeClient._playMetadata = metadata || {};
|
||||||
|
|
||||||
activeClient.connect(ip, () => {
|
activeClient.connect(ip, () => {
|
||||||
log(`Connected to ${ip}`);
|
log(`Connected to ${ip}`);
|
||||||
@@ -100,20 +107,18 @@ function play(ip, url) {
|
|||||||
log('Join failed, attempting launch...');
|
log('Join failed, attempting launch...');
|
||||||
log(`Join error: ${err && err.message ? err.message : String(err)}`);
|
log(`Join error: ${err && err.message ? err.message : String(err)}`);
|
||||||
// Join can fail if the session is stale; stop it and retry launch.
|
// Join can fail if the session is stale; stop it and retry launch.
|
||||||
stopSessions(activeClient, [session], () => launchPlayer(url, /*didStopFirst*/ true));
|
stopSessions(activeClient, [session], () => launchPlayer(url, activeClient._playMetadata, /*didStopFirst*/ true));
|
||||||
} else {
|
} else {
|
||||||
activePlayer = player;
|
activePlayer = player;
|
||||||
loadMedia(url);
|
loadMedia(url, activeClient._playMetadata);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// If another app is running, stop it first to avoid NOT_ALLOWED.
|
// Backdrop or other non-media session present: skip stopping to avoid platform sender crash, just launch.
|
||||||
if (sessions.length > 0) {
|
if (sessions.length > 0) {
|
||||||
log('Non-media session detected, stopping before launch...');
|
log('Non-media session detected; skipping stop and launching DefaultMediaReceiver...');
|
||||||
stopSessions(activeClient, sessions, () => launchPlayer(url, /*didStopFirst*/ true));
|
|
||||||
} else {
|
|
||||||
launchPlayer(url, /*didStopFirst*/ false);
|
|
||||||
}
|
}
|
||||||
|
launchPlayer(url, activeClient._playMetadata, /*didStopFirst*/ false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -126,7 +131,7 @@ function play(ip, url) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function launchPlayer(url, didStopFirst) {
|
function launchPlayer(url, metadata, didStopFirst) {
|
||||||
if (!activeClient) return;
|
if (!activeClient) return;
|
||||||
|
|
||||||
activeClient.launch(DefaultMediaReceiver, (err, player) => {
|
activeClient.launch(DefaultMediaReceiver, (err, player) => {
|
||||||
@@ -150,7 +155,7 @@ function launchPlayer(url, didStopFirst) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
activePlayer = retryPlayer;
|
activePlayer = retryPlayer;
|
||||||
loadMedia(url);
|
loadMedia(url, metadata);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -162,20 +167,23 @@ function launchPlayer(url, didStopFirst) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
activePlayer = player;
|
activePlayer = player;
|
||||||
loadMedia(url);
|
loadMedia(url, metadata);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadMedia(url) {
|
function loadMedia(url, metadata) {
|
||||||
if (!activePlayer) return;
|
if (!activePlayer) return;
|
||||||
|
|
||||||
|
const meta = metadata || {};
|
||||||
const media = {
|
const media = {
|
||||||
contentId: url,
|
contentId: url,
|
||||||
contentType: 'audio/mpeg',
|
contentType: 'audio/mpeg',
|
||||||
streamType: 'LIVE',
|
streamType: 'LIVE',
|
||||||
metadata: {
|
metadata: {
|
||||||
metadataType: 0,
|
metadataType: 0,
|
||||||
title: 'RadioPlayer'
|
title: meta.title || 'RadioPlayer',
|
||||||
|
subtitle: meta.artist || meta.station || undefined,
|
||||||
|
images: meta.image ? [{ url: meta.image }] : undefined
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
74
src-tauri/Cargo.lock
generated
74
src-tauri/Cargo.lock
generated
@@ -2660,6 +2660,15 @@ dependencies = [
|
|||||||
"syn 2.0.111",
|
"syn 2.0.111",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matchers"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||||
|
dependencies = [
|
||||||
|
"regex-automata",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matches"
|
name = "matches"
|
||||||
version = "0.1.10"
|
version = "0.1.10"
|
||||||
@@ -2891,6 +2900,15 @@ dependencies = [
|
|||||||
"minimal-lexical",
|
"minimal-lexical",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nu-ansi-term"
|
||||||
|
version = "0.50.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -3775,6 +3793,8 @@ dependencies = [
|
|||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tauri-plugin-shell",
|
"tauri-plugin-shell",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4566,6 +4586,15 @@ dependencies = [
|
|||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sharded-slab"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||||
|
dependencies = [
|
||||||
|
"lazy_static",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shared_child"
|
name = "shared_child"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -5316,6 +5345,15 @@ dependencies = [
|
|||||||
"syn 2.0.111",
|
"syn 2.0.111",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thread_local"
|
||||||
|
version = "1.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "time"
|
name = "time"
|
||||||
version = "0.3.44"
|
version = "0.3.44"
|
||||||
@@ -5615,6 +5653,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
"valuable",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-log"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"tracing-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-subscriber"
|
||||||
|
version = "0.3.22"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
|
||||||
|
dependencies = [
|
||||||
|
"matchers",
|
||||||
|
"nu-ansi-term",
|
||||||
|
"once_cell",
|
||||||
|
"regex-automata",
|
||||||
|
"sharded-slab",
|
||||||
|
"smallvec",
|
||||||
|
"thread_local",
|
||||||
|
"tracing",
|
||||||
|
"tracing-core",
|
||||||
|
"tracing-log",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5791,6 +5859,12 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "valuable"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "value-bag"
|
name = "value-bag"
|
||||||
version = "1.12.0"
|
version = "1.12.0"
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
rust_cast = "0.19.0"
|
rust_cast = "0.19.0"
|
||||||
mdns-sd = "0.17.1"
|
mdns-sd = "0.17.1"
|
||||||
agnostic-mdns = { version = "0.4", features = ["tokio"] }
|
agnostic-mdns = { version = "0.4", features = ["tokio"], optional = true }
|
||||||
async-channel = "2.5.0"
|
async-channel = "2.5.0"
|
||||||
tokio = { version = "1.48.0", features = ["full"] }
|
tokio = { version = "1.48.0", features = ["full"] }
|
||||||
tauri-plugin-shell = "2.3.3"
|
tauri-plugin-shell = "2.3.3"
|
||||||
@@ -33,4 +33,9 @@ reqwest = { version = "0.11", features = ["json", "rustls-tls"] }
|
|||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
cpal = "0.15"
|
cpal = "0.15"
|
||||||
ringbuf = "0.3"
|
ringbuf = "0.3"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
use_agnostic_mdns = ["agnostic-mdns"]
|
||||||
|
|
||||||
|
|||||||
@@ -2,19 +2,24 @@ use std::collections::HashMap;
|
|||||||
use std::io::{BufRead, BufReader};
|
use std::io::{BufRead, BufReader};
|
||||||
use std::net::{IpAddr, SocketAddr, TcpListener, TcpStream, UdpSocket};
|
use std::net::{IpAddr, SocketAddr, TcpListener, TcpStream, UdpSocket};
|
||||||
use std::process::{Child, Command, Stdio};
|
use std::process::{Child, Command, Stdio};
|
||||||
use std::sync::Mutex;
|
use std::sync::{Mutex, Arc};
|
||||||
use std::thread;
|
// thread usage replaced by async tasks; remove direct std::thread import
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
use tokio::sync::{RwLock as TokioRwLock, mpsc};
|
||||||
|
|
||||||
|
#[cfg(not(feature = "use_agnostic_mdns"))]
|
||||||
use mdns_sd::{ServiceDaemon, ServiceEvent};
|
use mdns_sd::{ServiceDaemon, ServiceEvent};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tauri::{AppHandle, Manager, State};
|
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::process::{CommandChild, CommandEvent};
|
||||||
use tauri_plugin_shell::ShellExt;
|
use tauri_plugin_shell::ShellExt;
|
||||||
use reqwest;
|
use reqwest;
|
||||||
use base64::{engine::general_purpose, Engine as _};
|
use base64::{engine::general_purpose, Engine as _};
|
||||||
|
|
||||||
mod player;
|
pub mod player;
|
||||||
use player::{PlayerCommand, PlayerController, PlayerShared, PlayerState};
|
use player::{PlayerCommand, PlayerController, PlayerShared, PlayerState};
|
||||||
|
|
||||||
struct SidecarState {
|
struct SidecarState {
|
||||||
@@ -22,7 +27,12 @@ struct SidecarState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct AppState {
|
struct AppState {
|
||||||
known_devices: Mutex<HashMap<String, String>>,
|
known_devices: Arc<TokioRwLock<HashMap<String, DeviceInfo>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DeviceInfo {
|
||||||
|
ip: String,
|
||||||
|
last_seen: std::time::Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CastProxy {
|
struct CastProxy {
|
||||||
@@ -43,7 +53,7 @@ struct CastProxyStartResult {
|
|||||||
// Native (non-WebView) audio player state.
|
// Native (non-WebView) audio player state.
|
||||||
// Step 1: state machine + command interface only (no decoding/output yet).
|
// Step 1: state machine + command interface only (no decoding/output yet).
|
||||||
struct PlayerRuntime {
|
struct PlayerRuntime {
|
||||||
shared: &'static PlayerShared,
|
shared: Arc<PlayerShared>,
|
||||||
controller: PlayerController,
|
controller: PlayerController,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,22 +89,24 @@ fn local_ip_for_peer(peer_ip: IpAddr) -> Result<IpAddr, String> {
|
|||||||
Ok(sock.local_addr().map_err(|e| e.to_string())?.ip())
|
Ok(sock.local_addr().map_err(|e| e.to_string())?.ip())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn wait_for_listen(ip: IpAddr, port: u16) {
|
fn wait_for_listen(ip: IpAddr, port: u16) -> bool {
|
||||||
// Best-effort: give ffmpeg a moment to bind before we tell the Chromecast.
|
// 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);
|
let addr = SocketAddr::new(ip, port);
|
||||||
for _ in 0..50 {
|
for _ in 0..50 {
|
||||||
if TcpStream::connect_timeout(&addr, Duration::from_millis(30)).is_ok() {
|
if TcpStream::connect_timeout(&addr, Duration::from_millis(30)).is_ok() {
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
std::thread::sleep(Duration::from_millis(20));
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
}
|
}
|
||||||
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stop_cast_proxy_locked(lock: &mut Option<CastProxy>) {
|
fn stop_cast_proxy_locked(lock: &mut Option<CastProxy>) {
|
||||||
if let Some(mut proxy) = lock.take() {
|
if let Some(mut proxy) = lock.take() {
|
||||||
let _ = proxy.child.kill();
|
let _ = proxy.child.kill();
|
||||||
let _ = proxy.child.wait();
|
let _ = proxy.child.wait();
|
||||||
println!("Cast proxy stopped");
|
info!("Cast proxy stopped");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +156,7 @@ fn spawn_standalone_cast_proxy(url: String, port: u16) -> Result<Child, String>
|
|||||||
std::thread::sleep(Duration::from_millis(150));
|
std::thread::sleep(Duration::from_millis(150));
|
||||||
if let Ok(Some(status)) = child.try_wait() {
|
if let Ok(Some(status)) = child.try_wait() {
|
||||||
if !status.success() {
|
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")?;
|
child = spawn("mp3")?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,10 +176,10 @@ async fn cast_proxy_start(
|
|||||||
player::preflight_ffmpeg_only()?;
|
player::preflight_ffmpeg_only()?;
|
||||||
|
|
||||||
let device_ip_str = {
|
let device_ip_str = {
|
||||||
let devices = state.known_devices.lock().unwrap();
|
let devices = state.known_devices.read().await;
|
||||||
devices
|
devices
|
||||||
.get(&device_name)
|
.get(&device_name)
|
||||||
.cloned()
|
.map(|d| d.ip.clone())
|
||||||
.ok_or("Device not found")?
|
.ok_or("Device not found")?
|
||||||
};
|
};
|
||||||
let device_ip: IpAddr = device_ip_str
|
let device_ip: IpAddr = device_ip_str
|
||||||
@@ -175,14 +187,6 @@ async fn cast_proxy_start(
|
|||||||
.map_err(|_| format!("Invalid device IP: {device_ip_str}"))?;
|
.map_err(|_| format!("Invalid device IP: {device_ip_str}"))?;
|
||||||
let local_ip = local_ip_for_peer(device_ip)?;
|
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.
|
// Stop any existing standalone proxy first.
|
||||||
{
|
{
|
||||||
let mut lock = proxy_state.inner.lock().unwrap();
|
let mut lock = proxy_state.inner.lock().unwrap();
|
||||||
@@ -202,63 +206,82 @@ async fn cast_proxy_start(
|
|||||||
.map_err(|e| e.to_string())?;
|
.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 (reply_tx, reply_rx) = std::sync::mpsc::channel();
|
||||||
let _ = player
|
let _ = player
|
||||||
.controller
|
.controller
|
||||||
.tx
|
.tx
|
||||||
.send(PlayerCommand::CastTapStart {
|
.send(PlayerCommand::CastTapStart {
|
||||||
port,
|
port,
|
||||||
|
bind_host: host.clone(),
|
||||||
reply: reply_tx,
|
reply: reply_tx,
|
||||||
})
|
})
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
match reply_rx.recv_timeout(Duration::from_secs(2)) {
|
match reply_rx.recv_timeout(Duration::from_secs(2)) {
|
||||||
Ok(Ok(())) => {
|
Ok(Ok(())) => {
|
||||||
wait_for_listen(local_ip, port);
|
if wait_for_listen(local_ip, port) {
|
||||||
Ok(CastProxyStartResult {
|
info!("Cast proxy started in TAP mode: {}", proxy_url);
|
||||||
|
return Ok(CastProxyStartResult {
|
||||||
url: proxy_url,
|
url: proxy_url,
|
||||||
mode: "tap".to_string(),
|
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)) => {
|
Ok(Err(e)) => {
|
||||||
eprintln!("Cast tap start failed; falling back to standalone proxy: {e}");
|
warn!("Cast tap start failed on attempt {}/{}: {e}", attempt+1, max_attempts);
|
||||||
let mut child = spawn_standalone_cast_proxy(url, port)?;
|
let _ = player.controller.tx.send(PlayerCommand::CastTapStop);
|
||||||
if let Some(stderr) = child.stderr.take() {
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
std::thread::spawn(move || {
|
continue;
|
||||||
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(_) => {
|
Err(_) => {
|
||||||
eprintln!("Cast tap start timed out; falling back to standalone proxy");
|
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)?;
|
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 || {
|
std::thread::spawn(move || {
|
||||||
let reader = BufReader::new(stderr);
|
let reader = BufReader::new(stderr);
|
||||||
for line in reader.lines().flatten() {
|
for line in reader.lines().flatten() {
|
||||||
eprintln!("[cast-proxy ffmpeg] {line}");
|
warn!("[cast-proxy ffmpeg] {line}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
wait_for_listen(local_ip, port);
|
// best-effort wait for standalone proxy
|
||||||
|
let _ = wait_for_listen(local_ip, port);
|
||||||
let mut lock = proxy_state.inner.lock().unwrap();
|
let mut lock = proxy_state.inner.lock().unwrap();
|
||||||
*lock = Some(CastProxy { child });
|
*lock = Some(CastProxy { child });
|
||||||
|
info!("Cast proxy started in STANDALONE mode (after TAP attempts): {}", proxy_url);
|
||||||
Ok(CastProxyStartResult {
|
Ok(CastProxyStartResult {
|
||||||
url: proxy_url,
|
url: proxy_url,
|
||||||
mode: "proxy".to_string(),
|
mode: "proxy".to_string(),
|
||||||
})
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -338,7 +361,7 @@ async fn player_stop(player: State<'_, PlayerRuntime>) -> Result<(), String> {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn list_cast_devices(state: State<'_, AppState>) -> Result<Vec<String>, String> {
|
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().await;
|
||||||
let mut list: Vec<String> = devices.keys().cloned().collect();
|
let mut list: Vec<String> = devices.keys().cloned().collect();
|
||||||
list.sort();
|
list.sort();
|
||||||
Ok(list)
|
Ok(list)
|
||||||
@@ -351,13 +374,22 @@ async fn cast_play(
|
|||||||
sidecar_state: State<'_, SidecarState>,
|
sidecar_state: State<'_, SidecarState>,
|
||||||
device_name: String,
|
device_name: String,
|
||||||
url: String,
|
url: String,
|
||||||
|
title: Option<String>,
|
||||||
|
artist: Option<String>,
|
||||||
|
image: Option<String>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
// Resolve device name -> ip with diagnostics on failure
|
||||||
let ip = {
|
let ip = {
|
||||||
let devices = state.known_devices.lock().unwrap();
|
let devices = state.known_devices.read().await;
|
||||||
devices
|
if let Some(d) = devices.get(&device_name) {
|
||||||
.get(&device_name)
|
info!("cast_play: resolved device '{}' -> {}", device_name, d.ip);
|
||||||
.cloned()
|
d.ip.clone()
|
||||||
.ok_or("Device not found")?
|
} 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();
|
let mut lock = sidecar_state.child.lock().unwrap();
|
||||||
@@ -366,21 +398,35 @@ async fn cast_play(
|
|||||||
let child = if let Some(ref mut child) = *lock {
|
let child = if let Some(ref mut child) = *lock {
|
||||||
child
|
child
|
||||||
} else {
|
} else {
|
||||||
println!("Spawning new sidecar...");
|
info!("Spawning new sidecar...");
|
||||||
|
// Use the packaged sidecar binary (radiocast-sidecar-<target>.exe)
|
||||||
let sidecar_command = app
|
let sidecar_command = app
|
||||||
.shell()
|
.shell()
|
||||||
.sidecar("radiocast-sidecar")
|
.sidecar("radiocast-sidecar")
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| {
|
||||||
let (mut rx, child) = sidecar_command.spawn().map_err(|e| e.to_string())?;
|
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 {
|
tauri::async_runtime::spawn(async move {
|
||||||
while let Some(event) = rx.recv().await {
|
while let Some(event) = rx.recv().await {
|
||||||
match event {
|
match event {
|
||||||
CommandEvent::Stdout(line) => {
|
CommandEvent::Stdout(line) => {
|
||||||
println!("Sidecar: {}", String::from_utf8_lossy(&line))
|
info!("Sidecar: {}", String::from_utf8_lossy(&line))
|
||||||
}
|
}
|
||||||
CommandEvent::Stderr(line) => {
|
CommandEvent::Stderr(line) => {
|
||||||
eprintln!("Sidecar Error: {}", String::from_utf8_lossy(&line))
|
error!("Sidecar Error: {}", String::from_utf8_lossy(&line))
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -393,12 +439,25 @@ async fn cast_play(
|
|||||||
|
|
||||||
let play_cmd = json!({
|
let play_cmd = json!({
|
||||||
"command": "play",
|
"command": "play",
|
||||||
"args": { "ip": ip, "url": url }
|
"args": {
|
||||||
|
"ip": ip,
|
||||||
|
"url": url,
|
||||||
|
"metadata": {
|
||||||
|
"title": title,
|
||||||
|
"artist": artist,
|
||||||
|
"image": image
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
let play_payload = format!("{}\n", play_cmd.to_string());
|
||||||
child
|
info!("Sending cast URL to device '{}': {}", device_name, url);
|
||||||
.write(format!("{}\n", play_cmd.to_string()).as_bytes())
|
match child.write(play_payload.as_bytes()) {
|
||||||
.map_err(|e| e.to_string())?;
|
Ok(()) => info!("Sidecar write OK"),
|
||||||
|
Err(e) => {
|
||||||
|
error!("Sidecar write failed: {}", e);
|
||||||
|
return Err(e.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,7 +590,7 @@ pub fn run() {
|
|||||||
})
|
})
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
app.manage(AppState {
|
app.manage(AppState {
|
||||||
known_devices: Mutex::new(HashMap::new()),
|
known_devices: Arc::new(TokioRwLock::new(HashMap::new())),
|
||||||
});
|
});
|
||||||
app.manage(SidecarState {
|
app.manage(SidecarState {
|
||||||
child: Mutex::new(None),
|
child: Mutex::new(None),
|
||||||
@@ -540,24 +599,103 @@ pub fn run() {
|
|||||||
inner: Mutex::new(None),
|
inner: Mutex::new(None),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Player scaffolding: leak shared state to get a 'static reference for the
|
// Initialize tracing subscriber for structured logging. Honor RUST_LOG if set.
|
||||||
// long-running thread without complex lifetime plumbing.
|
tracing_subscriber::fmt::init();
|
||||||
// Later refactors can move this to Arc<...> when the engine grows.
|
|
||||||
let shared: &'static PlayerShared = Box::leak(Box::new(PlayerShared {
|
// 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()),
|
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 });
|
app.manage(PlayerRuntime { shared, controller });
|
||||||
|
|
||||||
let handle = app.handle().clone();
|
let handle = app.handle().clone();
|
||||||
thread::spawn(move || {
|
|
||||||
|
// 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 mdns = ServiceDaemon::new().expect("Failed to create daemon");
|
||||||
let receiver = mdns
|
let receiver = mdns
|
||||||
.browse("_googlecast._tcp.local.")
|
.browse("_googlecast._tcp.local.")
|
||||||
.expect("Failed to browse");
|
.expect("Failed to browse");
|
||||||
while let Ok(event) = receiver.recv() {
|
while let Ok(event) = receiver.recv() {
|
||||||
match event {
|
if let ServiceEvent::ServiceResolved(info) = event {
|
||||||
ServiceEvent::ServiceResolved(info) => {
|
|
||||||
let name = info
|
let name = info
|
||||||
.get_property_val_str("fn")
|
.get_property_val_str("fn")
|
||||||
.or_else(|| Some(info.get_fullname()))
|
.or_else(|| Some(info.get_fullname()))
|
||||||
@@ -568,18 +706,38 @@ pub fn run() {
|
|||||||
.iter()
|
.iter()
|
||||||
.find(|ip| ip.is_ipv4())
|
.find(|ip| ip.is_ipv4())
|
||||||
.or_else(|| addresses.iter().next());
|
.or_else(|| addresses.iter().next());
|
||||||
|
|
||||||
if let Some(ip) = ip {
|
if let Some(ip) = ip {
|
||||||
let state = handle.state::<AppState>();
|
|
||||||
let mut devices = state.known_devices.lock().unwrap();
|
|
||||||
let ip_str = ip.to_string();
|
let ip_str = ip.to_string();
|
||||||
if !devices.contains_key(&name) {
|
// Best-effort send into the async channel; ignore if receiver dropped.
|
||||||
//println!("Discovered Cast Device: {} at {}", name, ip_str);
|
let _ = probe_tx.send((name, ip_str));
|
||||||
devices.insert(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}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ pub enum PlayerCommand {
|
|||||||
SetVolume { volume: f32 },
|
SetVolume { volume: f32 },
|
||||||
CastTapStart {
|
CastTapStart {
|
||||||
port: u16,
|
port: u16,
|
||||||
|
bind_host: String,
|
||||||
reply: mpsc::Sender<Result<(), String>>,
|
reply: mpsc::Sender<Result<(), String>>,
|
||||||
},
|
},
|
||||||
CastTapStop,
|
CastTapStop,
|
||||||
@@ -86,10 +87,11 @@ pub struct PlayerController {
|
|||||||
pub tx: mpsc::Sender<PlayerCommand>,
|
pub tx: mpsc::Sender<PlayerCommand>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn spawn_player_thread(shared: &'static PlayerShared) -> PlayerController {
|
pub fn spawn_player_thread(shared: std::sync::Arc<PlayerShared>) -> PlayerController {
|
||||||
let (tx, rx) = mpsc::channel::<PlayerCommand>();
|
let (tx, rx) = mpsc::channel::<PlayerCommand>();
|
||||||
|
|
||||||
std::thread::spawn(move || player_thread(shared, rx));
|
let shared_for_thread = std::sync::Arc::clone(&shared);
|
||||||
|
std::thread::spawn(move || player_thread(shared_for_thread, rx));
|
||||||
PlayerController { tx }
|
PlayerController { tx }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,14 +115,14 @@ fn volume_from_bits(bits: u32) -> f32 {
|
|||||||
f32::from_bits(bits)
|
f32::from_bits(bits)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_status(shared: &'static PlayerShared, status: PlayerStatus) {
|
fn set_status(shared: &std::sync::Arc<PlayerShared>, status: PlayerStatus) {
|
||||||
let mut s = shared.state.lock().unwrap();
|
let mut s = shared.state.lock().unwrap();
|
||||||
if s.status != status {
|
if s.status != status {
|
||||||
s.status = status;
|
s.status = status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_error(shared: &'static PlayerShared, message: String) {
|
fn set_error(shared: &std::sync::Arc<PlayerShared>, message: String) {
|
||||||
let mut s = shared.state.lock().unwrap();
|
let mut s = shared.state.lock().unwrap();
|
||||||
s.status = PlayerStatus::Error;
|
s.status = PlayerStatus::Error;
|
||||||
s.error = Some(message);
|
s.error = Some(message);
|
||||||
@@ -205,6 +207,8 @@ enum PipelineMode {
|
|||||||
struct CastTapProc {
|
struct CastTapProc {
|
||||||
child: std::process::Child,
|
child: std::process::Child,
|
||||||
writer_join: Option<std::thread::JoinHandle<()>>,
|
writer_join: Option<std::thread::JoinHandle<()>>,
|
||||||
|
server_join: Option<std::thread::JoinHandle<()>>,
|
||||||
|
stop_flag: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Pipeline {
|
struct Pipeline {
|
||||||
@@ -219,7 +223,7 @@ struct Pipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Pipeline {
|
impl Pipeline {
|
||||||
fn start(shared: &'static PlayerShared, url: String, mode: PipelineMode) -> Result<Self, String> {
|
fn start(shared: std::sync::Arc<PlayerShared>, url: String, mode: PipelineMode) -> Result<Self, String> {
|
||||||
let (device, sample_format, cfg, sample_rate, channels) = match mode {
|
let (device, sample_format, cfg, sample_rate, channels) = match mode {
|
||||||
PipelineMode::WithOutput => {
|
PipelineMode::WithOutput => {
|
||||||
let host = cpal::default_host();
|
let host = cpal::default_host();
|
||||||
@@ -265,7 +269,7 @@ impl Pipeline {
|
|||||||
|
|
||||||
// Decoder thread: spawns ffmpeg, reads PCM, writes into ring buffer.
|
// Decoder thread: spawns ffmpeg, reads PCM, writes into ring buffer.
|
||||||
let stop_for_decoder = Arc::clone(&stop_flag);
|
let stop_for_decoder = Arc::clone(&stop_flag);
|
||||||
let shared_for_decoder = shared;
|
let shared_for_decoder = std::sync::Arc::clone(&shared);
|
||||||
let decoder_url = url.clone();
|
let decoder_url = url.clone();
|
||||||
let cast_tx_for_decoder = Arc::clone(&cast_tx);
|
let cast_tx_for_decoder = Arc::clone(&cast_tx);
|
||||||
let decoder_join = std::thread::spawn(move || {
|
let decoder_join = std::thread::spawn(move || {
|
||||||
@@ -280,7 +284,7 @@ impl Pipeline {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
set_status(shared_for_decoder, PlayerStatus::Buffering);
|
set_status(&shared_for_decoder, PlayerStatus::Buffering);
|
||||||
|
|
||||||
let ffmpeg = ffmpeg_command();
|
let ffmpeg = ffmpeg_command();
|
||||||
let ffmpeg_disp = ffmpeg.to_string_lossy();
|
let ffmpeg_disp = ffmpeg.to_string_lossy();
|
||||||
@@ -314,7 +318,7 @@ impl Pipeline {
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
// If ffmpeg isn't available, this is a hard failure.
|
// If ffmpeg isn't available, this is a hard failure.
|
||||||
set_error(
|
set_error(
|
||||||
shared_for_decoder,
|
&shared_for_decoder,
|
||||||
format!(
|
format!(
|
||||||
"Failed to start ffmpeg ({ffmpeg_disp}): {e}. Set RADIOPLAYER_FFMPEG, bundle ffmpeg next to the app, or install ffmpeg on PATH."
|
"Failed to start ffmpeg ({ffmpeg_disp}): {e}. Set RADIOPLAYER_FFMPEG, bundle ffmpeg next to the app, or install ffmpeg on PATH."
|
||||||
),
|
),
|
||||||
@@ -326,7 +330,7 @@ impl Pipeline {
|
|||||||
let mut stdout = match child.stdout.take() {
|
let mut stdout = match child.stdout.take() {
|
||||||
Some(s) => s,
|
Some(s) => s,
|
||||||
None => {
|
None => {
|
||||||
set_error(shared_for_decoder, "ffmpeg stdout not available".to_string());
|
set_error(&shared_for_decoder, "ffmpeg stdout not available".to_string());
|
||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -355,7 +359,7 @@ impl Pipeline {
|
|||||||
if stop_for_decoder.load(Ordering::SeqCst) {
|
if stop_for_decoder.load(Ordering::SeqCst) {
|
||||||
break 'outer;
|
break 'outer;
|
||||||
}
|
}
|
||||||
set_status(shared_for_decoder, PlayerStatus::Buffering);
|
set_status(&shared_for_decoder, PlayerStatus::Buffering);
|
||||||
std::thread::sleep(Duration::from_millis(backoff_ms));
|
std::thread::sleep(Duration::from_millis(backoff_ms));
|
||||||
backoff_ms = (backoff_ms * 2).min(5000);
|
backoff_ms = (backoff_ms * 2).min(5000);
|
||||||
continue 'outer;
|
continue 'outer;
|
||||||
@@ -400,7 +404,7 @@ impl Pipeline {
|
|||||||
|
|
||||||
// Move to Playing once we've decoded a small buffer.
|
// Move to Playing once we've decoded a small buffer.
|
||||||
if pushed_since_start >= playing_threshold_samples {
|
if pushed_since_start >= playing_threshold_samples {
|
||||||
set_status(shared_for_decoder, PlayerStatus::Playing);
|
set_status(&shared_for_decoder, PlayerStatus::Playing);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -413,7 +417,8 @@ impl Pipeline {
|
|||||||
let mut cons = cons_opt.take().expect("cons must exist for WithOutput");
|
let mut cons = cons_opt.take().expect("cons must exist for WithOutput");
|
||||||
|
|
||||||
// Audio callback: drain ring buffer and write to output.
|
// Audio callback: drain ring buffer and write to output.
|
||||||
let shared_for_cb = shared;
|
let shared_for_cb = std::sync::Arc::clone(&shared);
|
||||||
|
let shared_for_cb_err = std::sync::Arc::clone(&shared_for_cb);
|
||||||
let stop_for_cb = Arc::clone(&stop_flag);
|
let stop_for_cb = Arc::clone(&stop_flag);
|
||||||
let volume_for_cb = Arc::clone(&volume_bits);
|
let volume_for_cb = Arc::clone(&volume_bits);
|
||||||
|
|
||||||
@@ -421,7 +426,7 @@ impl Pipeline {
|
|||||||
|
|
||||||
let err_fn = move |err| {
|
let err_fn = move |err| {
|
||||||
let msg = format!("Audio output error: {err}");
|
let msg = format!("Audio output error: {err}");
|
||||||
set_error(shared_for_cb, msg);
|
set_error(&shared_for_cb_err, msg);
|
||||||
};
|
};
|
||||||
|
|
||||||
let built = match sample_format {
|
let built = match sample_format {
|
||||||
@@ -448,7 +453,7 @@ impl Pipeline {
|
|||||||
if underrun != last_was_underrun {
|
if underrun != last_was_underrun {
|
||||||
last_was_underrun = underrun;
|
last_was_underrun = underrun;
|
||||||
set_status(
|
set_status(
|
||||||
shared_for_cb,
|
&shared_for_cb,
|
||||||
if underrun {
|
if underrun {
|
||||||
PlayerStatus::Buffering
|
PlayerStatus::Buffering
|
||||||
} else {
|
} else {
|
||||||
@@ -485,7 +490,7 @@ impl Pipeline {
|
|||||||
if underrun != last_was_underrun {
|
if underrun != last_was_underrun {
|
||||||
last_was_underrun = underrun;
|
last_was_underrun = underrun;
|
||||||
set_status(
|
set_status(
|
||||||
shared_for_cb,
|
&shared_for_cb,
|
||||||
if underrun {
|
if underrun {
|
||||||
PlayerStatus::Buffering
|
PlayerStatus::Buffering
|
||||||
} else {
|
} else {
|
||||||
@@ -523,7 +528,7 @@ impl Pipeline {
|
|||||||
if underrun != last_was_underrun {
|
if underrun != last_was_underrun {
|
||||||
last_was_underrun = underrun;
|
last_was_underrun = underrun;
|
||||||
set_status(
|
set_status(
|
||||||
shared_for_cb,
|
&shared_for_cb,
|
||||||
if underrun {
|
if underrun {
|
||||||
PlayerStatus::Buffering
|
PlayerStatus::Buffering
|
||||||
} else {
|
} else {
|
||||||
@@ -559,16 +564,18 @@ impl Pipeline {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn start_cast_tap(&mut self, port: u16, sample_rate: u32, channels: u16) -> Result<(), String> {
|
fn start_cast_tap(&mut self, port: u16, bind_host: &str, sample_rate: u32, channels: u16) -> Result<(), String> {
|
||||||
// Stop existing tap first.
|
// Stop existing tap first.
|
||||||
self.stop_cast_tap();
|
self.stop_cast_tap();
|
||||||
|
|
||||||
let ffmpeg = ffmpeg_command();
|
let ffmpeg = ffmpeg_command();
|
||||||
let ffmpeg_disp = ffmpeg.to_string_lossy();
|
let ffmpeg_disp = ffmpeg.to_string_lossy();
|
||||||
|
let bind_host = bind_host.to_owned();
|
||||||
|
|
||||||
let spawn = |codec: &str| -> Result<std::process::Child, String> {
|
let spawn = |codec: &str| -> Result<std::process::Child, String> {
|
||||||
command_hidden(&ffmpeg)
|
command_hidden(&ffmpeg)
|
||||||
.arg("-nostdin")
|
.arg("-nostdin")
|
||||||
|
.arg("-re")
|
||||||
.arg("-hide_banner")
|
.arg("-hide_banner")
|
||||||
.arg("-loglevel")
|
.arg("-loglevel")
|
||||||
.arg("warning")
|
.arg("warning")
|
||||||
@@ -587,13 +594,9 @@ impl Pipeline {
|
|||||||
.arg("128k")
|
.arg("128k")
|
||||||
.arg("-f")
|
.arg("-f")
|
||||||
.arg("mp3")
|
.arg("mp3")
|
||||||
.arg("-content_type")
|
.arg("-")
|
||||||
.arg("audio/mpeg")
|
|
||||||
.arg("-listen")
|
|
||||||
.arg("1")
|
|
||||||
.arg(format!("http://0.0.0.0:{port}/stream.mp3"))
|
|
||||||
.stdin(Stdio::piped())
|
.stdin(Stdio::piped())
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped())
|
.stderr(Stdio::piped())
|
||||||
.spawn()
|
.spawn()
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@@ -617,6 +620,22 @@ impl Pipeline {
|
|||||||
.take()
|
.take()
|
||||||
.ok_or_else(|| "ffmpeg cast tap stdin not available".to_string())?;
|
.ok_or_else(|| "ffmpeg cast tap stdin not available".to_string())?;
|
||||||
|
|
||||||
|
let stdout = child
|
||||||
|
.stdout
|
||||||
|
.take()
|
||||||
|
.ok_or_else(|| "ffmpeg cast tap stdout not available".to_string())?;
|
||||||
|
|
||||||
|
// Log stderr for debugging tap failures
|
||||||
|
if let Some(stderr) = child.stderr.take() {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
use std::io::BufRead;
|
||||||
|
let reader = std::io::BufReader::new(stderr);
|
||||||
|
for line in reader.lines().flatten() {
|
||||||
|
eprintln!("[cast-tap ffmpeg] {}", line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let (tx, rx) = mpsc::sync_channel::<Vec<u8>>(256);
|
let (tx, rx) = mpsc::sync_channel::<Vec<u8>>(256);
|
||||||
*self.cast_tx.lock().unwrap() = Some(tx);
|
*self.cast_tx.lock().unwrap() = Some(tx);
|
||||||
|
|
||||||
@@ -634,9 +653,127 @@ impl Pipeline {
|
|||||||
let _ = stdin.flush();
|
let _ = stdin.flush();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Spawn simple HTTP server to serve ffmpeg stdout
|
||||||
|
let server_stop = Arc::new(AtomicBool::new(false));
|
||||||
|
let server_stop_clone = Arc::clone(&server_stop);
|
||||||
|
|
||||||
|
// Use Arc<Mutex<Vec<mpsc::SyncSender>>> for broadcasting to multiple clients
|
||||||
|
let clients: Arc<Mutex<Vec<mpsc::SyncSender<Vec<u8>>>>> = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let clients_reader = Arc::clone(&clients);
|
||||||
|
|
||||||
|
// Reader thread: reads from ffmpeg stdout and broadcasts to all subscribers
|
||||||
|
let reader_stop = Arc::clone(&server_stop);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
use std::io::Read;
|
||||||
|
let mut ffmpeg_out = stdout;
|
||||||
|
let mut buffer = vec![0u8; 8192];
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if reader_stop.load(Ordering::SeqCst) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
match ffmpeg_out.read(&mut buffer) {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(n) => {
|
||||||
|
let chunk = buffer[..n].to_vec();
|
||||||
|
let mut clients_lock = clients_reader.lock().unwrap();
|
||||||
|
clients_lock.retain(|tx| tx.try_send(chunk.clone()).is_ok());
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let clients_server = Arc::clone(&clients);
|
||||||
|
let server_join = std::thread::spawn(move || {
|
||||||
|
use std::io::{BufRead, BufReader, Write};
|
||||||
|
use std::net::TcpListener;
|
||||||
|
|
||||||
|
let listener = match TcpListener::bind(format!("{bind_host}:{port}")) {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[cast-tap server] Failed to bind: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(e) = listener.set_nonblocking(true) {
|
||||||
|
eprintln!("[cast-tap server] Failed to set nonblocking: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if server_stop_clone.load(Ordering::SeqCst) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept client connections
|
||||||
|
let stream = match listener.accept() {
|
||||||
|
Ok((s, addr)) => {
|
||||||
|
eprintln!("[cast-tap server] Client connected: {addr}");
|
||||||
|
s
|
||||||
|
},
|
||||||
|
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[cast-tap server] Accept error: {e}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Spawn handler for each client
|
||||||
|
let stop_flag = Arc::clone(&server_stop_clone);
|
||||||
|
let (client_tx, client_rx) = mpsc::sync_channel::<Vec<u8>>(256);
|
||||||
|
|
||||||
|
// Subscribe this client
|
||||||
|
clients_server.lock().unwrap().push(client_tx);
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
// Read and discard HTTP request headers
|
||||||
|
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
||||||
|
let mut line = String::new();
|
||||||
|
loop {
|
||||||
|
line.clear();
|
||||||
|
if reader.read_line(&mut line).is_err() || line == "\r\n" || line == "\n" {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send HTTP response headers
|
||||||
|
let mut writer = stream;
|
||||||
|
let headers = b"HTTP/1.1 200 OK\r\nContent-Type: audio/mpeg\r\nConnection: close\r\nCache-Control: no-cache\r\n\r\n";
|
||||||
|
if writer.write_all(headers).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream chunks to client
|
||||||
|
loop {
|
||||||
|
if stop_flag.load(Ordering::SeqCst) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
match client_rx.recv_timeout(Duration::from_millis(100)) {
|
||||||
|
Ok(chunk) => {
|
||||||
|
if writer.write_all(&chunk).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(mpsc::RecvTimeoutError::Timeout) => continue,
|
||||||
|
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eprintln!("[cast-tap server] Client disconnected");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
self.cast_proc = Some(CastTapProc {
|
self.cast_proc = Some(CastTapProc {
|
||||||
child,
|
child,
|
||||||
writer_join: Some(writer_join),
|
writer_join: Some(writer_join),
|
||||||
|
server_join: Some(server_join),
|
||||||
|
stop_flag: server_stop,
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -645,22 +782,26 @@ impl Pipeline {
|
|||||||
fn stop_cast_tap(&mut self) {
|
fn stop_cast_tap(&mut self) {
|
||||||
*self.cast_tx.lock().unwrap() = None;
|
*self.cast_tx.lock().unwrap() = None;
|
||||||
if let Some(mut proc) = self.cast_proc.take() {
|
if let Some(mut proc) = self.cast_proc.take() {
|
||||||
|
proc.stop_flag.store(true, Ordering::SeqCst);
|
||||||
let _ = proc.child.kill();
|
let _ = proc.child.kill();
|
||||||
let _ = proc.child.wait();
|
let _ = proc.child.wait();
|
||||||
if let Some(j) = proc.writer_join.take() {
|
if let Some(j) = proc.writer_join.take() {
|
||||||
let _ = j.join();
|
let _ = j.join();
|
||||||
}
|
}
|
||||||
|
if let Some(j) = proc.server_join.take() {
|
||||||
|
let _ = j.join();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stop(mut self, shared: &'static PlayerShared) {
|
fn stop(mut self, shared: &std::sync::Arc<PlayerShared>) {
|
||||||
self.stop_flag.store(true, Ordering::SeqCst);
|
self.stop_flag.store(true, Ordering::SeqCst);
|
||||||
self.stop_cast_tap();
|
self.stop_cast_tap();
|
||||||
// dropping stream stops audio
|
// dropping stream stops audio
|
||||||
if let Some(j) = self.decoder_join.take() {
|
if let Some(j) = self.decoder_join.take() {
|
||||||
let _ = j.join();
|
let _ = j.join();
|
||||||
}
|
}
|
||||||
set_status(shared, PlayerStatus::Stopped);
|
set_status(&shared, PlayerStatus::Stopped);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_volume(&self, volume: f32) {
|
fn set_volume(&self, volume: f32) {
|
||||||
@@ -668,7 +809,7 @@ impl Pipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn player_thread(shared: &'static PlayerShared, rx: mpsc::Receiver<PlayerCommand>) {
|
fn player_thread(shared: std::sync::Arc<PlayerShared>, rx: mpsc::Receiver<PlayerCommand>) {
|
||||||
// Step 2: FFmpeg decode + CPAL playback.
|
// Step 2: FFmpeg decode + CPAL playback.
|
||||||
let mut pipeline: Option<Pipeline> = None;
|
let mut pipeline: Option<Pipeline> = None;
|
||||||
let mut pipeline_cast_owned = false;
|
let mut pipeline_cast_owned = false;
|
||||||
@@ -676,7 +817,7 @@ fn player_thread(shared: &'static PlayerShared, rx: mpsc::Receiver<PlayerCommand
|
|||||||
match cmd {
|
match cmd {
|
||||||
PlayerCommand::Play { url } => {
|
PlayerCommand::Play { url } => {
|
||||||
if let Some(p) = pipeline.take() {
|
if let Some(p) = pipeline.take() {
|
||||||
p.stop(shared);
|
p.stop(&shared);
|
||||||
}
|
}
|
||||||
|
|
||||||
pipeline_cast_owned = false;
|
pipeline_cast_owned = false;
|
||||||
@@ -688,7 +829,7 @@ fn player_thread(shared: &'static PlayerShared, rx: mpsc::Receiver<PlayerCommand
|
|||||||
s.status = PlayerStatus::Buffering;
|
s.status = PlayerStatus::Buffering;
|
||||||
}
|
}
|
||||||
|
|
||||||
match Pipeline::start(shared, url, PipelineMode::WithOutput) {
|
match Pipeline::start(std::sync::Arc::clone(&shared), url, PipelineMode::WithOutput) {
|
||||||
Ok(p) => {
|
Ok(p) => {
|
||||||
// Apply current volume to pipeline atomics.
|
// Apply current volume to pipeline atomics.
|
||||||
let vol = { shared.state.lock().unwrap().volume };
|
let vol = { shared.state.lock().unwrap().volume };
|
||||||
@@ -696,14 +837,14 @@ fn player_thread(shared: &'static PlayerShared, rx: mpsc::Receiver<PlayerCommand
|
|||||||
pipeline = Some(p);
|
pipeline = Some(p);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
set_error(shared, e);
|
set_error(&shared, e);
|
||||||
pipeline = None;
|
pipeline = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PlayerCommand::PlayCast { url } => {
|
PlayerCommand::PlayCast { url } => {
|
||||||
if let Some(p) = pipeline.take() {
|
if let Some(p) = pipeline.take() {
|
||||||
p.stop(shared);
|
p.stop(&shared);
|
||||||
}
|
}
|
||||||
|
|
||||||
pipeline_cast_owned = true;
|
pipeline_cast_owned = true;
|
||||||
@@ -715,21 +856,21 @@ fn player_thread(shared: &'static PlayerShared, rx: mpsc::Receiver<PlayerCommand
|
|||||||
s.status = PlayerStatus::Buffering;
|
s.status = PlayerStatus::Buffering;
|
||||||
}
|
}
|
||||||
|
|
||||||
match Pipeline::start(shared, url, PipelineMode::Headless) {
|
match Pipeline::start(std::sync::Arc::clone(&shared), url, PipelineMode::Headless) {
|
||||||
Ok(p) => {
|
Ok(p) => {
|
||||||
let vol = { shared.state.lock().unwrap().volume };
|
let vol = { shared.state.lock().unwrap().volume };
|
||||||
p.set_volume(vol);
|
p.set_volume(vol);
|
||||||
pipeline = Some(p);
|
pipeline = Some(p);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
set_error(shared, e);
|
set_error(&shared, e);
|
||||||
pipeline = None;
|
pipeline = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PlayerCommand::Stop => {
|
PlayerCommand::Stop => {
|
||||||
if let Some(p) = pipeline.take() {
|
if let Some(p) = pipeline.take() {
|
||||||
p.stop(shared);
|
p.stop(&shared);
|
||||||
} else {
|
} else {
|
||||||
let mut s = shared.state.lock().unwrap();
|
let mut s = shared.state.lock().unwrap();
|
||||||
s.status = PlayerStatus::Stopped;
|
s.status = PlayerStatus::Stopped;
|
||||||
@@ -747,10 +888,10 @@ fn player_thread(shared: &'static PlayerShared, rx: mpsc::Receiver<PlayerCommand
|
|||||||
p.set_volume(v);
|
p.set_volume(v);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PlayerCommand::CastTapStart { port, reply } => {
|
PlayerCommand::CastTapStart { port, bind_host, reply } => {
|
||||||
if let Some(p) = pipeline.as_mut() {
|
if let Some(p) = pipeline.as_mut() {
|
||||||
// Current pipeline sample format is always s16le.
|
// Current pipeline sample format is always s16le.
|
||||||
let res = p.start_cast_tap(port, p.sample_rate, p.channels);
|
let res = p.start_cast_tap(port, &bind_host, p.sample_rate, p.channels);
|
||||||
let _ = reply.send(res);
|
let _ = reply.send(res);
|
||||||
} else {
|
} else {
|
||||||
let _ = reply.send(Err("No active decoder pipeline".to_string()));
|
let _ = reply.send(Err("No active decoder pipeline".to_string()));
|
||||||
@@ -762,7 +903,7 @@ fn player_thread(shared: &'static PlayerShared, rx: mpsc::Receiver<PlayerCommand
|
|||||||
}
|
}
|
||||||
if pipeline_cast_owned {
|
if pipeline_cast_owned {
|
||||||
if let Some(p) = pipeline.take() {
|
if let Some(p) = pipeline.take() {
|
||||||
p.stop(shared);
|
p.stop(&shared);
|
||||||
}
|
}
|
||||||
pipeline_cast_owned = false;
|
pipeline_cast_owned = false;
|
||||||
}
|
}
|
||||||
@@ -772,8 +913,8 @@ fn player_thread(shared: &'static PlayerShared, rx: mpsc::Receiver<PlayerCommand
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(p) = pipeline.take() {
|
if let Some(p) = pipeline.take() {
|
||||||
p.stop(shared);
|
p.stop(&shared);
|
||||||
} else {
|
} else {
|
||||||
set_status(shared, PlayerStatus::Stopped);
|
set_status(&shared, PlayerStatus::Stopped);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
"active": true,
|
"active": true,
|
||||||
"targets": "all",
|
"targets": "all",
|
||||||
"externalBin": [
|
"externalBin": [
|
||||||
"binaries/RadioPlayer"
|
"binaries/radiocast-sidecar"
|
||||||
],
|
],
|
||||||
"resources": [
|
"resources": [
|
||||||
"resources/*"
|
"resources/*"
|
||||||
|
|||||||
15
src/main.js
15
src/main.js
@@ -1059,6 +1059,13 @@ function setupEventListeners() {
|
|||||||
refreshCastDeviceList();
|
refreshCastDeviceList();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// Notify UI when a device is removed so the list can update
|
||||||
|
window.__TAURI__.event.listen('cast-device-removed', (event) => {
|
||||||
|
console.log('Cast device removed:', event.payload);
|
||||||
|
if (!castOverlay.classList.contains('hidden')) {
|
||||||
|
refreshCastDeviceList();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Menu button - explicit functionality or placeholder?
|
// Menu button - explicit functionality or placeholder?
|
||||||
@@ -1256,7 +1263,13 @@ async function play() {
|
|||||||
currentCastTransport = 'direct';
|
currentCastTransport = 'direct';
|
||||||
}
|
}
|
||||||
|
|
||||||
await invoke('cast_play', { deviceName: currentCastDevice, url: castUrl });
|
await invoke('cast_play', {
|
||||||
|
deviceName: currentCastDevice,
|
||||||
|
url: castUrl,
|
||||||
|
title: station.title || 'Radio',
|
||||||
|
artist: station.slogan || undefined,
|
||||||
|
image: station.logo || undefined
|
||||||
|
});
|
||||||
isPlaying = true;
|
isPlaying = true;
|
||||||
// Sync volume
|
// Sync volume
|
||||||
const vol = volumeSlider.value / 100;
|
const vol = volumeSlider.value / 100;
|
||||||
|
|||||||
@@ -1,18 +1,37 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
|
||||||
const repoRoot = process.cwd();
|
const repoRoot = process.cwd();
|
||||||
const binariesDir = path.join(repoRoot, 'src-tauri', 'binaries');
|
const binariesDir = path.join(repoRoot, 'src-tauri', 'binaries');
|
||||||
|
|
||||||
// Existing filename and expected name (Windows x86_64 triple)
|
// No rename needed; ensure the sidecar exists.
|
||||||
const existing = 'radiocast-sidecar-x86_64-pc-windows-msvc.exe';
|
const existing = 'radiocast-sidecar-x86_64-pc-windows-msvc.exe';
|
||||||
const expected = 'RadioPlayer-x86_64-pc-windows-msvc.exe';
|
const expected = existing;
|
||||||
|
|
||||||
const src = path.join(binariesDir, existing);
|
const src = path.join(binariesDir, existing);
|
||||||
const dst = path.join(binariesDir, expected);
|
const dst = path.join(binariesDir, expected);
|
||||||
|
|
||||||
|
// On Windows the running sidecar process can lock the binary and prevent rebuilds.
|
||||||
|
// Try to kill any leftover sidecar processes before proceeding. This is best-effort
|
||||||
|
// and will silently continue if no process is found or the kill fails.
|
||||||
|
function tryKillSidecar() {
|
||||||
|
if (process.platform !== 'win32') return;
|
||||||
|
const candidates = ['radiocast-sidecar.exe', 'radiocast-sidecar-x86_64-pc-windows-msvc.exe', 'radiocast-sidecar'];
|
||||||
|
for (const name of candidates) {
|
||||||
|
try {
|
||||||
|
execSync(`taskkill /IM ${name} /F`, { stdio: 'ignore' });
|
||||||
|
console.log(`Killed leftover sidecar process: ${name}`);
|
||||||
|
} catch (e) {
|
||||||
|
// ignore errors; likely means the process wasn't running
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
tryKillSidecar();
|
||||||
|
|
||||||
if (!fs.existsSync(binariesDir)) {
|
if (!fs.existsSync(binariesDir)) {
|
||||||
console.warn('binaries directory not found, skipping copy');
|
console.warn('binaries directory not found, skipping copy');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
@@ -23,14 +42,8 @@ try {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fs.existsSync(dst)) {
|
console.log(`Sidecar binary present: ${dst}`);
|
||||||
console.log(`Expected binary already present: ${dst}`);
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.copyFileSync(src, dst);
|
|
||||||
console.log(`Copied ${existing} -> ${expected}`);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to copy binary:', e);
|
console.error('Failed to prepare binary:', e);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user