10 Commits

Author SHA1 Message Date
dac2b0e8dc cast info 2026-01-15 07:07:00 +01:00
0541b0b776 fix 2026-01-14 19:31:39 +01:00
6dd2025d3d radio details 2026-01-14 19:19:20 +01:00
7176cc8f4b fixed player in cast 2026-01-14 18:47:28 +01:00
83c9bcf12e cast info 2026-01-14 18:42:16 +01:00
ed2e660d34 fixed cast play 2026-01-14 17:55:18 +01:00
efdba35b77 chore: refactor async device discovery, tracing, and player Arc state 2026-01-13 17:15:59 +01:00
ab3a86041a feat: async mDNS discovery; emit device events; auto-build sidecar in npm dev 2026-01-13 16:17:53 +01:00
91e55fa37c fixed package 2026-01-13 13:32:51 +01:00
bbb767cd20 build fix 2026-01-13 13:18:46 +01:00
14 changed files with 1426 additions and 220 deletions

18
cast-receiver/README.md Normal file
View File

@@ -0,0 +1,18 @@
# Radio Player - Custom Cast Receiver
This folder contains a minimal Google Cast Web Receiver that displays a purple gradient background, station artwork, title and subtitle. It accepts `customData` hints sent from the sender (your app) for `backgroundImage`, `backgroundGradient` and `appName`.
Hosting requirements
- The receiver must be served over HTTPS and be publicly accessible.
- Recommended: host under GitHub Pages (`gh-pages` branch or `/docs` folder) or any static host (Netlify, Vercel, S3 + CloudFront).
Registering with Google Cast Console
1. Go to the Cast SDK Developer Console and create a new Application.
2. Choose "Custom Receiver" and provide the public HTTPS URL to `index.html` (e.g. `https://example.com/cast-receiver/index.html`).
3. Note the generated Application ID.
Sender changes
- After obtaining the Application ID, update your sender (sidecar) to launch that app ID instead of the DefaultMediaReceiver. The sidecar already supports passing `metadata.appId` when launching.
Testing locally
- You can serve this folder locally during development, but Chromecast devices require public HTTPS endpoints to use a registered app.

23
cast-receiver/index.html Normal file
View File

@@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Radio Player Receiver</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div id="bg" class="bg"></div>
<div id="app" class="app">
<div class="artwork"><img id="art" alt="Artwork"></div>
<div class="meta">
<div id="appName" class="app-name">Radio Player</div>
<h1 id="title">Radio Player</h1>
<h2 id="subtitle"></h2>
</div>
</div>
<script src="https://www.gstatic.com/cast/sdk/libs/caf_receiver/v3/cast_receiver_framework.js"></script>
<script src="receiver.js"></script>
</body>
</html>

50
cast-receiver/receiver.js Normal file
View File

@@ -0,0 +1,50 @@
// Minimal CAF receiver that applies customData theming and shows media metadata.
const context = cast.framework.CastReceiverContext.getInstance();
const playerManager = context.getPlayerManager();
function applyBranding(customData, metadata) {
try {
const bgEl = document.getElementById('bg');
const art = document.getElementById('art');
const title = document.getElementById('title');
const subtitle = document.getElementById('subtitle');
const appName = document.getElementById('appName');
if (customData) {
if (customData.backgroundImage) {
bgEl.style.backgroundImage = `url(${customData.backgroundImage})`;
bgEl.style.backgroundSize = 'cover';
bgEl.style.backgroundPosition = 'center';
} else if (customData.backgroundGradient) {
bgEl.style.background = customData.backgroundGradient;
}
if (customData.appName) appName.textContent = customData.appName;
}
if (metadata) {
if (metadata.title) title.textContent = metadata.title;
const sub = metadata.subtitle || metadata.artist || '';
subtitle.textContent = sub;
if (metadata.images && metadata.images.length) {
art.src = metadata.images[0].url || '';
}
}
} catch (e) {
// swallow UI errors
console.warn('Branding apply failed', e);
}
}
playerManager.setMessageInterceptor(cast.framework.messages.MessageType.LOAD, (request) => {
const media = request.media || {};
const customData = media.customData || {};
applyBranding(customData, media.metadata || {});
return request;
});
playerManager.addEventListener(cast.framework.events.EventType.MEDIA_STATUS, () => {
const media = playerManager.getMediaInformation();
if (media) applyBranding(media.customData || {}, media.metadata || {});
});
context.start();

11
cast-receiver/styles.css Normal file
View File

