tools: add sync-version.js to sync package.json -> Tauri files

- Add tools/sync-version.js script to read root package.json version
  and update src-tauri/tauri.conf.json and src-tauri/Cargo.toml.
- Update only the [package] version line in Cargo.toml to preserve formatting.
- Include JSON read/write helpers and basic error handling/reporting.
This commit is contained in:
2026-01-13 07:21:51 +01:00
parent abb7cafaed
commit 694f335408
50 changed files with 1128 additions and 6186 deletions

2
src-tauri/Cargo.lock generated
View File

@@ -3282,7 +3282,7 @@ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "radio-tauri"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"base64 0.22.1",
"cpal",

View File

@@ -1,6 +1,6 @@
[package]
name = "radio-tauri"
version = "0.1.0"
version = "0.1.1"
description = "A Tauri App"
authors = ["you"]
edition = "2021"

View File

@@ -1,6 +1,16 @@
use std::collections::HashMap;
use std::io::{BufRead, BufReader};
use std::net::{IpAddr, SocketAddr, TcpListener, TcpStream, UdpSocket};
use std::process::{Child, Command, Stdio};
use std::sync::Mutex;
use std::thread;
use std::time::Duration;
#[cfg(windows)]
use std::os::windows::process::CommandExt;
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x08000000;
use mdns_sd::{ServiceDaemon, ServiceEvent};
use serde_json::json;
@@ -21,6 +31,21 @@ struct AppState {
known_devices: Mutex<HashMap<String, String>>,
}
struct CastProxy {
child: Child,
}
struct CastProxyState {
inner: Mutex<Option<CastProxy>>,
}
#[derive(serde::Serialize)]
struct CastProxyStartResult {
url: String,
// "tap" | "proxy"
mode: String,
}
// Native (non-WebView) audio player state.
// Step 1: state machine + command interface only (no decoding/output yet).
struct PlayerRuntime {
@@ -40,6 +65,221 @@ fn clamp01(v: f32) -> f32 {
}
}
fn format_http_host(ip: IpAddr) -> String {
match ip {
IpAddr::V4(v4) => v4.to_string(),
IpAddr::V6(v6) => format!("[{v6}]"),
}
}
fn local_ip_for_peer(peer_ip: IpAddr) -> Result<IpAddr, String> {
// Trick: connect a UDP socket to the peer and read the chosen local address.
// Port number is irrelevant; no packets are sent for UDP connect().
let peer = SocketAddr::new(peer_ip, 9);
let bind_addr = match peer_ip {
IpAddr::V4(_) => "0.0.0.0:0",
IpAddr::V6(_) => "[::]:0",
};
let sock = UdpSocket::bind(bind_addr).map_err(|e| e.to_string())?;
sock.connect(peer).map_err(|e| e.to_string())?;
Ok(sock.local_addr().map_err(|e| e.to_string())?.ip())
}
fn wait_for_listen(ip: IpAddr, port: u16) {
// Best-effort: give ffmpeg a moment to bind before we tell the Chromecast.
let addr = SocketAddr::new(ip, port);
for _ in 0..50 {
if TcpStream::connect_timeout(&addr, Duration::from_millis(30)).is_ok() {
return;
}
std::thread::sleep(Duration::from_millis(20));
}
}
fn stop_cast_proxy_locked(lock: &mut Option<CastProxy>) {
if let Some(mut proxy) = lock.take() {
let _ = proxy.child.kill();
let _ = proxy.child.wait();
println!("Cast proxy stopped");
}
}
fn spawn_standalone_cast_proxy(url: String, port: u16) -> Result<Child, String> {
// Standalone path (fallback): FFmpeg pulls the station URL and serves MP3 over HTTP.
// Try libmp3lame first, then fall back to the built-in "mp3" encoder if needed.
let ffmpeg = player::ffmpeg_command();
let ffmpeg_disp = ffmpeg.to_string_lossy();
let spawn = |codec: &str| -> Result<Child, String> {
let mut cmd = Command::new(&ffmpeg);
#[cfg(windows)]
{
cmd.creation_flags(CREATE_NO_WINDOW);
}
cmd
.arg("-nostdin")
.arg("-hide_banner")
.arg("-loglevel")
.arg("warning")
.arg("-reconnect")
.arg("1")
.arg("-reconnect_streamed")
.arg("1")
.arg("-reconnect_delay_max")
.arg("5")
.arg("-i")
.arg(&url)
.arg("-vn")
.arg("-c:a")
.arg(codec)
.arg("-b:a")
.arg("128k")
.arg("-f")
.arg("mp3")
.arg("-content_type")
.arg("audio/mpeg")
.arg("-listen")
.arg("1")
.arg(format!("http://0.0.0.0:{port}/stream.mp3"))
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| {
format!(
"Failed to start ffmpeg cast proxy ({ffmpeg_disp}): {e}. Set RADIOPLAYER_FFMPEG, bundle ffmpeg next to the app, or install ffmpeg on PATH."
)
})
};
let mut child = spawn("libmp3lame")?;
std::thread::sleep(Duration::from_millis(150));
if let Ok(Some(status)) = child.try_wait() {
if !status.success() {
eprintln!("Standalone cast proxy exited early; retrying with -c:a mp3");
child = spawn("mp3")?;
}
}
Ok(child)
}
#[tauri::command]
async fn cast_proxy_start(
state: State<'_, AppState>,
proxy_state: State<'_, CastProxyState>,
player: State<'_, PlayerRuntime>,
device_name: String,
url: String,
) -> Result<CastProxyStartResult, String> {
// Make sure ffmpeg exists before we try to cast.
player::preflight_ffmpeg_only()?;
let device_ip_str = {
let devices = state.known_devices.lock().unwrap();
devices
.get(&device_name)
.cloned()
.ok_or("Device not found")?
};
let device_ip: IpAddr = device_ip_str
.parse()
.map_err(|_| format!("Invalid device IP: {device_ip_str}"))?;
let local_ip = local_ip_for_peer(device_ip)?;
// Pick an ephemeral port.
let listener = TcpListener::bind("0.0.0.0:0").map_err(|e| e.to_string())?;
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
drop(listener);
let host = format_http_host(local_ip);
let proxy_url = format!("http://{host}:{port}/stream.mp3");
// Stop any existing standalone proxy first.
{
let mut lock = proxy_state.inner.lock().unwrap();
stop_cast_proxy_locked(&mut lock);
}
// Prefer reusing the native decoder PCM when possible.
// If the currently playing URL differs (or nothing is playing), start a headless decoder.
let snapshot = player.shared.snapshot();
let is_same_url = snapshot.url.as_deref() == Some(url.as_str());
let is_decoding = matches!(snapshot.status, player::PlayerStatus::Playing | player::PlayerStatus::Buffering);
if !(is_same_url && is_decoding) {
player
.controller
.tx
.send(PlayerCommand::PlayCast { url: url.clone() })
.map_err(|e| e.to_string())?;
}
let (reply_tx, reply_rx) = std::sync::mpsc::channel();
let _ = player
.controller
.tx
.send(PlayerCommand::CastTapStart {
port,
reply: reply_tx,
})
.map_err(|e| e.to_string())?;
match reply_rx.recv_timeout(Duration::from_secs(2)) {
Ok(Ok(())) => {
wait_for_listen(local_ip, port);
Ok(CastProxyStartResult {
url: proxy_url,
mode: "tap".to_string(),
})
}
Ok(Err(e)) => {
eprintln!("Cast tap start failed; falling back to standalone proxy: {e}");
let mut child = spawn_standalone_cast_proxy(url, port)?;
if let Some(stderr) = child.stderr.take() {
std::thread::spawn(move || {
let reader = BufReader::new(stderr);
for line in reader.lines().flatten() {
eprintln!("[cast-proxy ffmpeg] {line}");
}
});
}
wait_for_listen(local_ip, port);
let mut lock = proxy_state.inner.lock().unwrap();
*lock = Some(CastProxy { child });
Ok(CastProxyStartResult {
url: proxy_url,
mode: "proxy".to_string(),
})
}
Err(_) => {
eprintln!("Cast tap start timed out; falling back to standalone proxy");
let mut child = spawn_standalone_cast_proxy(url, port)?;
if let Some(stderr) = child.stderr.take() {
std::thread::spawn(move || {
let reader = BufReader::new(stderr);
for line in reader.lines().flatten() {
eprintln!("[cast-proxy ffmpeg] {line}");
}
});
}
wait_for_listen(local_ip, port);
let mut lock = proxy_state.inner.lock().unwrap();
*lock = Some(CastProxy { child });
Ok(CastProxyStartResult {
url: proxy_url,
mode: "proxy".to_string(),
})
}
}
}
#[tauri::command]
async fn cast_proxy_stop(proxy_state: State<'_, CastProxyState>, player: State<'_, PlayerRuntime>) -> Result<(), String> {
let _ = player.controller.tx.send(PlayerCommand::CastTapStop);
let mut lock = proxy_state.inner.lock().unwrap();
stop_cast_proxy_locked(&mut lock);
Ok(())
}
#[tauri::command]
async fn player_get_state(player: State<'_, PlayerRuntime>) -> Result<PlayerState, String> {
Ok(player.shared.snapshot())
@@ -177,8 +417,18 @@ async fn cast_play(
async fn cast_stop(
_app: AppHandle,
sidecar_state: State<'_, SidecarState>,
proxy_state: State<'_, CastProxyState>,
player: State<'_, PlayerRuntime>,
_device_name: String,
) -> Result<(), String> {
{
let mut lock = proxy_state.inner.lock().unwrap();
stop_cast_proxy_locked(&mut lock);
}
// Safety net: stop any active tap too.
let _ = player.controller.tx.send(PlayerCommand::CastTapStop);
let mut lock = sidecar_state.child.lock().unwrap();
if let Some(ref mut child) = *lock {
let stop_cmd = json!({ "command": "stop", "args": {} });
@@ -282,6 +532,12 @@ pub fn run() {
if matches!(event, tauri::WindowEvent::CloseRequested { .. }) {
let player = window.app_handle().state::<PlayerRuntime>();
let _ = player.controller.tx.send(PlayerCommand::Shutdown);
// Also stop any active cast tap/proxy so we don't leave processes behind.
let _ = player.controller.tx.send(PlayerCommand::CastTapStop);
let proxy_state = window.app_handle().state::<CastProxyState>();
let mut lock = proxy_state.inner.lock().unwrap();
stop_cast_proxy_locked(&mut lock);
}
})
.setup(|app| {
@@ -291,6 +547,9 @@ pub fn run() {
app.manage(SidecarState {
child: Mutex::new(None),
});
app.manage(CastProxyState {
inner: Mutex::new(None),
});
// Player scaffolding: leak shared state to get a 'static reference for the
// long-running thread without complex lifetime plumbing.
@@ -342,6 +601,8 @@ pub fn run() {
cast_play,
cast_stop,
cast_set_volume,
cast_proxy_start,
cast_proxy_stop,
// allow frontend to request arbitrary URLs via backend (bypass CORS)
fetch_url,
// fetch remote images via backend (data: URL), helps with mixed-content

View File

@@ -11,6 +11,21 @@ use std::time::Duration;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use ringbuf::HeapRb;
#[cfg(windows)]
use std::os::windows::process::CommandExt;
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x08000000;
fn command_hidden(program: &OsString) -> Command {
let mut cmd = Command::new(program);
#[cfg(windows)]
{
cmd.creation_flags(CREATE_NO_WINDOW);
}
cmd
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum PlayerStatus {
@@ -53,8 +68,16 @@ impl PlayerShared {
#[derive(Debug)]
pub enum PlayerCommand {
Play { url: String },
// Cast-only playback: decode to PCM and keep it available for cast taps,
// but do not open a CPAL output stream.
PlayCast { url: String },
Stop,
SetVolume { volume: f32 },
CastTapStart {
port: u16,
reply: mpsc::Sender<Result<(), String>>,
},
CastTapStop,
Shutdown,
}
@@ -103,7 +126,7 @@ fn set_error(shared: &'static PlayerShared, message: String) {
s.error = Some(message);
}
fn ffmpeg_command() -> OsString {
pub(crate) fn ffmpeg_command() -> OsString {
// Step 2: external ffmpeg binary.
// Lookup order:
// 1) RADIOPLAYER_FFMPEG (absolute or relative)
@@ -139,19 +162,9 @@ fn ffmpeg_command() -> OsString {
OsString::from(local_name)
}
pub fn preflight_check() -> Result<(), String> {
// Ensure we have an output device up-front so UI gets a synchronous error.
let host = cpal::default_host();
let device = host
.default_output_device()
.ok_or_else(|| "No default audio output device".to_string())?;
let _ = device
.default_output_config()
.map_err(|e| format!("Failed to get output config: {e}"))?;
// Ensure ffmpeg can be executed.
pub fn preflight_ffmpeg_only() -> Result<(), String> {
let ffmpeg = ffmpeg_command();
let status = Command::new(&ffmpeg)
let status = command_hidden(&ffmpeg)
.arg("-version")
.stdout(Stdio::null())
.stderr(Stdio::null())
@@ -165,38 +178,82 @@ pub fn preflight_check() -> Result<(), String> {
if !status.success() {
return Err("FFmpeg exists but returned non-zero for -version".to_string());
}
Ok(())
}
pub fn preflight_check() -> Result<(), String> {
// Ensure we have an output device up-front so UI gets a synchronous error.
let host = cpal::default_host();
let device = host
.default_output_device()
.ok_or_else(|| "No default audio output device".to_string())?;
let _ = device
.default_output_config()
.map_err(|e| format!("Failed to get output config: {e}"))?;
preflight_ffmpeg_only()?;
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PipelineMode {
WithOutput,
Headless,
}
struct CastTapProc {
child: std::process::Child,
writer_join: Option<std::thread::JoinHandle<()>>,
}
struct Pipeline {
stop_flag: Arc<AtomicBool>,
volume_bits: Arc<AtomicU32>,
_stream: cpal::Stream,
_stream: Option<cpal::Stream>,
decoder_join: Option<std::thread::JoinHandle<()>>,
cast_tx: Arc<Mutex<Option<mpsc::SyncSender<Vec<u8>>>>>,
cast_proc: Option<CastTapProc>,
sample_rate: u32,
channels: u16,
}
impl Pipeline {
fn start(shared: &'static PlayerShared, url: String) -> Result<Self, String> {
let host = cpal::default_host();
let device = host
.default_output_device()
.ok_or_else(|| "No default audio output device".to_string())?;
let default_cfg = device
.default_output_config()
.map_err(|e| format!("Failed to get output config: {e}"))?;
let sample_format = default_cfg.sample_format();
let cfg = default_cfg.config();
let sample_rate = cfg.sample_rate.0;
let channels = cfg.channels as u16;
fn start(shared: &'static PlayerShared, url: String, mode: PipelineMode) -> Result<Self, String> {
let (device, sample_format, cfg, sample_rate, channels) = match mode {
PipelineMode::WithOutput => {
let host = cpal::default_host();
let device = host
.default_output_device()
.ok_or_else(|| "No default audio output device".to_string())?;
let default_cfg = device
.default_output_config()
.map_err(|e| format!("Failed to get output config: {e}"))?;
let sample_format = default_cfg.sample_format();
let cfg = default_cfg.config();
let sample_rate = cfg.sample_rate.0;
let channels = cfg.channels as u16;
(Some(device), Some(sample_format), Some(cfg), sample_rate, channels)
}
PipelineMode::Headless => {
// For cast-only, pick a sane, widely-supported PCM format.
// This does not depend on an audio device.
(None, None, None, 48_000u32, 2u16)
}
};
// 5 seconds of PCM buffering (i16 samples)
let capacity_samples = (sample_rate as usize)
.saturating_mul(cfg.channels as usize)
.saturating_mul(5);
let rb = HeapRb::<i16>::new(capacity_samples);
let (mut prod, mut cons) = rb.split();
let (mut prod_opt, mut cons_opt) = if mode == PipelineMode::WithOutput {
let cfg = cfg.as_ref().expect("cfg must exist for WithOutput");
let capacity_samples = (sample_rate as usize)
.saturating_mul(cfg.channels as usize)
.saturating_mul(5);
let rb = HeapRb::<i16>::new(capacity_samples);
let (prod, cons) = rb.split();
(Some(prod), Some(cons))
} else {
(None, None)
};
let stop_flag = Arc::new(AtomicBool::new(false));
let volume_bits = Arc::new(AtomicU32::new({
@@ -204,15 +261,18 @@ impl Pipeline {
volume_to_bits(s.volume)
}));
let cast_tx: Arc<Mutex<Option<mpsc::SyncSender<Vec<u8>>>>> = Arc::new(Mutex::new(None));
// Decoder thread: spawns ffmpeg, reads PCM, writes into ring buffer.
let stop_for_decoder = Arc::clone(&stop_flag);
let shared_for_decoder = shared;
let decoder_url = url.clone();
let cast_tx_for_decoder = Arc::clone(&cast_tx);
let decoder_join = std::thread::spawn(move || {
let mut backoff_ms: u64 = 250;
let mut pushed_since_start: usize = 0;
let playing_threshold_samples = (sample_rate as usize)
.saturating_mul(cfg.channels as usize)
.saturating_mul(channels as usize)
.saturating_div(4); // ~250ms
'outer: loop {
@@ -224,7 +284,7 @@ impl Pipeline {
let ffmpeg = ffmpeg_command();
let ffmpeg_disp = ffmpeg.to_string_lossy();
let mut child = match Command::new(&ffmpeg)
let mut child = match command_hidden(&ffmpeg)
.arg("-nostdin")
.arg("-hide_banner")
.arg("-loglevel")
@@ -303,13 +363,21 @@ impl Pipeline {
backoff_ms = 250;
// Forward raw PCM bytes to cast tap (if enabled).
if let Some(tx) = cast_tx_for_decoder.lock().unwrap().as_ref() {
// Best-effort: never block local playback.
let _ = tx.try_send(buf[..n].to_vec());
}
// Convert bytes to i16 LE samples
let mut i = 0usize;
if let Some(b0) = leftover.take() {
if n >= 1 {
let b1 = buf[0];
let sample = i16::from_le_bytes([b0, b1]);
let _ = prod.push(sample);
if let Some(prod) = prod_opt.as_mut() {
let _ = prod.push(sample);
}
pushed_since_start += 1;
i = 1;
} else {
@@ -319,9 +387,10 @@ impl Pipeline {
while i + 1 < n {
let sample = i16::from_le_bytes([buf[i], buf[i + 1]]);
if prod.push(sample).is_ok() {
pushed_since_start += 1;
if let Some(prod) = prod_opt.as_mut() {
let _ = prod.push(sample);
}
pushed_since_start += 1;
i += 2;
}
@@ -337,146 +406,256 @@ impl Pipeline {
}
});
// Audio callback: drain ring buffer and write to output.
let shared_for_cb = shared;
let stop_for_cb = Arc::clone(&stop_flag);
let volume_for_cb = Arc::clone(&volume_bits);
let stream = if mode == PipelineMode::WithOutput {
let device = device.expect("device must exist for WithOutput");
let sample_format = sample_format.expect("sample_format must exist for WithOutput");
let cfg = cfg.expect("cfg must exist for WithOutput");
let mut cons = cons_opt.take().expect("cons must exist for WithOutput");
let mut last_was_underrun = false;
// Audio callback: drain ring buffer and write to output.
let shared_for_cb = shared;
let stop_for_cb = Arc::clone(&stop_flag);
let volume_for_cb = Arc::clone(&volume_bits);
let err_fn = move |err| {
let msg = format!("Audio output error: {err}");
set_error(shared_for_cb, msg);
let mut last_was_underrun = false;
let err_fn = move |err| {
let msg = format!("Audio output error: {err}");
set_error(shared_for_cb, msg);
};
let built = match sample_format {
cpal::SampleFormat::F32 => device.build_output_stream(
&cfg,
move |data: &mut [f32], _| {
if stop_for_cb.load(Ordering::Relaxed) {
for s in data.iter_mut() {
*s = 0.0;
}
return;
}
let vol = volume_from_bits(volume_for_cb.load(Ordering::Relaxed));
let mut underrun = false;
for s in data.iter_mut() {
if let Some(v) = cons.pop() {
*s = (v as f32 / 32768.0) * vol;
} else {
*s = 0.0;
underrun = true;
}
}
if underrun != last_was_underrun {
last_was_underrun = underrun;
set_status(
shared_for_cb,
if underrun {
PlayerStatus::Buffering
} else {
PlayerStatus::Playing
},
);
}
},
err_fn,
None,
),
cpal::SampleFormat::I16 => device.build_output_stream(
&cfg,
move |data: &mut [i16], _| {
if stop_for_cb.load(Ordering::Relaxed) {
for s in data.iter_mut() {
*s = 0;
}
return;
}
let vol = volume_from_bits(volume_for_cb.load(Ordering::Relaxed));
let mut underrun = false;
for s in data.iter_mut() {
if let Some(v) = cons.pop() {
let scaled =
(v as f32 * vol).clamp(i16::MIN as f32, i16::MAX as f32);
*s = scaled as i16;
} else {
*s = 0;
underrun = true;
}
}
if underrun != last_was_underrun {
last_was_underrun = underrun;
set_status(
shared_for_cb,
if underrun {
PlayerStatus::Buffering
} else {
PlayerStatus::Playing
},
);
}
},
err_fn,
None,
),
cpal::SampleFormat::U16 => device.build_output_stream(
&cfg,
move |data: &mut [u16], _| {
if stop_for_cb.load(Ordering::Relaxed) {
for s in data.iter_mut() {
*s = 0;
}
return;
}
let vol = volume_from_bits(volume_for_cb.load(Ordering::Relaxed));
let mut underrun = false;
for s in data.iter_mut() {
if let Some(v) = cons.pop() {
// Convert signed i16 to unsigned with bias.
let f = (v as f32 / 32768.0) * vol;
let scaled = (f * 32767.0 + 32768.0).clamp(0.0, 65535.0);
*s = scaled as u16;
} else {
*s = 0;
underrun = true;
}
}
if underrun != last_was_underrun {
last_was_underrun = underrun;
set_status(
shared_for_cb,
if underrun {
PlayerStatus::Buffering
} else {
PlayerStatus::Playing
},
);
}
},
err_fn,
None,
),
_ => return Err("Unsupported output sample format".to_string()),
}
.map_err(|e| format!("Failed to create output stream: {e}"))?;
built
.play()
.map_err(|e| format!("Failed to start output stream: {e}"))?;
Some(built)
} else {
None
};
let stream = match sample_format {
cpal::SampleFormat::F32 => device.build_output_stream(
&cfg,
move |data: &mut [f32], _| {
if stop_for_cb.load(Ordering::Relaxed) {
for s in data.iter_mut() {
*s = 0.0;
}
return;
}
let vol = volume_from_bits(volume_for_cb.load(Ordering::Relaxed));
let mut underrun = false;
for s in data.iter_mut() {
if let Some(v) = cons.pop() {
*s = (v as f32 / 32768.0) * vol;
} else {
*s = 0.0;
underrun = true;
}
}
if underrun != last_was_underrun {
last_was_underrun = underrun;
set_status(
shared_for_cb,
if underrun {
PlayerStatus::Buffering
} else {
PlayerStatus::Playing
},
);
}
},
err_fn,
None,
),
cpal::SampleFormat::I16 => device.build_output_stream(
&cfg,
move |data: &mut [i16], _| {
if stop_for_cb.load(Ordering::Relaxed) {
for s in data.iter_mut() {
*s = 0;
}
return;
}
let vol = volume_from_bits(volume_for_cb.load(Ordering::Relaxed));
let mut underrun = false;
for s in data.iter_mut() {
if let Some(v) = cons.pop() {
let scaled = (v as f32 * vol).clamp(i16::MIN as f32, i16::MAX as f32);
*s = scaled as i16;
} else {
*s = 0;
underrun = true;
}
}
if underrun != last_was_underrun {
last_was_underrun = underrun;
set_status(
shared_for_cb,
if underrun {
PlayerStatus::Buffering
} else {
PlayerStatus::Playing
},
);
}
},
err_fn,
None,
),
cpal::SampleFormat::U16 => device.build_output_stream(
&cfg,
move |data: &mut [u16], _| {
if stop_for_cb.load(Ordering::Relaxed) {
for s in data.iter_mut() {
*s = 0;
}
return;
}
let vol = volume_from_bits(volume_for_cb.load(Ordering::Relaxed));
let mut underrun = false;
for s in data.iter_mut() {
if let Some(v) = cons.pop() {
// Convert signed i16 to unsigned with bias.
let f = (v as f32 / 32768.0) * vol;
let scaled = (f * 32767.0 + 32768.0).clamp(0.0, 65535.0);
*s = scaled as u16;
} else {
*s = 0;
underrun = true;
}
}
if underrun != last_was_underrun {
last_was_underrun = underrun;
set_status(
shared_for_cb,
if underrun {
PlayerStatus::Buffering
} else {
PlayerStatus::Playing
},
);
}
},
err_fn,
None,
),
_ => return Err("Unsupported output sample format".to_string()),
}
.map_err(|e| format!("Failed to create output stream: {e}"))?;
stream
.play()
.map_err(|e| format!("Failed to start output stream: {e}"))?;
Ok(Self {
stop_flag,
volume_bits,
_stream: stream,
decoder_join: Some(decoder_join),
cast_tx,
cast_proc: None,
sample_rate,
channels,
})
}
fn start_cast_tap(&mut self, port: u16, sample_rate: u32, channels: u16) -> Result<(), String> {
// Stop existing tap first.
self.stop_cast_tap();
let ffmpeg = ffmpeg_command();
let ffmpeg_disp = ffmpeg.to_string_lossy();
let spawn = |codec: &str| -> Result<std::process::Child, String> {
command_hidden(&ffmpeg)
.arg("-nostdin")
.arg("-hide_banner")
.arg("-loglevel")
.arg("warning")
.arg("-f")
.arg("s16le")
.arg("-ac")
.arg(channels.to_string())
.arg("-ar")
.arg(sample_rate.to_string())
.arg("-i")
.arg("pipe:0")
.arg("-vn")
.arg("-c:a")
.arg(codec)
.arg("-b:a")
.arg("128k")
.arg("-f")
.arg("mp3")
.arg("-content_type")
.arg("audio/mpeg")
.arg("-listen")
.arg("1")
.arg(format!("http://0.0.0.0:{port}/stream.mp3"))
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| {
format!(
"Failed to start ffmpeg cast tap ({ffmpeg_disp}): {e}. Set RADIOPLAYER_FFMPEG, bundle ffmpeg next to the app, or install ffmpeg on PATH."
)
})
};
let mut child = spawn("libmp3lame")?;
std::thread::sleep(Duration::from_millis(150));
if let Ok(Some(status)) = child.try_wait() {
if !status.success() {
// Some builds lack libmp3lame; fall back to built-in encoder.
child = spawn("mp3")?;
}
}
let stdin = child
.stdin
.take()
.ok_or_else(|| "ffmpeg cast tap stdin not available".to_string())?;
let (tx, rx) = mpsc::sync_channel::<Vec<u8>>(256);
*self.cast_tx.lock().unwrap() = Some(tx);
let writer_join = std::thread::spawn(move || {
use std::io::Write;
let mut stdin = stdin;
while let Ok(chunk) = rx.recv() {
if chunk.is_empty() {
continue;
}
if stdin.write_all(&chunk).is_err() {
break;
}
}
let _ = stdin.flush();
});
self.cast_proc = Some(CastTapProc {
child,
writer_join: Some(writer_join),
});
Ok(())
}
fn stop_cast_tap(&mut self) {
*self.cast_tx.lock().unwrap() = None;
if let Some(mut proc) = self.cast_proc.take() {
let _ = proc.child.kill();
let _ = proc.child.wait();
if let Some(j) = proc.writer_join.take() {
let _ = j.join();
}
}
}
fn stop(mut self, shared: &'static PlayerShared) {
self.stop_flag.store(true, Ordering::SeqCst);
self.stop_cast_tap();
// dropping stream stops audio
if let Some(j) = self.decoder_join.take() {
let _ = j.join();
@@ -492,6 +671,7 @@ impl Pipeline {
fn player_thread(shared: &'static PlayerShared, rx: mpsc::Receiver<PlayerCommand>) {
// Step 2: FFmpeg decode + CPAL playback.
let mut pipeline: Option<Pipeline> = None;
let mut pipeline_cast_owned = false;
while let Ok(cmd) = rx.recv() {
match cmd {
PlayerCommand::Play { url } => {
@@ -499,6 +679,8 @@ fn player_thread(shared: &'static PlayerShared, rx: mpsc::Receiver<PlayerCommand
p.stop(shared);
}
pipeline_cast_owned = false;
{
let mut s = shared.state.lock().unwrap();
s.error = None;
@@ -506,7 +688,7 @@ fn player_thread(shared: &'static PlayerShared, rx: mpsc::Receiver<PlayerCommand
s.status = PlayerStatus::Buffering;
}
match Pipeline::start(shared, url) {
match Pipeline::start(shared, url, PipelineMode::WithOutput) {
Ok(p) => {
// Apply current volume to pipeline atomics.
let vol = { shared.state.lock().unwrap().volume };
@@ -519,6 +701,32 @@ fn player_thread(shared: &'static PlayerShared, rx: mpsc::Receiver<PlayerCommand
}
}
}
PlayerCommand::PlayCast { url } => {
if let Some(p) = pipeline.take() {
p.stop(shared);
}
pipeline_cast_owned = true;
{
let mut s = shared.state.lock().unwrap();
s.error = None;
s.url = Some(url.clone());
s.status = PlayerStatus::Buffering;
}
match Pipeline::start(shared, url, PipelineMode::Headless) {
Ok(p) => {
let vol = { shared.state.lock().unwrap().volume };
p.set_volume(vol);
pipeline = Some(p);
}
Err(e) => {
set_error(shared, e);
pipeline = None;
}
}
}
PlayerCommand::Stop => {
if let Some(p) = pipeline.take() {
p.stop(shared);
@@ -527,6 +735,7 @@ fn player_thread(shared: &'static PlayerShared, rx: mpsc::Receiver<PlayerCommand
s.status = PlayerStatus::Stopped;
s.error = None;
}
pipeline_cast_owned = false;
}
PlayerCommand::SetVolume { volume } => {
let v = clamp01(volume);
@@ -538,6 +747,26 @@ fn player_thread(shared: &'static PlayerShared, rx: mpsc::Receiver<PlayerCommand
p.set_volume(v);
}
}
PlayerCommand::CastTapStart { port, reply } => {
if let Some(p) = pipeline.as_mut() {
// Current pipeline sample format is always s16le.
let res = p.start_cast_tap(port, p.sample_rate, p.channels);
let _ = reply.send(res);
} else {
let _ = reply.send(Err("No active decoder pipeline".to_string()));
}
}
PlayerCommand::CastTapStop => {
if let Some(p) = pipeline.as_mut() {
p.stop_cast_tap();
}
if pipeline_cast_owned {
if let Some(p) = pipeline.take() {
p.stop(shared);
}
pipeline_cast_owned = false;
}
}
PlayerCommand::Shutdown => break,
}
}

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "RadioPlayer",
"version": "0.1.0",
"version": "0.1.1",
"identifier": "si.klevze.radioPlayer",
"build": {
"frontendDist": "../src"