first step
This commit is contained in:
284
src-tauri/Cargo.lock
generated
284
src-tauri/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
509
src-tauri/src/player.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user