@@ -0,0 +1,11 @@
:root{--primary:#6a0dad;--accent:#b36cf3}
html,body{height:100%;margin:0;font-family:Inter,system-ui,Arial,Helvetica,sans-serif}
body{background:linear-gradient(135deg,var(--primary),var(--accent));color:#fff}
.bg{position:fixed;inset:0;background-size:cover;background-position:center;filter:blur(10px) saturate(120%);opacity:0.9}
.app{position:relative;z-index:2;display:flex;align-items:center;gap:24px;padding:48px}
.artwork{width:320px;height:320px;flex:0 0 320px;background:rgba(255,255,255,0.06);display:flex;align-items:center;justify-content:center;border-radius:8px;overflow:hidden}
.artwork img{width:100%;height:100%;object-fit:cover}
.meta{display:flex;flex-direction:column}
.app-name{font-weight:600;opacity:0.9}
h1{margin:6px 0 0 0;font-size:28px}
h2{margin:6px 0 0 0;font-size:18px;opacity:0.9}

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "radio-tauri", "name": "radio-tauri",
"version": "0.1.0", "version": "0.1.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "radio-tauri", "name": "radio-tauri",
"version": "0.1.0", "version": "0.1.1",
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",

View File

@@ -4,7 +4,8 @@
"version": "0.1.1", "version": "0.1.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "tauri dev", "build:sidecar": "npm --prefix sidecar install && npm --prefix sidecar run build",
"dev": "npm run build:sidecar && node tools/copy-binaries.js && node tools/copy-ffmpeg.js && tauri dev",
"dev:native": "node tools/copy-binaries.js && node tools/copy-ffmpeg.js && tauri dev", "dev:native": "node tools/copy-binaries.js && node tools/copy-ffmpeg.js && tauri dev",
"ffmpeg:download": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/download-ffmpeg.ps1", "ffmpeg:download": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/download-ffmpeg.ps1",
"version:sync": "node tools/sync-version.js", "version:sync": "node tools/sync-version.js",

View File

@@ -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,16 @@ rl.on('line', (line) => {
} }
}); });
function play(ip, url) { function play(ip, url, metadata) {
if (activeClient) { if (activeClient) {
try { activeClient.removeAllListeners(); } catch (e) { }
try { activeClient.close(); } catch (e) { } try { activeClient.close(); } catch (e) { }
} }
activeClient = new Client(); activeClient = new Client();
// Increase max listeners for this client instance to avoid Node warnings
try { if (typeof activeClient.setMaxListeners === 'function') activeClient.setMaxListeners(50); } catch (e) {}
activeClient._playMetadata = metadata || {};
activeClient.connect(ip, () => { activeClient.connect(ip, () => {
log(`Connected to ${ip}`); log(`Connected to ${ip}`);
@@ -100,20 +110,21 @@ 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 {
// Clean up previous player listeners before replacing
try { if (activePlayer && typeof activePlayer.removeAllListeners === 'function') activePlayer.removeAllListeners(); } catch (e) {}
activePlayer = player; activePlayer = player;
loadMedia(url); try { if (typeof activePlayer.setMaxListeners === 'function') activePlayer.setMaxListeners(50); } catch (e) {}
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,10 +137,11 @@ function play(ip, url) {
}); });
} }
function launchPlayer(url, didStopFirst) { function launchPlayer(url, metadata, didStopFirst) {
if (!activeClient) return; if (!activeClient) return;
activeClient.launch(DefaultMediaReceiver, (err, player) => { const launchApp = (metadata && metadata.appId) ? metadata.appId : DefaultMediaReceiver;
activeClient.launch(launchApp, (err, player) => {
if (err) { if (err) {
const details = `Launch error: ${err && err.message ? err.message : String(err)}${err && err.code ? ` (code: ${err.code})` : ''}`; const details = `Launch error: ${err && err.message ? err.message : String(err)}${err && err.code ? ` (code: ${err.code})` : ''}`;
// If launch fails with NOT_ALLOWED, the device may be busy with another app/session. // If launch fails with NOT_ALLOWED, the device may be busy with another app/session.
@@ -149,8 +161,10 @@ function launchPlayer(url, didStopFirst) {
try { error(`Launch retry error full: ${JSON.stringify(retryErr)}`); } catch (e) { /* ignore */ } try { error(`Launch retry error full: ${JSON.stringify(retryErr)}`); } catch (e) { /* ignore */ }
return; return;
} }
try { if (activePlayer && typeof activePlayer.removeAllListeners === 'function') activePlayer.removeAllListeners(); } catch (e) {}
activePlayer = retryPlayer; activePlayer = retryPlayer;
loadMedia(url); try { if (typeof activePlayer.setMaxListeners === 'function') activePlayer.setMaxListeners(50); } catch (e) {}
loadMedia(url, metadata);
}); });
}); });
}); });
@@ -161,24 +175,52 @@ function launchPlayer(url, didStopFirst) {
try { error(`Launch error full: ${JSON.stringify(err)}`); } catch (e) { /* ignore */ } try { error(`Launch error full: ${JSON.stringify(err)}`); } catch (e) { /* ignore */ }
return; return;
} }
try { if (activePlayer && typeof activePlayer.removeAllListeners === 'function') activePlayer.removeAllListeners(); } catch (e) {}
activePlayer = player; activePlayer = player;
loadMedia(url); try { if (typeof activePlayer.setMaxListeners === 'function') activePlayer.setMaxListeners(50); } catch (e) {}
loadMedia(url, metadata);
}); });
} }
function loadMedia(url) { function loadMedia(url, metadata) {
if (!activePlayer) return; if (!activePlayer) return;
const meta = metadata || {};
// Build a richer metadata payload. Many receivers only honor specific
// fields; we set both Music metadata and generic hints via `customData`.
const media = { const media = {
contentId: url, contentId: url,
contentType: 'audio/mpeg', contentType: 'audio/mpeg',
streamType: 'LIVE', streamType: 'LIVE',
metadata: { metadata: {
metadataType: 0, // Use MusicTrack metadata (common on audio receivers) but include
title: 'RadioPlayer' // a subtitle field in case receivers surface it.
metadataType: 3, // MusicTrackMediaMetadata
title: meta.title || 'Radio Station',
albumName: 'Radio Player',
artist: meta.artist || meta.subtitle || meta.station || '',
subtitle: meta.subtitle || '',
images: (meta.image ? [
{ url: meta.image },
// also include a large hint for receivers that prefer big artwork
{ url: meta.image, width: 1920, height: 1080 }
] : [])
},
// Many receivers ignore `customData`, but some Styled receivers will
// use it. Include background and theming hints here.
customData: {
appName: meta.appName || 'Radio Player',
backgroundImage: meta.backgroundImage || meta.image || undefined,
backgroundGradient: meta.bgGradient || '#6a0dad',
themeHint: {
primary: '#6a0dad',
accent: '#b36cf3'
}
} }
}; };
// Ensure we don't accumulate 'status' listeners across loads
try { if (activePlayer && typeof activePlayer.removeAllListeners === 'function') activePlayer.removeAllListeners('status'); } catch (e) {}
activePlayer.load(media, { autoplay: true }, (err, status) => { activePlayer.load(media, { autoplay: true }, (err, status) => {
if (err) return error(`Load error: ${err.message}`); if (err) return error(`Load error: ${err.message}`);
log('Media loaded, playing...'); log('Media loaded, playing...');
@@ -192,9 +234,11 @@ function loadMedia(url) {
function stop() { function stop() {
if (activePlayer) { if (activePlayer) {
try { activePlayer.stop(); } catch (e) { } try { activePlayer.stop(); } catch (e) { }
try { if (typeof activePlayer.removeAllListeners === 'function') activePlayer.removeAllListeners(); } catch (e) {}
log('Stopped playback'); log('Stopped playback');
} }
if (activeClient) { if (activeClient) {
try { if (typeof activeClient.removeAllListeners === 'function') activeClient.removeAllListeners(); } catch (e) {}
try { activeClient.close(); } catch (e) { } try { activeClient.close(); } catch (e) { }
activeClient = null; activeClient = null;
activePlayer = null; activePlayer = null;

720
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ version = "0.1.1"
description = "A Tauri App" description = "A Tauri App"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
default-run = "radio-tauri"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -24,10 +25,17 @@ 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"], optional = true }
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"
reqwest = { version = "0.11", features = ["json", "rustls-tls"] } 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"]

View File

@@ -2,25 +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(windows)] #[cfg(not(feature = "use_agnostic_mdns"))]
use std::os::windows::process::CommandExt;
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x08000000;
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 {
@@ -28,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 {
@@ -49,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,
} }
@@ -85,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");
} }
} }
@@ -111,12 +117,7 @@ fn spawn_standalone_cast_proxy(url: String, port: u16) -> Result<Child, String>
let ffmpeg_disp = ffmpeg.to_string_lossy(); let ffmpeg_disp = ffmpeg.to_string_lossy();
let spawn = |codec: &str| -> Result<Child, String> { let spawn = |codec: &str| -> Result<Child, String> {
let mut cmd = Command::new(&ffmpeg); Command::new(&ffmpeg)
#[cfg(windows)]
{
cmd.creation_flags(CREATE_NO_WINDOW);
}
cmd
.arg("-nostdin") .arg("-nostdin")
.arg("-hide_banner") .arg("-hide_banner")
.arg("-loglevel") .arg("-loglevel")
@@ -155,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")?;
} }
} }
@@ -175,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
@@ -186,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();
@@ -213,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]
@@ -349,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)
@@ -362,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();
@@ -377,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))
} }
_ => {} _ => {}
} }
@@ -404,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(())
} }
@@ -542,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),
@@ -551,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()))
@@ -579,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}));
} }
} }
}); });

