first step

This commit is contained in:
2026-01-11 10:30:54 +01:00
parent f9b9ce0994
commit 34c3f0dc89
7 changed files with 1105 additions and 30 deletions

284
src-tauri/Cargo.lock generated
View File

@@ -32,6 +32,28 @@ dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "alsa"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43"
dependencies = [
"alsa-sys",
"bitflags 2.10.0",
"cfg-if",
"libc",
]
[[package]]
name = "alsa-sys"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527"
dependencies = [
"libc",
"pkg-config",
]
[[package]]
name = "android_system_properties"
version = "0.1.5"
@@ -247,6 +269,24 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bindgen"
version = "0.72.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
dependencies = [
"bitflags 2.10.0",
"cexpr",
"clang-sys",
"itertools",
"proc-macro2",
"quote",
"regex",
"rustc-hash",
"shlex",
"syn 2.0.111",
]
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -426,6 +466,15 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
[[package]]
name = "cexpr"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
"nom",
]
[[package]]
name = "cfb"
version = "0.7.3"
@@ -471,6 +520,17 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "clang-sys"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
dependencies = [
"glob",
"libc",
"libloading 0.8.9",
]
[[package]]
name = "cmake"
version = "0.1.57"
@@ -565,6 +625,49 @@ dependencies = [
"libc",
]
[[package]]
name = "coreaudio-rs"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace"
dependencies = [
"bitflags 1.3.2",
"core-foundation-sys",
"coreaudio-sys",
]
[[package]]
name = "coreaudio-sys"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ceec7a6067e62d6f931a2baf6f3a751f4a892595bcec1461a3c94ef9949864b6"
dependencies = [
"bindgen",
]
[[package]]
name = "cpal"
version = "0.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
dependencies = [
"alsa",
"core-foundation-sys",
"coreaudio-rs",
"dasp_sample",
"jni",
"js-sys",
"libc",
"mach2",
"ndk 0.8.0",
"ndk-context",
"oboe",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows 0.54.0",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -680,6 +783,12 @@ dependencies = [
"syn 2.0.111",
]
[[package]]
name = "dasp_sample"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "deranged"
version = "0.5.5"
@@ -1898,6 +2007,15 @@ dependencies = [
"once_cell",
]
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.17"
@@ -2040,7 +2158,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf"
dependencies = [
"gtk-sys",
"libloading",
"libloading 0.7.4",
"once_cell",
]
@@ -2060,6 +2178,16 @@ dependencies = [
"winapi",
]
[[package]]
name = "libloading"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
dependencies = [
"cfg-if",
"windows-link 0.2.1",
]
[[package]]
name = "libredox"
version = "0.1.12"
@@ -2109,6 +2237,15 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mach2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
dependencies = [
"libc",
]
[[package]]
name = "markup5ever"
version = "0.14.1"
@@ -2176,6 +2313,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2236,6 +2379,20 @@ dependencies = [
"tempfile",
]
[[package]]
name = "ndk"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7"
dependencies = [
"bitflags 2.10.0",
"jni-sys",
"log",
"ndk-sys 0.5.0+25.2.9519653",
"num_enum",
"thiserror 1.0.69",
]
[[package]]
name = "ndk"
version = "0.9.0"
@@ -2245,7 +2402,7 @@ dependencies = [
"bitflags 2.10.0",
"jni-sys",
"log",
"ndk-sys",
"ndk-sys 0.6.0+11769913",
"num_enum",
"raw-window-handle",
"thiserror 1.0.69",
@@ -2257,6 +2414,15 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
[[package]]
name = "ndk-sys"
version = "0.5.0+25.2.9519653"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691"
dependencies = [
"jni-sys",
]
[[package]]
name = "ndk-sys"
version = "0.6.0+11769913"
@@ -2291,12 +2457,33 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "num-conv"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]]
name = "num-derive"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -2540,6 +2727,29 @@ dependencies = [
"objc2-security",
]
[[package]]
name = "oboe"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb"
dependencies = [
"jni",
"ndk 0.8.0",
"ndk-context",
"num-derive",
"num-traits",
"oboe-sys",
]
[[package]]
name = "oboe-sys"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d"
dependencies = [
"cc",
]
[[package]]
name = "once_cell"
version = "1.21.3"
@@ -3074,8 +3284,10 @@ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
name = "radio-tauri"
version = "0.1.0"
dependencies = [
"cpal",
"mdns-sd",
"reqwest 0.11.27",
"ringbuf",
"rust_cast",
"serde",
"serde_json",
@@ -3335,6 +3547,15 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "ringbuf"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79abed428d1fd2a128201cec72c5f6938e2da607c6f3745f769fabea399d950a"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "rust_cast"
version = "0.19.0"
@@ -3352,6 +3573,12 @@ dependencies = [
"serde_json",
]
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -3922,7 +4149,7 @@ checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3"
dependencies = [
"bytemuck",
"js-sys",
"ndk",
"ndk 0.9.0",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
@@ -4134,9 +4361,9 @@ dependencies = [
"lazy_static",
"libc",
"log",
"ndk",
"ndk 0.9.0",
"ndk-context",
"ndk-sys",
"ndk-sys 0.6.0+11769913",
"objc2",
"objc2-app-kit",
"objc2-foundation",
@@ -4147,7 +4374,7 @@ dependencies = [
"tao-macros",
"unicode-segmentation",
"url",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
@@ -4218,7 +4445,7 @@ dependencies = [
"webkit2gtk",
"webview2-com",
"window-vibrancy",
"windows",
"windows 0.61.3",
]
[[package]]
@@ -4319,7 +4546,7 @@ dependencies = [
"tauri-plugin",
"thiserror 2.0.17",
"url",
"windows",
"windows 0.61.3",
"zbus",
]
@@ -4366,7 +4593,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
"windows",
"windows 0.61.3",
]
[[package]]
@@ -4392,7 +4619,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
"windows",
"windows 0.61.3",
"wry",
]
@@ -5158,7 +5385,7 @@ checksum = "d4ba622a989277ef3886dd5afb3e280e3dd6d974b766118950a08f8f678ad6a4"
dependencies = [
"webview2-com-macros",
"webview2-com-sys",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-implement",
"windows-interface",
@@ -5182,7 +5409,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36695906a1b53a3bf5c4289621efedac12b73eeb0b89e7e1a89b517302d5d75c"
dependencies = [
"thiserror 2.0.17",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
]
@@ -5244,6 +5471,16 @@ dependencies = [
"windows-version",
]
[[package]]
name = "windows"
version = "0.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
dependencies = [
"windows-core 0.54.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows"
version = "0.61.3"
@@ -5266,6 +5503,16 @@ dependencies = [
"windows-core 0.61.2",
]
[[package]]
name = "windows-core"
version = "0.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
dependencies = [
"windows-result 0.1.2",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.61.2"
@@ -5347,6 +5594,15 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-result"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-result"
version = "0.3.4"
@@ -5769,7 +6025,7 @@ dependencies = [
"jni",
"kuchikiki",
"libc",
"ndk",
"ndk 0.9.0",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
@@ -5787,7 +6043,7 @@ dependencies = [
"webkit2gtk",
"webkit2gtk-sys",
"webview2-com",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",

View File

@@ -27,4 +27,6 @@ mdns-sd = "0.17.1"
tokio = { version = "1.48.0", features = ["full"] }
tauri-plugin-shell = "2.3.3"
reqwest = { version = "0.11", features = ["json", "rustls-tls"] }
cpal = "0.15"
ringbuf = "0.3"

View File

@@ -9,6 +9,9 @@ use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_shell::ShellExt;
use reqwest;
mod player;
use player::{PlayerCommand, PlayerController, PlayerShared, PlayerState};
struct SidecarState {
child: Mutex<Option<CommandChild>>,
}
@@ -17,6 +20,81 @@ struct AppState {
known_devices: Mutex<HashMap<String, String>>,
}
// Native (non-WebView) audio player state.
// Step 1: state machine + command interface only (no decoding/output yet).
struct PlayerRuntime {
shared: &'static PlayerShared,
controller: PlayerController,
}
fn clamp01(v: f32) -> f32 {
if v.is_nan() {
0.0
} else if v < 0.0 {
0.0
} else if v > 1.0 {
1.0
} else {
v
}
}
#[tauri::command]
async fn player_get_state(player: State<'_, PlayerRuntime>) -> Result<PlayerState, String> {
Ok(player.shared.snapshot())
}
#[tauri::command]
async fn player_set_volume(
player: State<'_, PlayerRuntime>,
volume: f32,
) -> Result<(), String> {
let volume = clamp01(volume);
{
let mut s = player.shared.state.lock().unwrap();
s.volume = volume;
}
player
.controller
.tx
.send(PlayerCommand::SetVolume { volume })
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
async fn player_play(player: State<'_, PlayerRuntime>, url: String) -> Result<(), String> {
{
let mut s = player.shared.state.lock().unwrap();
s.error = None;
s.url = Some(url.clone());
// Step 1: report buffering immediately; the engine thread will progress.
s.status = player::PlayerStatus::Buffering;
}
player
.controller
.tx
.send(PlayerCommand::Play { url })
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
async fn player_stop(player: State<'_, PlayerRuntime>) -> Result<(), String> {
{
let mut s = player.shared.state.lock().unwrap();
s.error = None;
s.status = player::PlayerStatus::Stopped;
}
player
.controller
.tx
.send(PlayerCommand::Stop)
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
async fn list_cast_devices(state: State<'_, AppState>) -> Result<Vec<String>, String> {
let devices = state.known_devices.lock().unwrap();
@@ -139,6 +217,14 @@ pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_opener::init())
.on_window_event(|window, event| {
// Ensure native audio shuts down on app close.
// We do not prevent the close; this is best-effort cleanup.
if matches!(event, tauri::WindowEvent::CloseRequested { .. }) {
let player = window.app_handle().state::<PlayerRuntime>();
let _ = player.controller.tx.send(PlayerCommand::Shutdown);
}
})
.setup(|app| {
app.manage(AppState {
known_devices: Mutex::new(HashMap::new()),
@@ -147,6 +233,15 @@ pub fn run() {
child: Mutex::new(None),
});
// Player scaffolding: leak shared state to get a 'static reference for the
// long-running thread without complex lifetime plumbing.
// Later refactors can move this to Arc<...> when the engine grows.
let shared: &'static PlayerShared = Box::leak(Box::new(PlayerShared {
state: Mutex::new(PlayerState::default()),
}));
let controller = player::spawn_player_thread(shared);
app.manage(PlayerRuntime { shared, controller });
let handle = app.handle().clone();
thread::spawn(move || {
let mdns = ServiceDaemon::new().expect("Failed to create daemon");
@@ -189,7 +284,12 @@ pub fn run() {
cast_stop,
cast_set_volume,
// allow frontend to request arbitrary URLs via backend (bypass CORS)
fetch_url
fetch_url,
// native player commands (step 1 scaffold)
player_play,
player_stop,
player_set_volume,
player_get_state
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

509
src-tauri/src/player.rs Normal file
View File

@@ -0,0 +1,509 @@
use serde::Serialize;
use std::io::Read;
use std::process::{Command, Stdio};
use std::ffi::OsString;
use std::sync::{
atomic::{AtomicBool, AtomicU32, Ordering},
mpsc, Arc, Mutex,
};
use std::time::Duration;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use ringbuf::HeapRb;
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum PlayerStatus {
Idle,
Buffering,
Playing,
Stopped,
Error,
}
#[derive(Debug, Clone, Serialize)]
pub struct PlayerState {
pub status: PlayerStatus,
pub url: Option<String>,
pub volume: f32,
pub error: Option<String>,
}
impl Default for PlayerState {
fn default() -> Self {
Self {
status: PlayerStatus::Idle,
url: None,
volume: 0.5,
error: None,
}
}
}
pub struct PlayerShared {
pub state: Mutex<PlayerState>,
}
impl PlayerShared {
pub fn snapshot(&self) -> PlayerState {
self.state.lock().unwrap().clone()
}
}
#[derive(Debug)]
pub enum PlayerCommand {
Play { url: String },
Stop,
SetVolume { volume: f32 },
Shutdown,
}
#[derive(Clone)]
pub struct PlayerController {
pub tx: mpsc::Sender<PlayerCommand>,
}
pub fn spawn_player_thread(shared: &'static PlayerShared) -> PlayerController {
let (tx, rx) = mpsc::channel::<PlayerCommand>();
std::thread::spawn(move || player_thread(shared, rx));
PlayerController { tx }
}
fn clamp01(v: f32) -> f32 {
if v.is_nan() {
0.0
} else if v < 0.0 {
0.0
} else if v > 1.0 {
1.0
} else {
v
}
}
fn volume_to_bits(v: f32) -> u32 {
clamp01(v).to_bits()
}
fn volume_from_bits(bits: u32) -> f32 {
f32::from_bits(bits)
}
fn set_status(shared: &'static PlayerShared, status: PlayerStatus) {
let mut s = shared.state.lock().unwrap();
if s.status != status {
s.status = status;
}
}
fn set_error(shared: &'static PlayerShared, message: String) {
let mut s = shared.state.lock().unwrap();
s.status = PlayerStatus::Error;
s.error = Some(message);
}
fn ffmpeg_command() -> OsString {
// Step 2: external ffmpeg binary.
// Lookup order:
// 1) RADIOPLAYER_FFMPEG (absolute or relative)
// 2) ffmpeg next to the application executable
// 3) PATH lookup (ffmpeg / ffmpeg.exe)
if let Ok(p) = std::env::var("RADIOPLAYER_FFMPEG") {
if !p.trim().is_empty() {
return OsString::from(p);
}
}
let local_name = if cfg!(windows) { "ffmpeg.exe" } else { "ffmpeg" };
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
let candidate = dir.join(local_name);
if candidate.exists() {
return candidate.into_os_string();
}
}
}
OsString::from(local_name)
}
struct Pipeline {
stop_flag: Arc<AtomicBool>,
volume_bits: Arc<AtomicU32>,
_stream: cpal::Stream,
decoder_join: Option<std::thread::JoinHandle<()>>,
}
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;
// 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 stop_flag = Arc::new(AtomicBool::new(false));
let volume_bits = Arc::new(AtomicU32::new({
let s = shared.state.lock().unwrap();
volume_to_bits(s.volume)
}));
// 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 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_div(4); // ~250ms
'outer: loop {
if stop_for_decoder.load(Ordering::SeqCst) {
break;
}
set_status(shared_for_decoder, PlayerStatus::Buffering);
let ffmpeg = ffmpeg_command();
let ffmpeg_disp = ffmpeg.to_string_lossy();
let mut child = match Command::new(&ffmpeg)
.arg("-nostdin")
.arg("-hide_banner")
.arg("-loglevel")
.arg("warning")
// basic reconnect flags (best-effort; not all protocols honor these)
.arg("-reconnect")
.arg("1")
.arg("-reconnect_streamed")
.arg("1")
.arg("-reconnect_delay_max")
.arg("5")
.arg("-i")
.arg(&decoder_url)
.arg("-vn")
.arg("-ac")
.arg(channels.to_string())
.arg("-ar")
.arg(sample_rate.to_string())
.arg("-f")
.arg("s16le")
.arg("pipe:1")
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
{
Ok(c) => c,
Err(e) => {
// If ffmpeg isn't available, this is a hard failure.
set_error(
shared_for_decoder,
format!(
"Failed to start ffmpeg ({ffmpeg_disp}): {e}. Set RADIOPLAYER_FFMPEG, bundle ffmpeg next to the app, or install ffmpeg on PATH."
),
);
break;
}
};
let mut stdout = match child.stdout.take() {
Some(s) => s,
None => {
set_error(shared_for_decoder, "ffmpeg stdout not available".to_string());
let _ = child.kill();
break;
}
};
let mut buf = [0u8; 8192];
let mut leftover: Option<u8> = None;
loop {
if stop_for_decoder.load(Ordering::SeqCst) {
let _ = child.kill();
let _ = child.wait();
break 'outer;
}
let n = match stdout.read(&mut buf) {
Ok(0) => 0,
Ok(n) => n,
Err(_) => 0,
};
if n == 0 {
// EOF / disconnect. Try to reconnect after backoff.
let _ = child.kill();
let _ = child.wait();
if stop_for_decoder.load(Ordering::SeqCst) {
break 'outer;
}
set_status(shared_for_decoder, PlayerStatus::Buffering);
std::thread::sleep(Duration::from_millis(backoff_ms));
backoff_ms = (backoff_ms * 2).min(5000);
continue 'outer;
}
backoff_ms = 250;
// 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);
pushed_since_start += 1;
i = 1;
} else {
leftover = Some(b0);
}
}
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;
}
i += 2;
}
if i < n {
leftover = Some(buf[i]);
}
// Move to Playing once we've decoded a small buffer.
if pushed_since_start >= playing_threshold_samples {
set_status(shared_for_decoder, PlayerStatus::Playing);
}
}
}
});
// 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 mut last_was_underrun = false;
let err_fn = move |err| {
let msg = format!("Audio output error: {err}");
set_error(shared_for_cb, msg);
};
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),
})
}
fn stop(mut self, shared: &'static PlayerShared) {
self.stop_flag.store(true, Ordering::SeqCst);
// dropping stream stops audio
if let Some(j) = self.decoder_join.take() {
let _ = j.join();
}
set_status(shared, PlayerStatus::Stopped);
}
fn set_volume(&self, volume: f32) {
self.volume_bits.store(volume_to_bits(volume), Ordering::Relaxed);
}
}
fn player_thread(shared: &'static PlayerShared, rx: mpsc::Receiver<PlayerCommand>) {
// Step 2: FFmpeg decode + CPAL playback.
let mut pipeline: Option<Pipeline> = None;
while let Ok(cmd) = rx.recv() {
match cmd {
PlayerCommand::Play { url } => {
if let Some(p) = pipeline.take() {
p.stop(shared);
}
{
let mut s = shared.state.lock().unwrap();
s.error = None;
s.url = Some(url.clone());
s.status = PlayerStatus::Buffering;
}
match Pipeline::start(shared, url) {
Ok(p) => {
// Apply current volume to pipeline atomics.
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);
} else {
let mut s = shared.state.lock().unwrap();
s.status = PlayerStatus::Stopped;
s.error = None;
}
}
PlayerCommand::SetVolume { volume } => {
let v = clamp01(volume);
{
let mut s = shared.state.lock().unwrap();
s.volume = v;
}
if let Some(p) = pipeline.as_ref() {
p.set_volume(v);
}
}
PlayerCommand::Shutdown => break,
}
}
if let Some(p) = pipeline.take() {
p.stop(shared);
} else {
set_status(shared, PlayerStatus::Stopped);
}
}