From 34c3f0dc89def82346c1385ca6deb440271994af Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sun, 11 Jan 2026 10:30:54 +0100 Subject: [PATCH] first step --- .ai/tauris-agent.md | 156 +++++++++ android/app/src/main/assets/main.js | 18 +- src-tauri/Cargo.lock | 284 +++++++++++++++- src-tauri/Cargo.toml | 2 + src-tauri/src/lib.rs | 102 +++++- src-tauri/src/player.rs | 509 ++++++++++++++++++++++++++++ src/main.js | 64 +++- 7 files changed, 1105 insertions(+), 30 deletions(-) create mode 100644 .ai/tauris-agent.md create mode 100644 src-tauri/src/player.rs diff --git a/.ai/tauris-agent.md b/.ai/tauris-agent.md new file mode 100644 index 0000000..f021f3e --- /dev/null +++ b/.ai/tauris-agent.md @@ -0,0 +1,156 @@ +# ROLE: Senior Desktop Audio Engineer & Tauri Architect + +You are an expert in: +- Tauri (Rust backend + system WebView frontend) +- Native audio streaming (FFmpeg, GStreamer, CPAL, Rodio) +- Desktop media players +- Chromecast / casting architectures +- Incremental refactors of production apps + +You are working on an existing project named **Taurus RadioPlayer**. + +--- + +## PROJECT CONTEXT (IMPORTANT) + +This is a **Tauri desktop application**, NOT Electron. + +### Current architecture: +- Frontend: Vanilla HTML / CSS / JS served in WebView +- Backend: Rust (Tauri commands) +- Audio: **HTML5 Audio API (new Audio())** +- Casting: Google Cast via Node.js sidecar (`castv2-client`) +- Stations: JSON file + user-defined stations in `localStorage` +- Platforms: Windows, Linux, macOS + +### Critical limitation: +HTML5 audio is insufficient for: +- stable radio streaming +- buffering control +- reconnection +- unified local + cast playback + +--- + +## PRIMARY GOAL + +Upgrade the application by: + +1. **Removing HTML5 Audio completely** +2. **Implementing a native audio streaming engine** +3. **Keeping the existing HTML/CSS UI unchanged** +4. **Preserving the current station model and UX** +5. **Maintaining cross-platform compatibility** +6. **Avoiding unnecessary rewrites** + +This is an **incremental upgrade**, not a rewrite. + +--- + +## TARGET ARCHITECTURE + +- UI remains WebView-based (HTML/CSS/JS) +- JS communicates only via Tauri `invoke()` +- Audio decoding and playback are handled natively +- One decoded stream can be routed to: + - local speakers + - cast devices +- Casting logic may remain temporarily in the sidecar + +--- + +## TECHNICAL DIRECTIVES (MANDATORY) + +### 1. Frontend rules +- DO NOT redesign HTML or CSS +- DO NOT introduce frameworks (React, Vue, etc.) +- Only replace JS logic that currently uses `new Audio()` +- All playback must go through backend commands + +### 2. Backend rules +- Prefer **Rust-native solutions** +- Acceptable audio stacks: + - FFmpeg + CPAL / Rodio + - GStreamer (if justified) +- Implement commands such as: + - `player_play(url)` + - `player_stop()` + - `player_set_volume(volume)` + - `player_get_state()` +- Handle: + - buffering + - reconnect on stream drop + - clean shutdown + - thread safety + +### 3. Casting rules +- Do not break existing Chromecast support +- Prefer reusing decoded audio where possible +- Do not introduce browser-based casting +- Sidecar removal is OPTIONAL, not required now + +--- + +## MIGRATION STRATEGY (VERY IMPORTANT) + +You must: +- Work in **small, safe steps** +- Clearly explain what files change and why +- Never delete working functionality without replacement +- Prefer additive refactors over destructive ones + +Each response should: +1. Explain intent +2. Show concrete code +3. State which file is modified +4. Preserve compatibility + +--- + +## WHAT YOU SHOULD PRODUCE + +You may generate: +- Rust code (Tauri commands, audio engine) +- JS changes (invoke-based playback) +- Architecture explanations +- Migration steps +- TODO lists +- Warnings about pitfalls + +You MUST NOT: +- Suggest Electron or Flutter +- Suggest full rewrites +- Ignore existing sidecar or station model +- Break the current UX + +--- + +## ENGINEERING PHILOSOPHY + +This app should evolve into: + +> “A native audio engine with a web UI shell” + +The WebView is a **control surface**, not a media engine. + +--- + +## COMMUNICATION STYLE + +- Be precise +- Be pragmatic +- Be production-oriented +- Prefer correctness over novelty +- Assume this is a real app with users + +--- + +## FIRST TASK WHEN STARTING + +Begin by: +1. Identifying all HTML5 Audio usage +2. Proposing the native audio engine design +3. Defining the minimal command interface +4. Planning the replacement step-by-step + +Do NOT write all code at once. diff --git a/android/app/src/main/assets/main.js b/android/app/src/main/assets/main.js index 50c6b53..9056c68 100644 --- a/android/app/src/main/assets/main.js +++ b/android/app/src/main/assets/main.js @@ -7,7 +7,8 @@ let currentIndex = 0; let isPlaying = false; let currentMode = 'local'; // 'local' | 'cast' let currentCastDevice = null; -const audio = new Audio(); + +// Local playback is handled natively by the Tauri backend (player_* commands). // UI Elements const stationNameEl = document.getElementById('station-name'); @@ -146,10 +147,10 @@ async function play() { statusDotEl.style.backgroundColor = 'var(--text-muted)'; // Grey/Yellow while loading if (currentMode === 'local') { - audio.src = station.url; - audio.volume = volumeSlider.value / 100; try { - await audio.play(); + const vol = volumeSlider.value / 100; + await invoke('player_set_volume', { volume: vol }).catch(() => {}); + await invoke('player_play', { url: station.url }); isPlaying = true; updateUI(); } catch (e) { @@ -176,8 +177,11 @@ async function play() { async function stop() { if (currentMode === 'local') { - audio.pause(); - audio.src = ''; + try { + await invoke('player_stop'); + } catch (e) { + console.error(e); + } } else if (currentMode === 'cast' && currentCastDevice) { try { await invoke('cast_stop', { deviceName: currentCastDevice }); @@ -243,7 +247,7 @@ function handleVolumeInput() { const decimals = val / 100; if (currentMode === 'local') { - audio.volume = decimals; + invoke('player_set_volume', { volume: decimals }).catch(() => {}); } else if (currentMode === 'cast' && currentCastDevice) { invoke('cast_set_volume', { deviceName: currentCastDevice, volume: decimals }); } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c0ca4b8..c5c7dcf 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b320e62..ec8fd79 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9541404..5668d73 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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>, } @@ -17,6 +20,81 @@ struct AppState { known_devices: Mutex>, } +// 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 { + 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, 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::(); + 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"); diff --git a/src-tauri/src/player.rs b/src-tauri/src/player.rs new file mode 100644 index 0000000..7e8d1a0 --- /dev/null +++ b/src-tauri/src/player.rs @@ -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, + pub volume: f32, + pub error: Option, +} + +impl Default for PlayerState { + fn default() -> Self { + Self { + status: PlayerStatus::Idle, + url: None, + volume: 0.5, + error: None, + } + } +} + +pub struct PlayerShared { + pub state: Mutex, +} + +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, +} + +pub fn spawn_player_thread(shared: &'static PlayerShared) -> PlayerController { + let (tx, rx) = mpsc::channel::(); + + 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, + volume_bits: Arc, + _stream: cpal::Stream, + decoder_join: Option>, +} + +impl Pipeline { + fn start(shared: &'static PlayerShared, url: String) -> Result { + 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::::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 = 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) { + // Step 2: FFmpeg decode + CPAL playback. + let mut pipeline: Option = 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); + } +} diff --git a/src/main.js b/src/main.js index 76e41ab..b7a5cc5 100644 --- a/src/main.js +++ b/src/main.js @@ -7,7 +7,10 @@ let currentIndex = 0; let isPlaying = false; let currentMode = 'local'; // 'local' | 'cast' let currentCastDevice = null; -const audio = new Audio(); + +// Local playback is handled natively by the Tauri backend (player_* commands). +// The WebView is a control surface only. +let localPlayerPollId = null; // UI Elements const stationNameEl = document.getElementById('station-name'); @@ -95,7 +98,9 @@ function restoreSavedVolume() { volumeSlider.value = String(saved); volumeValue.textContent = `${saved}%`; const decimals = saved / 100; - audio.volume = decimals; + + // Keep backend player volume in sync (best-effort). + invoke('player_set_volume', { volume: decimals }).catch(() => {}); // If currently in cast mode and a device is selected, propagate volume if (currentMode === 'cast' && currentCastDevice) { invoke('cast_set_volume', { deviceName: currentCastDevice, volume: decimals }).catch(()=>{}); @@ -103,6 +108,44 @@ function restoreSavedVolume() { } } +function stopLocalPlayerStatePolling() { + if (localPlayerPollId) { + try { clearInterval(localPlayerPollId); } catch (e) {} + localPlayerPollId = null; + } +} + +function startLocalPlayerStatePolling() { + stopLocalPlayerStatePolling(); + // Polling keeps the existing UI in sync with native buffering/reconnect. + localPlayerPollId = setInterval(async () => { + try { + if (!isPlaying || currentMode !== 'local') return; + const st = await invoke('player_get_state'); + if (!st || !statusTextEl || !statusDotEl) return; + + const status = String(st.status || '').toLowerCase(); + if (status === 'buffering') { + statusTextEl.textContent = 'Buffering...'; + statusDotEl.style.backgroundColor = 'var(--text-muted)'; + } else if (status === 'playing') { + statusTextEl.textContent = 'Playing'; + statusDotEl.style.backgroundColor = 'var(--success)'; + } else if (status === 'error') { + statusTextEl.textContent = st.error ? `Error: ${st.error}` : 'Error'; + statusDotEl.style.backgroundColor = 'var(--danger)'; + } else { + // idle/stopped: keep UI consistent with our isPlaying flag + } + } catch (e) { + // Don't spam; just surface a minimal indicator. + try { + if (statusTextEl) statusTextEl.textContent = 'Error'; + } catch (_) {} + } + }, 600); +} + async function loadStations() { try { // stop any existing pollers before reloading stations @@ -896,12 +939,13 @@ async function play() { statusDotEl.style.backgroundColor = 'var(--text-muted)'; // Grey/Yellow while loading if (currentMode === 'local') { - audio.src = station.url; - audio.volume = volumeSlider.value / 100; try { - await audio.play(); + const vol = volumeSlider.value / 100; + await invoke('player_set_volume', { volume: vol }).catch(() => {}); + await invoke('player_play', { url: station.url }); isPlaying = true; updateUI(); + startLocalPlayerStatePolling(); } catch (e) { console.error('Playback failed', e); statusTextEl.textContent = 'Error'; @@ -926,8 +970,12 @@ async function play() { async function stop() { if (currentMode === 'local') { - audio.pause(); - audio.src = ''; + stopLocalPlayerStatePolling(); + try { + await invoke('player_stop'); + } catch (e) { + console.error(e); + } } else if (currentMode === 'cast' && currentCastDevice) { try { await invoke('cast_stop', { deviceName: currentCastDevice }); @@ -981,7 +1029,7 @@ function handleVolumeInput() { const decimals = val / 100; if (currentMode === 'local') { - audio.volume = decimals; + invoke('player_set_volume', { volume: decimals }).catch(() => {}); } else if (currentMode === 'cast' && currentCastDevice) { invoke('cast_set_volume', { deviceName: currentCastDevice, volume: decimals }); }