View File

@@ -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,12 +564,13 @@ 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)
@@ -587,13 +593,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,7 +619,23 @@ 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 (tx, rx) = mpsc::sync_channel::<Vec<u8>>(256); 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>>(1024);
*self.cast_tx.lock().unwrap() = Some(tx); *self.cast_tx.lock().unwrap() = Some(tx);
let writer_join = std::thread::spawn(move || { let writer_join = std::thread::spawn(move || {
@@ -634,9 +652,144 @@ 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; 16384];
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>>(1024);
// 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\nAccept-Ranges: none\r\nicy-br: 128\r\n\r\n";
if writer.write_all(headers).is_err() {
return;
}
// Pre-buffer before streaming to prevent initial stuttering
let mut prebuffer = Vec::with_capacity(65536);
let prebuffer_start = std::time::Instant::now();
while prebuffer.len() < 32768 && prebuffer_start.elapsed() < Duration::from_millis(500) {
match client_rx.recv_timeout(Duration::from_millis(50)) {
Ok(chunk) => prebuffer.extend_from_slice(&chunk),
_ => break,
}
}
// Send prebuffered data
if !prebuffer.is_empty() {
if writer.write_all(&prebuffer).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 +798,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 +825,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 +833,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 +845,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 +853,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 +872,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 +904,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 +919,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 +929,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);
} }
} }

View File

@@ -26,7 +26,7 @@
"active": true, "active": true,
"targets": "all", "targets": "all",
"externalBin": [ "externalBin": [
"binaries/RadioPlayer" "binaries/radiocast-sidecar"
], ],
"resources": [ "resources": [
"resources/*" "resources/*"

View File

@@ -1050,6 +1050,24 @@ function setupEventListeners() {
await appWindow.close(); await appWindow.close();
}); });
// Listen for cast device discovery events from backend
if (runningInTauri && window.__TAURI__ && window.__TAURI__.event) {
window.__TAURI__.event.listen('cast-device-discovered', (event) => {
console.log('Cast device discovered:', event.payload);
// If cast overlay is currently open, refresh the device list
if (!castOverlay.classList.contains('hidden')) {
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?
// Menu removed — header click opens stations via artwork placeholder // Menu removed — header click opens stations via artwork placeholder
@@ -1245,7 +1263,17 @@ 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,
// Additional metadata hints for receivers
subtitle: station.slogan || station.name,
backgroundImage: station.background || station.logo || undefined,
bgGradient: station.bgGradient || 'linear-gradient(135deg,#5b2d91,#b36cf3)'
});
isPlaying = true; isPlaying = true;
// Sync volume // Sync volume
const vol = volumeSlider.value / 100; const vol = volumeSlider.value / 100;
@@ -1353,6 +1381,10 @@ async function openCastOverlay() {
castOverlay.setAttribute('aria-hidden', 'false'); castOverlay.setAttribute('aria-hidden', 'false');
// ensure cast overlay shows linear list style // ensure cast overlay shows linear list style
deviceListEl.classList.remove('stations-grid'); deviceListEl.classList.remove('stations-grid');
await refreshCastDeviceList();
}
async function refreshCastDeviceList() {
deviceListEl.innerHTML = '<li class="device"><div class="device-main">Scanning...</div><div class="device-sub">Searching for speakers</div></li>'; deviceListEl.innerHTML = '<li class="device"><div class="device-main">Scanning...</div><div class="device-sub">Searching for speakers</div></li>';
try { try {

View File

@@ -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);
} }