mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
Environment upgrade & hot-cache playback (#463)
* chore: upgrade dependencies and migrate playback to rodio 0.22 Bump npm and Rust crates; adapt symphonia decoding, ringbuf 0.5, lofty tags, and discord-rich-presence usage. Use native rodio Player/MixerDeviceSink and cpal device descriptions; drop the unused cpal patch. Align Vite 8 build targets and chunking; remove redundant dynamic imports and fix hot-cache debug logging imports. * perf(build): lazy-load routes and restore default chunk warnings Lazy-load all routed pages with React.lazy to shrink the main bundle; wrap root Routes in Suspense for lazy Login. Drop chunkSizeWarningLimit override so Vite uses the default 500 kB threshold. * fix(windows): tray double-click without spurious menu; clean unused import Disable tray menu on left mouse-up on Windows so a double-click to hide the main window does not immediately reopen the context menu (tray-icon default menu_on_left_click). Gate std::fs in app_api/core behind cfg(linux) for /proc-only code so Windows builds stay warning-free. * fix(sidebar): preserve new-releases read state under storage cap When merging seen album ids, keep the current newest sample first so the 500-id localStorage limit does not truncate freshly marked reads and bring back the unread badge. * fix(audio): hot-cache replay, analysis no-op skips, playback source UI Retain stream_completed_cache across audio_stop so end-of-queue replay can use RAM promote or disk hot file instead of re-ranging HTTP. Add cpu_seed_redundant_for_track gate before file/bytes seeds and local-file spawn; emit analysis:waveform-updated only on Upserted. Ranged/legacy promote checks generation after await before filling the slot. Frontend: promote on same-track and cold resume; set currentPlaybackSource on resume, queue undo restore, and gapless track switch so cache/stream icons stay accurate. Import tauri::Manager for try_state in audio_play. * fix(ts): narrow activeServerId for hot-cache promote calls promoteCompletedStreamToHotCache expects a string; bind non-null server ids in repeat-one, playTrack prev/same-track, and cold resume paths so tauri production build (tsc) succeeds. * fix(player): handle same-track hot-cache promote promise chain Add .catch for promoteCompletedStreamToHotCache → runPlayTrackBody so sync throws and unexpected rejections do not surface as unhandled in DevTools; reset defer-hot-cache prefetch and isPlaying on failure. * chore(nix): sync npmDepsHash with package-lock.json * chore(release): finalize 1.46.0 CHANGELOG with PR #463 links Document the release with full GitHub PR #463 on every subsection so entries stay attributable if sections are reordered. Fix ContextMenu lines where dynamic imports were accidentally merged onto one line. * docs(contributors): credit cucadmuh for #463
This commit is contained in:
@@ -14,6 +14,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
> **📦 Version jump 1.34.x → 1.40.0:** The 1.34.x patch series was bumped a lot as each small feature landed. 1.40.0 consolidates the last few weeks of work — macOS signing + auto-updater, the Device-Sync overhaul, theme work and contrast audits — into a single coherent release. The next major bump (2.0.0) is planned once Windows code-signing + Windows auto-updater are active as well.
|
> **📦 Version jump 1.34.x → 1.40.0:** The 1.34.x patch series was bumped a lot as each small feature landed. 1.40.0 consolidates the last few weeks of work — macOS signing + auto-updater, the Device-Sync overhaul, theme work and contrast audits — into a single coherent release. The next major bump (2.0.0) is planned once Windows code-signing + Windows auto-updater are active as well.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.46.0] - 2026-05-05
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
### Dependencies — npm / Cargo refresh and rodio 0.22
|
||||||
|
|
||||||
|
**By [@cucadmuh](https://github.com/cucadmuh), PR [#463](https://github.com/Psychotoxical/psysonic/pull/463)**
|
||||||
|
|
||||||
|
* Bumped **frontend** and **Tauri / Rust** dependencies across the workspace (`package.json`, `package-lock.json`, `Cargo.toml`, `Cargo.lock`).
|
||||||
|
* Playback stack migrated to **rodio 0.22** with corresponding updates in decode, sources, engine, device I/O and related modules.
|
||||||
|
|
||||||
|
### Build — lazy-loaded routes and Vite chunk warnings
|
||||||
|
|
||||||
|
**By [@cucadmuh](https://github.com/cucadmuh), PR [#463](https://github.com/Psychotoxical/psysonic/pull/463)**
|
||||||
|
|
||||||
|
* Heavier app **routes** are loaded **lazily** so the initial JS bundle stays smaller (`App.tsx` and related entry wiring).
|
||||||
|
* Restored **default Vite `chunkSizeWarningLimit`** behaviour so oversized chunks are reported again during production builds (`vite.config.ts`).
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
### Hot cache, HTTP streaming replay, and queue source indicator
|
||||||
|
|
||||||
|
**By [@cucadmuh](https://github.com/cucadmuh), PR [#463](https://github.com/Psychotoxical/psysonic/pull/463)**
|
||||||
|
|
||||||
|
* **`stream_completed_cache`** is **no longer cleared** on **`audio_stop`**, so a fully buffered ranged download is not thrown away when the queue ends; starting the same track again can reuse **RAM** or **hot-disk** data instead of running a full **HTTP ranged** fetch from scratch (when hot cache is enabled and promotion succeeds).
|
||||||
|
* **Same-track `playTrack`** and **cold `resume`** after **`audio:ended`** (engine not in paused-loaded state) **await hot-cache promote** so `resolvePlaybackUrl` can switch to **`psysonic-local://`** before the next **`audio_play`**.
|
||||||
|
* **Ranged HTTP** sources merge format hints from the URL tail, **`Content-Type`**, **`Content-Disposition`** filename, and Subsonic **`song.suffix`** (IPC **`streamFormatSuffix`**). A bounded **Range** probe runs only when hints are still missing. Generic **`video/mp4`** **Content-Type** is not treated as an audio-container hint.
|
||||||
|
* **SQLite analysis:** skip redundant **CPU seeds** when **waveform and loudness** rows already exist; emit **`analysis:waveform-updated`** only after a real DB **upsert**, not on cache-hit no-ops. Ranged / legacy download tasks re-check **playback generation** after awaits before writing the completed-stream slot.
|
||||||
|
* **Queue panel** source icon (**stream / hot cache / offline**) now updates on **resume**, **queue-undo restore**, and **gapless `audio:track_switched`**, not only when **`playTrack`** runs.
|
||||||
|
* **TypeScript / robustness:** non-null **`activeServerId`** bindings for promote IPC; **`.catch`** on the same-track **promote → play** promise chain with generation guard and prefetch-reset on failure.
|
||||||
|
|
||||||
|
### Sidebar — New Releases read state under storage cap
|
||||||
|
|
||||||
|
**By [@cucadmuh](https://github.com/cucadmuh), PR [#463](https://github.com/Psychotoxical/psysonic/pull/463)**
|
||||||
|
|
||||||
|
* When persisted “seen” release IDs hit the **500-id cap**, **fresh reads are merged** at the front of the capped set so **unread badges** do not come back incorrectly (`mergeSeenNewReleaseIdsCap`).
|
||||||
|
|
||||||
|
### Windows — tray double-click
|
||||||
|
|
||||||
|
**By [@cucadmuh](https://github.com/cucadmuh), PR [#463](https://github.com/Psychotoxical/psysonic/pull/463)**
|
||||||
|
|
||||||
|
* **Double-click** on the tray icon opens (or focuses) the main window without a spurious **context-menu** interaction; tray module import cleanup.
|
||||||
|
|
||||||
## [1.45.0] - 2026-05-04
|
## [1.45.0] - 2026-05-04
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"npmDepsHash": "sha256-OOQmA7IQhD9WztDek4IA/2/v33zt9MHSHSbUCOaqbrY="
|
"npmDepsHash": "sha256-3zwfk7gxSYXoiOwNyPHP+K9P7yAksGKXQvwtvic/IdQ="
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+812
-1158
File diff suppressed because it is too large
Load Diff
+21
-20
@@ -26,37 +26,38 @@
|
|||||||
"@fontsource-variable/rubik": "^5.2.8",
|
"@fontsource-variable/rubik": "^5.2.8",
|
||||||
"@fontsource-variable/space-grotesk": "^5.2.10",
|
"@fontsource-variable/space-grotesk": "^5.2.10",
|
||||||
"@fontsource-variable/unbounded": "^5.2.8",
|
"@fontsource-variable/unbounded": "^5.2.8",
|
||||||
"@tanstack/react-virtual": "^3.13.23",
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
"@tauri-apps/plugin-fs": "^2.5.0",
|
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||||
"@tauri-apps/plugin-process": "^2.3.1",
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
"@tauri-apps/plugin-shell": "^2",
|
"@tauri-apps/plugin-shell": "^2",
|
||||||
"@tauri-apps/plugin-store": "^2",
|
"@tauri-apps/plugin-store": "^2",
|
||||||
"@tauri-apps/plugin-updater": "^2.10.0",
|
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||||
"@tauri-apps/plugin-window-state": "^2.4.1",
|
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.16.0",
|
||||||
"i18next": "^25.8.16",
|
"i18next": "^26.0.8",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^1.14.0",
|
||||||
"md5": "^2.3.0",
|
"md5": "^2.3.0",
|
||||||
"papaparse": "^5.5.3",
|
"papaparse": "^5.5.3",
|
||||||
"react": "^18.3.1",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^19.2.5",
|
||||||
"react-i18next": "^16.5.6",
|
"react-i18next": "^17.0.6",
|
||||||
"react-router-dom": "^6.26.2",
|
"react-router-dom": "^7.15.0",
|
||||||
"zustand": "^5.0.0"
|
"zustand": "^5.0.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
"@types/md5": "^2.3.5",
|
"@types/md5": "^2.3.6",
|
||||||
"@types/node": "^25.3.5",
|
"@types/node": "^25.6.0",
|
||||||
"@types/papaparse": "^5.5.2",
|
"@types/papaparse": "^5.5.2",
|
||||||
"@types/react": "^18.3.11",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^4.3.2",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"typescript": "^5.5.3",
|
"esbuild": "^0.28.0",
|
||||||
"vite": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vitest": "^4.1.3"
|
"vite": "^8.0.10",
|
||||||
|
"vitest": "^4.1.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+643
-899
File diff suppressed because it is too large
Load Diff
+14
-22
@@ -30,38 +30,39 @@ tauri-plugin-dialog = "2"
|
|||||||
tauri-plugin-fs = "2"
|
tauri-plugin-fs = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
rodio = { version = "0.19", default-features = false, features = ["symphonia-all"] }
|
rodio = { version = "0.22", default-features = false, features = ["playback", "symphonia-all"] }
|
||||||
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
|
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["stream", "json", "multipart", "rustls-tls", "rustls-tls-native-roots", "blocking"] }
|
reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "multipart", "query", "form", "rustls", "blocking"] }
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
md5 = "0.7"
|
md5 = "0.8"
|
||||||
tokio = { version = "1", features = ["rt", "time", "sync"] }
|
tokio = { version = "1", features = ["rt", "time", "sync"] }
|
||||||
biquad = "0.4"
|
biquad = "0.6"
|
||||||
ringbuf = "0.3"
|
ringbuf = "0.5"
|
||||||
tauri-plugin-window-state = "2.4.1"
|
tauri-plugin-window-state = "2.4.1"
|
||||||
tauri-plugin-process = "2"
|
tauri-plugin-process = "2"
|
||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
|
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
|
||||||
discord-rich-presence = "0.2"
|
discord-rich-presence = "1.1"
|
||||||
url = "2"
|
url = "2"
|
||||||
thread-priority = "1"
|
thread-priority = "3"
|
||||||
lofty = "0.22"
|
lofty = "0.24"
|
||||||
sysinfo = { version = "0.33", default-features = false, features = ["disk"] }
|
sysinfo = { version = "0.38", default-features = false, features = ["disk"] }
|
||||||
id3 = "1.16.4"
|
id3 = "1.16.4"
|
||||||
symphonia-adapter-libopus = "0.2.7"
|
symphonia-adapter-libopus = "0.2.9"
|
||||||
rusqlite = { version = "0.37", features = ["bundled"] }
|
rusqlite = { version = "0.39", features = ["bundled"] }
|
||||||
ebur128 = "0.1"
|
ebur128 = "0.1"
|
||||||
|
dasp_sample = "0.11.0"
|
||||||
|
|
||||||
[target.'cfg(unix)'.dependencies]
|
[target.'cfg(unix)'.dependencies]
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
zbus = { version = "5.9", default-features = false, features = ["blocking-api"] }
|
zbus = { version = "5.15", default-features = false, features = ["blocking-api"] }
|
||||||
# Match wry/tauri’s WebKitGTK stack — used only to turn off kinetic wheel scrolling.
|
# Match wry/tauri’s WebKitGTK stack — used only to turn off kinetic wheel scrolling.
|
||||||
webkit2gtk = { version = "2.0", default-features = false, features = ["v2_40"] }
|
webkit2gtk = { version = "2.0", default-features = false, features = ["v2_40"] }
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
windows = { version = "0.58", features = [
|
windows = { version = "0.62", features = [
|
||||||
"Win32_Foundation",
|
"Win32_Foundation",
|
||||||
"Win32_Graphics",
|
"Win32_Graphics",
|
||||||
"Win32_Graphics_Gdi",
|
"Win32_Graphics_Gdi",
|
||||||
@@ -80,12 +81,3 @@ windows = { version = "0.58", features = [
|
|||||||
# of failing the entire probe
|
# of failing the entire probe
|
||||||
symphonia-format-isomp4 = { path = "patches/symphonia-format-isomp4" }
|
symphonia-format-isomp4 = { path = "patches/symphonia-format-isomp4" }
|
||||||
|
|
||||||
# Local patch for cpal's CoreAudio backend on macOS:
|
|
||||||
# - Forces IOType::DefaultOutput for all output streams so that macOS never
|
|
||||||
# triggers the TCC microphone-permission prompt. Upstream cpal uses
|
|
||||||
# IOType::HalOutput when the user pins a non-default device, which routes
|
|
||||||
# through AUHAL — that component is flagged as input-capable and triggers
|
|
||||||
# the mic consent dialog on first launch / after each update.
|
|
||||||
# - Tradeoff: output-device selection is a no-op on macOS (stream follows
|
|
||||||
# system default). Surfaced in the Settings UI.
|
|
||||||
cpal = { path = "patches/cpal-0.15.3" }
|
|
||||||
|
|||||||
@@ -303,6 +303,15 @@ impl AnalysisCache {
|
|||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Both waveform and loudness rows exist — a CPU seed from bytes/file would only
|
||||||
|
/// decode the file to immediately skip with `SkippedWaveformCacheHit`.
|
||||||
|
pub fn cpu_seed_redundant_for_track(&self, track_id: &str) -> Result<bool, String> {
|
||||||
|
Ok(
|
||||||
|
self.get_latest_waveform_for_track(track_id)?.is_some()
|
||||||
|
&& self.get_latest_loudness_for_track(track_id)?.is_some(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_latest_loudness_for_track(&self, track_id: &str) -> Result<Option<LoudnessSnapshot>, String> {
|
pub fn get_latest_loudness_for_track(&self, track_id: &str) -> Result<Option<LoudnessSnapshot>, String> {
|
||||||
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||||
const SQL: &str = r#"
|
const SQL: &str = r#"
|
||||||
|
|||||||
+121
-60
@@ -4,10 +4,12 @@ use std::sync::{Arc, Mutex, TryLockError};
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use ringbuf::{HeapConsumer, HeapRb};
|
use ringbuf::{HeapCons, HeapRb};
|
||||||
use rodio::{Sink, Source};
|
use ringbuf::traits::Split;
|
||||||
|
use rodio::Player;
|
||||||
|
use rodio::Source;
|
||||||
use symphonia::core::io::MediaSource;
|
use symphonia::core::io::MediaSource;
|
||||||
use tauri::{AppHandle, Emitter, State};
|
use tauri::{AppHandle, Emitter, Manager, State};
|
||||||
|
|
||||||
use super::decode::{build_source, build_streaming_source, SizedDecoder};
|
use super::decode::{build_source, build_streaming_source, SizedDecoder};
|
||||||
use super::dev_io::*;
|
use super::dev_io::*;
|
||||||
@@ -29,6 +31,9 @@ use super::stream::{
|
|||||||
/// `analysis_track_id`: Subsonic `song.id` from the UI — ties waveform/loudness
|
/// `analysis_track_id`: Subsonic `song.id` from the UI — ties waveform/loudness
|
||||||
/// cache to the track when playing `psysonic-local://` (hot/offline). Optional
|
/// cache to the track when playing `psysonic-local://` (hot/offline). Optional
|
||||||
/// for HTTP streams (`playback_identity` is used as fallback).
|
/// for HTTP streams (`playback_identity` is used as fallback).
|
||||||
|
///
|
||||||
|
/// `stream_format_suffix`: Subsonic `song.suffix` (e.g. m4a); `stream.view` URLs have no
|
||||||
|
/// file extension, so this helps pick a Symphonia `format_hint` for ranged HTTP.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn audio_play(
|
pub async fn audio_play(
|
||||||
url: String,
|
url: String,
|
||||||
@@ -42,6 +47,7 @@ pub async fn audio_play(
|
|||||||
manual: bool, // true = user-initiated skip → bypass crossfade, start immediately
|
manual: bool, // true = user-initiated skip → bypass crossfade, start immediately
|
||||||
hi_res_enabled: bool, // false = safe 44.1 kHz mode; true = native rate (alpha)
|
hi_res_enabled: bool, // false = safe 44.1 kHz mode; true = native rate (alpha)
|
||||||
analysis_track_id: Option<String>,
|
analysis_track_id: Option<String>,
|
||||||
|
stream_format_suffix: Option<String>,
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
state: State<'_, AudioEngine>,
|
state: State<'_, AudioEngine>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@@ -210,47 +216,53 @@ pub async fn audio_play(
|
|||||||
local_hint
|
local_hint
|
||||||
);
|
);
|
||||||
if let Some(ref seed_id) = cache_id_for_tasks {
|
if let Some(ref seed_id) = cache_id_for_tasks {
|
||||||
let path_owned = std::path::PathBuf::from(path);
|
let skip_cpu_seed = app
|
||||||
let app_seed = app.clone();
|
.try_state::<crate::analysis_cache::AnalysisCache>()
|
||||||
let gen_seed = gen;
|
.map(|c| c.cpu_seed_redundant_for_track(seed_id).unwrap_or(false))
|
||||||
let gen_arc_seed = state.generation.clone();
|
.unwrap_or(false);
|
||||||
let seed_id = seed_id.clone();
|
if !skip_cpu_seed {
|
||||||
tokio::spawn(async move {
|
let path_owned = std::path::PathBuf::from(path);
|
||||||
if gen_arc_seed.load(Ordering::SeqCst) != gen_seed {
|
let app_seed = app.clone();
|
||||||
return;
|
let gen_seed = gen;
|
||||||
}
|
let gen_arc_seed = state.generation.clone();
|
||||||
let data = match tokio::fs::read(&path_owned).await {
|
let seed_id = seed_id.clone();
|
||||||
Ok(d) => d,
|
tokio::spawn(async move {
|
||||||
Err(_) => return,
|
if gen_arc_seed.load(Ordering::SeqCst) != gen_seed {
|
||||||
};
|
return;
|
||||||
if gen_arc_seed.load(Ordering::SeqCst) != gen_seed {
|
}
|
||||||
return;
|
let data = match tokio::fs::read(&path_owned).await {
|
||||||
}
|
Ok(d) => d,
|
||||||
if data.is_empty() || data.len() > LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES {
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
if gen_arc_seed.load(Ordering::SeqCst) != gen_seed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if data.is_empty() || data.len() > LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES {
|
||||||
|
crate::app_deprintln!(
|
||||||
|
"[stream] psysonic-local: skip analysis seed track_id={} bytes={} (over {} MiB cap)",
|
||||||
|
seed_id,
|
||||||
|
data.len(),
|
||||||
|
LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES / (1024 * 1024)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
crate::app_deprintln!(
|
crate::app_deprintln!(
|
||||||
"[stream] psysonic-local: skip analysis seed track_id={} bytes={} (over {} MiB cap)",
|
"[stream] psysonic-local: file read complete track_id={} size_mib={:.2} — full-track analysis (cpu-seed queue)",
|
||||||
seed_id,
|
seed_id,
|
||||||
data.len(),
|
data.len() as f64 / (1024.0 * 1024.0)
|
||||||
LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES / (1024 * 1024)
|
|
||||||
);
|
);
|
||||||
return;
|
let high = crate::audio::engine::analysis_seed_high_priority_for_track(&app_seed, &seed_id);
|
||||||
}
|
if let Err(e) =
|
||||||
crate::app_deprintln!(
|
crate::submit_analysis_cpu_seed(app_seed.clone(), seed_id.clone(), data, high).await
|
||||||
"[stream] psysonic-local: file read complete track_id={} size_mib={:.2} — full-track analysis (cpu-seed queue)",
|
{
|
||||||
seed_id,
|
crate::app_eprintln!(
|
||||||
data.len() as f64 / (1024.0 * 1024.0)
|
"[analysis] local-file seed failed for {}: {}",
|
||||||
);
|
seed_id,
|
||||||
let high = crate::audio::engine::analysis_seed_high_priority_for_track(&app_seed, &seed_id);
|
e
|
||||||
if let Err(e) =
|
);
|
||||||
crate::submit_analysis_cpu_seed(app_seed.clone(), seed_id.clone(), data, high).await
|
}
|
||||||
{
|
});
|
||||||
crate::app_eprintln!(
|
}
|
||||||
"[analysis] local-file seed failed for {}: {}",
|
|
||||||
seed_id,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
let reader = LocalFileSource { file, len };
|
let reader = LocalFileSource { file, len };
|
||||||
PlayInput::SeekableMedia {
|
PlayInput::SeekableMedia {
|
||||||
@@ -258,7 +270,11 @@ pub async fn audio_play(
|
|||||||
format_hint: local_hint,
|
format_hint: local_hint,
|
||||||
tag: "local-file",
|
tag: "local-file",
|
||||||
}
|
}
|
||||||
} else if manual && !stream_cache_hit && !preloaded_hit && !is_local {
|
} else if !stream_cache_hit && !preloaded_hit && !is_local {
|
||||||
|
// `manual` must NOT gate this branch: natural track end calls `next(false)`,
|
||||||
|
// so auto-advance must still use ranged HTTP when available. Gating used to
|
||||||
|
// force every auto-start through `fetch_data` (full RAM buffer + extra fetch log)
|
||||||
|
// instead of `RangedHttpSource`.
|
||||||
let response = audio_http_client(&state).get(&url).send().await.map_err(|e| e.to_string())?;
|
let response = audio_http_client(&state).get(&url).send().await.map_err(|e| e.to_string())?;
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
if state.generation.load(Ordering::SeqCst) != gen {
|
if state.generation.load(Ordering::SeqCst) != gen {
|
||||||
@@ -270,13 +286,22 @@ pub async fn audio_play(
|
|||||||
return Err(msg);
|
return Err(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
let stream_hint = content_type_to_hint(
|
let mut stream_hint = content_type_to_hint(
|
||||||
response
|
response
|
||||||
.headers()
|
.headers()
|
||||||
.get("content-type")
|
.get(reqwest::header::CONTENT_TYPE)
|
||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
.unwrap_or(""),
|
.unwrap_or(""),
|
||||||
).or_else(|| format_hint.clone());
|
)
|
||||||
|
.or_else(|| {
|
||||||
|
response
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_DISPOSITION)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|cd| format_hint_from_content_disposition(cd))
|
||||||
|
})
|
||||||
|
.or_else(|| normalize_stream_suffix_for_hint(stream_format_suffix.as_deref()))
|
||||||
|
.or_else(|| format_hint.clone());
|
||||||
|
|
||||||
let supports_range = response.headers()
|
let supports_range = response.headers()
|
||||||
.get(reqwest::header::ACCEPT_RANGES)
|
.get(reqwest::header::ACCEPT_RANGES)
|
||||||
@@ -284,6 +309,39 @@ pub async fn audio_play(
|
|||||||
.is_some_and(|v| v.to_ascii_lowercase().contains("bytes"));
|
.is_some_and(|v| v.to_ascii_lowercase().contains("bytes"));
|
||||||
let total_size = response.content_length();
|
let total_size = response.content_length();
|
||||||
|
|
||||||
|
if stream_hint.is_none() && supports_range {
|
||||||
|
if let Some(total_u64) = total_size.filter(|&t| t > 0) {
|
||||||
|
let last = total_u64
|
||||||
|
.saturating_sub(1)
|
||||||
|
.min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64);
|
||||||
|
if let Ok(pr) = audio_http_client(&state)
|
||||||
|
.get(&url)
|
||||||
|
.header(reqwest::header::RANGE, format!("bytes=0-{last}"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
let stat = pr.status();
|
||||||
|
let ok = stat == reqwest::StatusCode::PARTIAL_CONTENT
|
||||||
|
|| stat == reqwest::StatusCode::OK;
|
||||||
|
if ok {
|
||||||
|
match pr.bytes().await {
|
||||||
|
Ok(bytes) if !bytes.is_empty() => {
|
||||||
|
stream_hint = sniff_stream_format_extension(&bytes).or(stream_hint);
|
||||||
|
if stream_hint.is_some() {
|
||||||
|
crate::app_deprintln!(
|
||||||
|
"[stream] ranged: format sniff from {} B prefix → hint={:?}",
|
||||||
|
bytes.len(),
|
||||||
|
stream_hint
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Guardrail: when format/container hint is unknown, some demuxers may
|
// Guardrail: when format/container hint is unknown, some demuxers may
|
||||||
// seek near EOF during probe. With a progressively downloaded ranged
|
// seek near EOF during probe. With a progressively downloaded ranged
|
||||||
// source that can delay first audible samples until most/all bytes are
|
// source that can delay first audible samples until most/all bytes are
|
||||||
@@ -360,9 +418,9 @@ pub async fn audio_play(
|
|||||||
cache_id_for_tasks.clone(),
|
cache_id_for_tasks.clone(),
|
||||||
));
|
));
|
||||||
|
|
||||||
let (_new_cons_tx, new_cons_rx) = std::sync::mpsc::channel::<HeapConsumer<u8>>();
|
let (_new_cons_tx, new_cons_rx) = std::sync::mpsc::channel::<HeapCons<u8>>();
|
||||||
let reader = AudioStreamReader {
|
let reader = AudioStreamReader {
|
||||||
cons,
|
cons: Mutex::new(cons),
|
||||||
new_cons_rx: Mutex::new(new_cons_rx),
|
new_cons_rx: Mutex::new(new_cons_rx),
|
||||||
deadline: std::time::Instant::now()
|
deadline: std::time::Instant::now()
|
||||||
+ Duration::from_secs(RADIO_READ_TIMEOUT_SECS),
|
+ Duration::from_secs(RADIO_READ_TIMEOUT_SECS),
|
||||||
@@ -570,7 +628,7 @@ pub async fn audio_play(
|
|||||||
};
|
};
|
||||||
let needs_switch = target_rate > 0 && target_rate != current_stream_rate;
|
let needs_switch = target_rate > 0 && target_rate != current_stream_rate;
|
||||||
if needs_switch {
|
if needs_switch {
|
||||||
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel::<rodio::OutputStreamHandle>(0);
|
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel::<Arc<rodio::MixerDeviceSink>>(0);
|
||||||
let dev = state.selected_device.lock().unwrap().clone();
|
let dev = state.selected_device.lock().unwrap().clone();
|
||||||
if state.stream_reopen_tx.send((target_rate, hi_res_enabled, dev, reply_tx)).is_ok() {
|
if state.stream_reopen_tx.send((target_rate, hi_res_enabled, dev, reply_tx)).is_ok() {
|
||||||
match reply_rx.recv_timeout(std::time::Duration::from_secs(5)) {
|
match reply_rx.recv_timeout(std::time::Duration::from_secs(5)) {
|
||||||
@@ -596,7 +654,7 @@ pub async fn audio_play(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let sink = Arc::new(Sink::try_new(&*state.stream_handle.lock().unwrap()).map_err(|e| e.to_string())?);
|
let sink = Arc::new(Player::connect_new(state.stream_handle.lock().unwrap().mixer()));
|
||||||
sink.set_volume(effective_volume);
|
sink.set_volume(effective_volume);
|
||||||
|
|
||||||
// ── Sink pre-fill for hi-res tracks ──────────────────────────────────────
|
// ── Sink pre-fill for hi-res tracks ──────────────────────────────────────
|
||||||
@@ -636,8 +694,8 @@ pub async fn audio_play(
|
|||||||
let ch = source.channels();
|
let ch = source.channels();
|
||||||
let sr = source.sample_rate();
|
let sr = source.sample_rate();
|
||||||
// 500 ms in whole frames, then expand to interleaved samples.
|
// 500 ms in whole frames, then expand to interleaved samples.
|
||||||
let frames = (sr / 2) as usize;
|
let frames = (sr.get() / 2) as usize;
|
||||||
let total_samples = frames.saturating_mul(ch as usize);
|
let total_samples = frames.saturating_mul(ch.get() as usize);
|
||||||
let silence = rodio::buffer::SamplesBuffer::new(ch, sr, vec![0f32; total_samples]);
|
let silence = rodio::buffer::SamplesBuffer::new(ch, sr, vec![0f32; total_samples]);
|
||||||
sink.append(silence);
|
sink.append(silence);
|
||||||
}
|
}
|
||||||
@@ -1219,7 +1277,9 @@ pub fn audio_stop(state: State<'_, AudioEngine>, app: AppHandle) {
|
|||||||
*state.current_playback_url.lock().unwrap() = None;
|
*state.current_playback_url.lock().unwrap() = None;
|
||||||
*state.current_analysis_track_id.lock().unwrap() = None;
|
*state.current_analysis_track_id.lock().unwrap() = None;
|
||||||
*state.chained_info.lock().unwrap() = None;
|
*state.chained_info.lock().unwrap() = None;
|
||||||
*state.stream_completed_cache.lock().unwrap() = None;
|
// Keep `stream_completed_cache`: natural track end often calls `audio_stop` when the
|
||||||
|
// queue is exhausted; clearing here dropped the full ranged buffer and forced a
|
||||||
|
// re-download on replay. The slot is only consumed on `take`/overwrite for another URL.
|
||||||
// Drop RadioLiveState → triggers Drop → task.abort() → TCP released.
|
// Drop RadioLiveState → triggers Drop → task.abort() → TCP released.
|
||||||
drop(state.radio_state.lock().unwrap().take());
|
drop(state.radio_state.lock().unwrap().take());
|
||||||
let mut cur = state.current.lock().unwrap();
|
let mut cur = state.current.lock().unwrap();
|
||||||
@@ -1604,7 +1664,7 @@ pub async fn audio_play_radio(
|
|||||||
let rb = HeapRb::<u8>::new(RADIO_BUF_CAPACITY);
|
let rb = HeapRb::<u8>::new(RADIO_BUF_CAPACITY);
|
||||||
let (prod, cons) = rb.split();
|
let (prod, cons) = rb.split();
|
||||||
|
|
||||||
let (new_cons_tx, new_cons_rx) = std::sync::mpsc::channel::<HeapConsumer<u8>>();
|
let (new_cons_tx, new_cons_rx) = std::sync::mpsc::channel::<HeapCons<u8>>();
|
||||||
let flags = Arc::new(RadioSharedFlags {
|
let flags = Arc::new(RadioSharedFlags {
|
||||||
is_paused: AtomicBool::new(false),
|
is_paused: AtomicBool::new(false),
|
||||||
is_hard_paused: AtomicBool::new(false),
|
is_hard_paused: AtomicBool::new(false),
|
||||||
@@ -1632,7 +1692,7 @@ pub async fn audio_play_radio(
|
|||||||
|
|
||||||
// ── Build Symphonia decoder in a blocking thread ──────────────────────────
|
// ── Build Symphonia decoder in a blocking thread ──────────────────────────
|
||||||
let reader = AudioStreamReader {
|
let reader = AudioStreamReader {
|
||||||
cons,
|
cons: Mutex::new(cons),
|
||||||
new_cons_rx: Mutex::new(new_cons_rx),
|
new_cons_rx: Mutex::new(new_cons_rx),
|
||||||
deadline: std::time::Instant::now() + Duration::from_secs(RADIO_READ_TIMEOUT_SECS),
|
deadline: std::time::Instant::now() + Duration::from_secs(RADIO_READ_TIMEOUT_SECS),
|
||||||
gen_arc: state.generation.clone(),
|
gen_arc: state.generation.clone(),
|
||||||
@@ -1661,7 +1721,7 @@ pub async fn audio_play_radio(
|
|||||||
state.samples_played.store(0, Ordering::Relaxed);
|
state.samples_played.store(0, Ordering::Relaxed);
|
||||||
|
|
||||||
// Radio: no gapless trim, no ReplayGain, 5 ms fade-in to suppress click.
|
// Radio: no gapless trim, no ReplayGain, 5 ms fade-in to suppress click.
|
||||||
let dyn_src = DynSource::new(decoder.convert_samples::<f32>());
|
let dyn_src = DynSource::new(decoder);
|
||||||
let eq_src = EqSource::new(dyn_src, state.eq_gains.clone(),
|
let eq_src = EqSource::new(dyn_src, state.eq_gains.clone(),
|
||||||
state.eq_enabled.clone(), state.eq_pre_gain.clone());
|
state.eq_enabled.clone(), state.eq_pre_gain.clone());
|
||||||
let fade_in = EqualPowerFadeIn::new(eq_src, Duration::from_millis(5));
|
let fade_in = EqualPowerFadeIn::new(eq_src, Duration::from_millis(5));
|
||||||
@@ -1672,7 +1732,7 @@ pub async fn audio_play_radio(
|
|||||||
|
|
||||||
if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); }
|
if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); }
|
||||||
|
|
||||||
let sink = Arc::new(Sink::try_new(&*state.stream_handle.lock().unwrap()).map_err(|e| e.to_string())?);
|
let sink = Arc::new(Player::connect_new(state.stream_handle.lock().unwrap().mixer()));
|
||||||
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
|
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
|
||||||
sink.append(boosted);
|
sink.append(boosted);
|
||||||
|
|
||||||
@@ -1692,8 +1752,8 @@ pub async fn audio_play_radio(
|
|||||||
|
|
||||||
*state.current_playback_url.lock().unwrap() = Some(url.clone());
|
*state.current_playback_url.lock().unwrap() = Some(url.clone());
|
||||||
|
|
||||||
state.current_sample_rate.store(sample_rate, Ordering::Relaxed);
|
state.current_sample_rate.store(sample_rate.get(), Ordering::Relaxed);
|
||||||
state.current_channels.store(channels as u32, Ordering::Relaxed);
|
state.current_channels.store(channels.get() as u32, Ordering::Relaxed);
|
||||||
|
|
||||||
app.emit("audio:playing", 0.0f64).ok();
|
app.emit("audio:playing", 0.0f64).ok();
|
||||||
|
|
||||||
@@ -1764,7 +1824,8 @@ pub fn audio_default_output_device_name() -> Option<String> {
|
|||||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||||
with_suppressed_alsa_stderr(|| {
|
with_suppressed_alsa_stderr(|| {
|
||||||
let host = rodio::cpal::default_host();
|
let host = rodio::cpal::default_host();
|
||||||
host.default_output_device().and_then(|d| d.name().ok())
|
host.default_output_device()
|
||||||
|
.and_then(|d| d.description().ok().map(|desc| desc.name().to_string()))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1779,7 +1840,7 @@ pub async fn audio_set_device(
|
|||||||
*state.selected_device.lock().unwrap() = device_name.clone();
|
*state.selected_device.lock().unwrap() = device_name.clone();
|
||||||
|
|
||||||
let rate = state.stream_sample_rate.load(Ordering::Relaxed);
|
let rate = state.stream_sample_rate.load(Ordering::Relaxed);
|
||||||
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel::<rodio::OutputStreamHandle>(0);
|
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel::<Arc<rodio::MixerDeviceSink>>(0);
|
||||||
state.stream_reopen_tx
|
state.stream_reopen_tx
|
||||||
.send((rate, false, device_name, reply_tx))
|
.send((rate, false, device_name, reply_tx))
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ pub(crate) struct SizedDecoder {
|
|||||||
current_frame_offset: usize,
|
current_frame_offset: usize,
|
||||||
format: Box<dyn FormatReader>,
|
format: Box<dyn FormatReader>,
|
||||||
total_duration: Option<Time>,
|
total_duration: Option<Time>,
|
||||||
buffer: SampleBuffer<i16>,
|
buffer: SampleBuffer<f32>,
|
||||||
spec: SignalSpec,
|
spec: SignalSpec,
|
||||||
/// Counts consecutive DecodeErrors in the hot-path. Reset to 0 on every
|
/// Counts consecutive DecodeErrors in the hot-path. Reset to 0 on every
|
||||||
/// successfully decoded frame. Used to detect fully undecodable streams.
|
/// successfully decoded frame. Used to detect fully undecodable streams.
|
||||||
@@ -297,9 +297,9 @@ impl SizedDecoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
fn make_buffer(decoded: AudioBufferRef, spec: &SignalSpec) -> SampleBuffer<i16> {
|
fn make_buffer(decoded: AudioBufferRef, spec: &SignalSpec) -> SampleBuffer<f32> {
|
||||||
let duration = units::Duration::from(decoded.capacity() as u64);
|
let duration = units::Duration::from(decoded.capacity() as u64);
|
||||||
let mut buffer = SampleBuffer::<i16>::new(duration, *spec);
|
let mut buffer = SampleBuffer::<f32>::new(duration, *spec);
|
||||||
buffer.copy_interleaved_ref(decoded);
|
buffer.copy_interleaved_ref(decoded);
|
||||||
buffer
|
buffer
|
||||||
}
|
}
|
||||||
@@ -338,10 +338,10 @@ impl SizedDecoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Iterator for SizedDecoder {
|
impl Iterator for SizedDecoder {
|
||||||
type Item = i16;
|
type Item = f32;
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
fn next(&mut self) -> Option<i16> {
|
fn next(&mut self) -> Option<f32> {
|
||||||
if self.current_frame_offset >= self.buffer.len() {
|
if self.current_frame_offset >= self.buffer.len() {
|
||||||
// Loop until a decodable packet is found or the stream ends.
|
// Loop until a decodable packet is found or the stream ends.
|
||||||
// DecodeErrors (e.g. MP3 "invalid main_data offset") are non-fatal:
|
// DecodeErrors (e.g. MP3 "invalid main_data offset") are non-fatal:
|
||||||
@@ -392,18 +392,19 @@ impl Iterator for SizedDecoder {
|
|||||||
|
|
||||||
impl Source for SizedDecoder {
|
impl Source for SizedDecoder {
|
||||||
#[inline]
|
#[inline]
|
||||||
fn current_frame_len(&self) -> Option<usize> {
|
fn current_span_len(&self) -> Option<usize> {
|
||||||
Some(self.buffer.samples().len())
|
Some(self.buffer.samples().len())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
fn channels(&self) -> u16 {
|
fn channels(&self) -> rodio::ChannelCount {
|
||||||
self.spec.channels.count() as u16
|
std::num::NonZeroU16::new(self.spec.channels.count() as u16)
|
||||||
|
.unwrap_or(std::num::NonZeroU16::MIN)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
fn sample_rate(&self) -> u32 {
|
fn sample_rate(&self) -> rodio::SampleRate {
|
||||||
self.spec.rate
|
std::num::NonZeroU32::new(self.spec.rate).unwrap_or(std::num::NonZeroU32::MIN)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
@@ -432,18 +433,18 @@ impl Source for SizedDecoder {
|
|||||||
pos.as_secs_f64().into()
|
pos.as_secs_f64().into()
|
||||||
};
|
};
|
||||||
|
|
||||||
let to_skip = self.current_frame_offset % self.channels() as usize;
|
let to_skip = self.current_frame_offset % self.channels().get() as usize;
|
||||||
|
|
||||||
let seek_res = self
|
let seek_res = self
|
||||||
.format
|
.format
|
||||||
.seek(SeekMode::Accurate, SeekTo::Time { time, track_id: None })
|
.seek(SeekMode::Accurate, SeekTo::Time { time, track_id: None })
|
||||||
.map_err(|e| rodio::source::SeekError::Other(
|
.map_err(|e| rodio::source::SeekError::Other(
|
||||||
Box::new(std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))
|
std::sync::Arc::new(std::io::Error::other(e.to_string()))
|
||||||
))?;
|
))?;
|
||||||
|
|
||||||
self.refine_position(seek_res)
|
self.refine_position(seek_res)
|
||||||
.map_err(|e| rodio::source::SeekError::Other(
|
.map_err(|e| rodio::source::SeekError::Other(
|
||||||
Box::new(std::io::Error::new(std::io::ErrorKind::Other, e))
|
std::sync::Arc::new(std::io::Error::other(e))
|
||||||
))?;
|
))?;
|
||||||
|
|
||||||
self.current_frame_offset += to_skip;
|
self.current_frame_offset += to_skip;
|
||||||
@@ -573,35 +574,47 @@ pub(crate) fn build_source(
|
|||||||
// then resample to the canonical target rate if needed.
|
// then resample to the canonical target rate if needed.
|
||||||
let dyn_src: DynSource = if gapless.delay_samples > 0 || gapless.total_valid_samples.is_some() {
|
let dyn_src: DynSource = if gapless.delay_samples > 0 || gapless.total_valid_samples.is_some() {
|
||||||
let delay_dur = Duration::from_secs_f64(
|
let delay_dur = Duration::from_secs_f64(
|
||||||
gapless.delay_samples as f64 / sample_rate as f64
|
gapless.delay_samples as f64 / sample_rate.get() as f64
|
||||||
);
|
);
|
||||||
let base = decoder.convert_samples::<f32>().skip_duration(delay_dur);
|
let base = decoder.skip_duration(delay_dur);
|
||||||
|
|
||||||
if let Some(total) = gapless.total_valid_samples {
|
if let Some(total) = gapless.total_valid_samples {
|
||||||
let valid_dur = Duration::from_secs_f64(total as f64 / sample_rate as f64);
|
let valid_dur = Duration::from_secs_f64(total as f64 / sample_rate.get() as f64);
|
||||||
let trimmed = base.take_duration(valid_dur);
|
let trimmed = base.take_duration(valid_dur);
|
||||||
if target_rate > 0 && sample_rate != target_rate {
|
if target_rate > 0 && sample_rate.get() != target_rate {
|
||||||
DynSource::new(UniformSourceIterator::new(trimmed, channels, target_rate))
|
DynSource::new(UniformSourceIterator::new(
|
||||||
|
trimmed,
|
||||||
|
channels,
|
||||||
|
std::num::NonZeroU32::new(target_rate).unwrap_or(std::num::NonZeroU32::MIN),
|
||||||
|
))
|
||||||
} else {
|
} else {
|
||||||
DynSource::new(trimmed)
|
DynSource::new(trimmed)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if target_rate > 0 && sample_rate != target_rate {
|
if target_rate > 0 && sample_rate.get() != target_rate {
|
||||||
DynSource::new(UniformSourceIterator::new(base, channels, target_rate))
|
DynSource::new(UniformSourceIterator::new(
|
||||||
|
base,
|
||||||
|
channels,
|
||||||
|
std::num::NonZeroU32::new(target_rate).unwrap_or(std::num::NonZeroU32::MIN),
|
||||||
|
))
|
||||||
} else {
|
} else {
|
||||||
DynSource::new(base)
|
DynSource::new(base)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let converted = decoder.convert_samples::<f32>();
|
let converted = decoder;
|
||||||
if target_rate > 0 && sample_rate != target_rate {
|
if target_rate > 0 && sample_rate.get() != target_rate {
|
||||||
DynSource::new(UniformSourceIterator::new(converted, channels, target_rate))
|
DynSource::new(UniformSourceIterator::new(
|
||||||
|
converted,
|
||||||
|
channels,
|
||||||
|
std::num::NonZeroU32::new(target_rate).unwrap_or(std::num::NonZeroU32::MIN),
|
||||||
|
))
|
||||||
} else {
|
} else {
|
||||||
DynSource::new(converted)
|
DynSource::new(converted)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let output_rate = if target_rate > 0 && sample_rate != target_rate { target_rate } else { sample_rate };
|
let output_rate = if target_rate > 0 && sample_rate.get() != target_rate { target_rate } else { sample_rate.get() };
|
||||||
|
|
||||||
let fadeout_trigger = Arc::new(AtomicBool::new(false));
|
let fadeout_trigger = Arc::new(AtomicBool::new(false));
|
||||||
let fadeout_samples = Arc::new(AtomicU64::new(0));
|
let fadeout_samples = Arc::new(AtomicU64::new(0));
|
||||||
@@ -617,7 +630,7 @@ pub(crate) fn build_source(
|
|||||||
source: boosted,
|
source: boosted,
|
||||||
duration_secs: effective_dur,
|
duration_secs: effective_dur,
|
||||||
output_rate,
|
output_rate,
|
||||||
output_channels: channels,
|
output_channels: channels.get(),
|
||||||
fadeout_trigger,
|
fadeout_trigger,
|
||||||
fadeout_samples,
|
fadeout_samples,
|
||||||
})
|
})
|
||||||
@@ -650,17 +663,21 @@ pub(crate) fn build_streaming_source(
|
|||||||
.unwrap_or(duration_hint)
|
.unwrap_or(duration_hint)
|
||||||
};
|
};
|
||||||
|
|
||||||
let converted = decoder.convert_samples::<f32>();
|
let converted = decoder;
|
||||||
let dyn_src: DynSource = if target_rate > 0 && sample_rate != target_rate {
|
let dyn_src: DynSource = if target_rate > 0 && sample_rate.get() != target_rate {
|
||||||
DynSource::new(UniformSourceIterator::new(converted, channels, target_rate))
|
DynSource::new(UniformSourceIterator::new(
|
||||||
|
converted,
|
||||||
|
channels,
|
||||||
|
std::num::NonZeroU32::new(target_rate).unwrap_or(std::num::NonZeroU32::MIN),
|
||||||
|
))
|
||||||
} else {
|
} else {
|
||||||
DynSource::new(converted)
|
DynSource::new(converted)
|
||||||
};
|
};
|
||||||
|
|
||||||
let output_rate = if target_rate > 0 && sample_rate != target_rate {
|
let output_rate = if target_rate > 0 && sample_rate.get() != target_rate {
|
||||||
target_rate
|
target_rate
|
||||||
} else {
|
} else {
|
||||||
sample_rate
|
sample_rate.get()
|
||||||
};
|
};
|
||||||
|
|
||||||
let fadeout_trigger = Arc::new(AtomicBool::new(false));
|
let fadeout_trigger = Arc::new(AtomicBool::new(false));
|
||||||
@@ -677,7 +694,7 @@ pub(crate) fn build_streaming_source(
|
|||||||
source: boosted,
|
source: boosted,
|
||||||
duration_secs: effective_dur,
|
duration_secs: effective_dur,
|
||||||
output_rate,
|
output_rate,
|
||||||
output_channels: channels,
|
output_channels: channels.get(),
|
||||||
fadeout_trigger,
|
fadeout_trigger,
|
||||||
fadeout_samples,
|
fadeout_samples,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ pub(crate) fn enumerate_output_device_names() -> Vec<String> {
|
|||||||
with_suppressed_alsa_stderr(|| {
|
with_suppressed_alsa_stderr(|| {
|
||||||
let host = rodio::cpal::default_host();
|
let host = rodio::cpal::default_host();
|
||||||
host.output_devices()
|
host.output_devices()
|
||||||
.map(|iter| iter.filter_map(|d| d.name().ok()).collect())
|
.map(|iter| {
|
||||||
|
iter.filter_map(|d| d.description().ok().map(|desc| desc.name().to_string()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! Poll default output device and pinned-device presence; reopen stream when needed.
|
//! Poll default output device and pinned-device presence; reopen stream when needed.
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use tauri::Emitter;
|
use tauri::Emitter;
|
||||||
@@ -21,7 +22,7 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
|||||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||||
rodio::cpal::default_host()
|
rodio::cpal::default_host()
|
||||||
.default_output_device()
|
.default_output_device()
|
||||||
.and_then(|d| d.name().ok())
|
.and_then(|d| d.description().ok().map(|desc| desc.name().to_string()))
|
||||||
}).await.unwrap_or(None);
|
}).await.unwrap_or(None);
|
||||||
|
|
||||||
// macOS/Windows: consecutive polls where a pinned device is absent from cpal's list.
|
// macOS/Windows: consecutive polls where a pinned device is absent from cpal's list.
|
||||||
@@ -48,10 +49,15 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
|||||||
StderrGuard(saved)
|
StderrGuard(saved)
|
||||||
};
|
};
|
||||||
let host = rodio::cpal::default_host();
|
let host = rodio::cpal::default_host();
|
||||||
let default = host.default_output_device().and_then(|d| d.name().ok());
|
let default = host
|
||||||
|
.default_output_device()
|
||||||
|
.and_then(|d| d.description().ok().map(|desc| desc.name().to_string()));
|
||||||
let available: Vec<String> = host
|
let available: Vec<String> = host
|
||||||
.output_devices()
|
.output_devices()
|
||||||
.map(|iter| iter.filter_map(|d| d.name().ok()).collect())
|
.map(|iter| {
|
||||||
|
iter.filter_map(|d| d.description().ok().map(|desc| desc.name().to_string()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
(default, available)
|
(default, available)
|
||||||
}).await.unwrap_or((None, vec![]));
|
}).await.unwrap_or((None, vec![]));
|
||||||
@@ -94,7 +100,7 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
|||||||
let reopen_tx2 = reopen_tx.clone();
|
let reopen_tx2 = reopen_tx.clone();
|
||||||
let new_handle = tauri::async_runtime::spawn_blocking(move || {
|
let new_handle = tauri::async_runtime::spawn_blocking(move || {
|
||||||
let (reply_tx, reply_rx) =
|
let (reply_tx, reply_rx) =
|
||||||
std::sync::mpsc::sync_channel::<rodio::OutputStreamHandle>(0);
|
std::sync::mpsc::sync_channel::<Arc<rodio::MixerDeviceSink>>(0);
|
||||||
if reopen_tx2.send((rate, false, None, reply_tx)).is_err() {
|
if reopen_tx2.send((rate, false, None, reply_tx)).is_err() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -131,7 +137,7 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
|||||||
let reopen_tx2 = reopen_tx.clone();
|
let reopen_tx2 = reopen_tx.clone();
|
||||||
let new_handle = tauri::async_runtime::spawn_blocking(move || {
|
let new_handle = tauri::async_runtime::spawn_blocking(move || {
|
||||||
let (reply_tx, reply_rx) =
|
let (reply_tx, reply_rx) =
|
||||||
std::sync::mpsc::sync_channel::<rodio::OutputStreamHandle>(0);
|
std::sync::mpsc::sync_channel::<Arc<rodio::MixerDeviceSink>>(0);
|
||||||
if reopen_tx2.send((rate, false, None, reply_tx)).is_err() {
|
if reopen_tx2.send((rate, false, None, reply_tx)).is_err() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64};
|
|||||||
use std::sync::{Arc, Mutex, RwLock};
|
use std::sync::{Arc, Mutex, RwLock};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use rodio::Sink;
|
use rodio::Player;
|
||||||
use tauri::{AppHandle, Manager};
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
use super::state::{ChainedInfo, PreloadedTrack};
|
use super::state::{ChainedInfo, PreloadedTrack};
|
||||||
|
|
||||||
pub struct AudioEngine {
|
pub struct AudioEngine {
|
||||||
pub stream_handle: Arc<std::sync::Mutex<rodio::OutputStreamHandle>>,
|
pub stream_handle: Arc<std::sync::Mutex<Arc<rodio::MixerDeviceSink>>>,
|
||||||
/// Sample rate the output stream was last opened at (updated on every re-open).
|
/// Sample rate the output stream was last opened at (updated on every re-open).
|
||||||
pub stream_sample_rate: Arc<AtomicU32>,
|
pub stream_sample_rate: Arc<AtomicU32>,
|
||||||
/// The rate the device was opened at on cold start — used to restore the
|
/// The rate the device was opened at on cold start — used to restore the
|
||||||
@@ -19,7 +19,7 @@ pub struct AudioEngine {
|
|||||||
pub device_default_rate: u32,
|
pub device_default_rate: u32,
|
||||||
/// Sends `(desired_rate, is_hi_res, device_name, reply_tx)` to the audio-stream
|
/// Sends `(desired_rate, is_hi_res, device_name, reply_tx)` to the audio-stream
|
||||||
/// thread to re-open the output device. `device_name = None` → system default.
|
/// thread to re-open the output device. `device_name = None` → system default.
|
||||||
pub stream_reopen_tx: std::sync::mpsc::SyncSender<(u32, bool, Option<String>, std::sync::mpsc::SyncSender<rodio::OutputStreamHandle>)>,
|
pub stream_reopen_tx: std::sync::mpsc::SyncSender<(u32, bool, Option<String>, std::sync::mpsc::SyncSender<Arc<rodio::MixerDeviceSink>>)>,
|
||||||
/// User-selected output device name (None = follow system default).
|
/// User-selected output device name (None = follow system default).
|
||||||
pub selected_device: Arc<Mutex<Option<String>>>,
|
pub selected_device: Arc<Mutex<Option<String>>>,
|
||||||
pub current: Arc<Mutex<AudioCurrent>>,
|
pub current: Arc<Mutex<AudioCurrent>>,
|
||||||
@@ -40,7 +40,7 @@ pub struct AudioEngine {
|
|||||||
pub(crate) current_is_seekable: Arc<AtomicBool>,
|
pub(crate) current_is_seekable: Arc<AtomicBool>,
|
||||||
pub crossfade_enabled: Arc<AtomicBool>,
|
pub crossfade_enabled: Arc<AtomicBool>,
|
||||||
pub crossfade_secs: Arc<AtomicU32>,
|
pub crossfade_secs: Arc<AtomicU32>,
|
||||||
pub fading_out_sink: Arc<Mutex<Option<Arc<Sink>>>>,
|
pub fading_out_sink: Arc<Mutex<Option<Arc<Player>>>>,
|
||||||
/// When true, audio_play chains sources to the existing Sink instead of
|
/// When true, audio_play chains sources to the existing Sink instead of
|
||||||
/// creating a new one, achieving sample-accurate gapless transitions.
|
/// creating a new one, achieving sample-accurate gapless transitions.
|
||||||
pub gapless_enabled: Arc<AtomicBool>,
|
pub gapless_enabled: Arc<AtomicBool>,
|
||||||
@@ -82,7 +82,7 @@ pub struct AudioEngine {
|
|||||||
/// Secondary sink dedicated to track previews. Runs on the same `OutputStream`
|
/// Secondary sink dedicated to track previews. Runs on the same `OutputStream`
|
||||||
/// as the main sink (rodio mixes both internally) so we don't open a second
|
/// as the main sink (rodio mixes both internally) so we don't open a second
|
||||||
/// device handle — important on ALSA-exclusive hardware.
|
/// device handle — important on ALSA-exclusive hardware.
|
||||||
pub(crate) preview_sink: Arc<Mutex<Option<Arc<Sink>>>>,
|
pub(crate) preview_sink: Arc<Mutex<Option<Arc<Player>>>>,
|
||||||
/// Cancel token for the active preview. Bumped on every `audio_preview_play`
|
/// Cancel token for the active preview. Bumped on every `audio_preview_play`
|
||||||
/// and `audio_preview_stop` so that orphan timer/progress tasks bail out.
|
/// and `audio_preview_stop` so that orphan timer/progress tasks bail out.
|
||||||
pub(crate) preview_gen: Arc<AtomicU64>,
|
pub(crate) preview_gen: Arc<AtomicU64>,
|
||||||
@@ -95,7 +95,7 @@ pub struct AudioEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct AudioCurrent {
|
pub struct AudioCurrent {
|
||||||
pub sink: Option<Arc<Sink>>,
|
pub sink: Option<Arc<Player>>,
|
||||||
pub duration_secs: f64,
|
pub duration_secs: f64,
|
||||||
pub seek_offset: f64,
|
pub seek_offset: f64,
|
||||||
pub play_started: Option<Instant>,
|
pub play_started: Option<Instant>,
|
||||||
@@ -133,8 +133,8 @@ impl AudioCurrent {
|
|||||||
/// 3. Device default.
|
/// 3. Device default.
|
||||||
/// 4. System default (last resort).
|
/// 4. System default (last resort).
|
||||||
///
|
///
|
||||||
/// Returns `(OutputStream, OutputStreamHandle, actual_sample_rate)`.
|
/// Returns `(stream_handle, actual_sample_rate)`.
|
||||||
fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32) -> (rodio::OutputStream, rodio::OutputStreamHandle, u32) {
|
fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32) -> (Arc<rodio::MixerDeviceSink>, u32) {
|
||||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||||
|
|
||||||
// Suppress ALSA stderr noise while enumerating devices on Unix.
|
// Suppress ALSA stderr noise while enumerating devices on Unix.
|
||||||
@@ -163,7 +163,13 @@ fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32)
|
|||||||
// `find_by_name` returns None and we drop through to `default_output_device`
|
// `find_by_name` returns None and we drop through to `default_output_device`
|
||||||
// unchanged — no regression.
|
// unchanged — no regression.
|
||||||
let find_by_name = |name: &str| -> Option<_> {
|
let find_by_name = |name: &str| -> Option<_> {
|
||||||
host.output_devices().ok()?.find(|d| d.name().ok().as_deref() == Some(name))
|
host.output_devices().ok()?.find(|d| {
|
||||||
|
d.description()
|
||||||
|
.ok()
|
||||||
|
.map(|desc| desc.name().to_string())
|
||||||
|
.as_deref()
|
||||||
|
== Some(name)
|
||||||
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let device = device_name
|
let device = device_name
|
||||||
@@ -184,64 +190,60 @@ fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32)
|
|||||||
// 1. Exact rate match — prefer more channels (stereo > mono).
|
// 1. Exact rate match — prefer more channels (stereo > mono).
|
||||||
let exact = configs.iter()
|
let exact = configs.iter()
|
||||||
.filter(|c| {
|
.filter(|c| {
|
||||||
c.min_sample_rate().0 <= desired_rate
|
c.min_sample_rate() <= desired_rate
|
||||||
&& desired_rate <= c.max_sample_rate().0
|
&& desired_rate <= c.max_sample_rate()
|
||||||
})
|
})
|
||||||
.max_by_key(|c| c.channels());
|
.max_by_key(|c| c.channels());
|
||||||
|
|
||||||
if let Some(cfg) = exact {
|
if exact.is_some() {
|
||||||
let config = cfg.clone()
|
if let Ok(handle) = rodio::DeviceSinkBuilder::from_device(device.clone())
|
||||||
.with_sample_rate(rodio::cpal::SampleRate(desired_rate));
|
.and_then(|b| b.with_sample_rate(std::num::NonZeroU32::new(desired_rate).unwrap_or(std::num::NonZeroU32::MIN)).open_stream())
|
||||||
if let Ok((stream, handle)) =
|
|
||||||
rodio::OutputStream::try_from_device_config(&device, config)
|
|
||||||
{
|
{
|
||||||
crate::app_eprintln!("[psysonic] audio stream opened at {} Hz (exact)", desired_rate);
|
crate::app_eprintln!("[psysonic] audio stream opened at {} Hz (exact)", desired_rate);
|
||||||
return (stream, handle, desired_rate);
|
return (Arc::new(handle), desired_rate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. No exact match — use the highest supported rate.
|
// 2. No exact match — use the highest supported rate.
|
||||||
let best = configs.iter()
|
let best = configs.iter()
|
||||||
.max_by_key(|c| c.max_sample_rate().0);
|
.max_by_key(|c| c.max_sample_rate());
|
||||||
|
|
||||||
if let Some(cfg) = best {
|
if let Some(cfg) = best {
|
||||||
let rate = cfg.max_sample_rate().0;
|
let rate = cfg.max_sample_rate();
|
||||||
let config = cfg.clone()
|
if let Ok(handle) = rodio::DeviceSinkBuilder::from_device(device.clone())
|
||||||
.with_sample_rate(rodio::cpal::SampleRate(rate));
|
.and_then(|b| b.with_sample_rate(std::num::NonZeroU32::new(rate).unwrap_or(std::num::NonZeroU32::MIN)).open_stream())
|
||||||
if let Ok((stream, handle)) =
|
|
||||||
rodio::OutputStream::try_from_device_config(&device, config)
|
|
||||||
{
|
{
|
||||||
crate::app_eprintln!(
|
crate::app_eprintln!(
|
||||||
"[psysonic] audio stream opened at {} Hz (highest, wanted {})",
|
"[psysonic] audio stream opened at {} Hz (highest, wanted {})",
|
||||||
rate, desired_rate
|
rate, desired_rate
|
||||||
);
|
);
|
||||||
return (stream, handle, rate);
|
return (Arc::new(handle), rate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Device default.
|
// 3. Device default.
|
||||||
if let Ok((stream, handle)) = rodio::OutputStream::try_from_device(&device) {
|
if let Ok(handle) = rodio::DeviceSinkBuilder::from_device(device.clone()).and_then(|b| b.open_stream()) {
|
||||||
let rate = device
|
let rate = device
|
||||||
.default_output_config()
|
.default_output_config()
|
||||||
.map(|c| c.sample_rate().0)
|
.map(|c| c.sample_rate())
|
||||||
.unwrap_or(44100);
|
.unwrap_or(44100);
|
||||||
crate::app_eprintln!("[psysonic] audio stream opened at {} Hz (device default)", rate);
|
crate::app_eprintln!("[psysonic] audio stream opened at {} Hz (device default)", rate);
|
||||||
return (stream, handle, rate);
|
return (Arc::new(handle), rate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Last resort: system default.
|
// 4. Last resort: system default.
|
||||||
crate::app_eprintln!("[psysonic] audio stream falling back to system default");
|
crate::app_eprintln!("[psysonic] audio stream falling back to system default");
|
||||||
let (stream, handle) = rodio::OutputStream::try_default()
|
let handle = rodio::DeviceSinkBuilder::open_default_sink()
|
||||||
.expect("cannot open any audio output device");
|
.expect("cannot open any audio output device");
|
||||||
let rate = rodio::cpal::default_host()
|
let rate = rodio::cpal::default_host()
|
||||||
.default_output_device()
|
.default_output_device()
|
||||||
.and_then(|d| d.default_output_config().ok())
|
.and_then(|d| d.default_output_config().ok())
|
||||||
.map(|c| c.sample_rate().0)
|
.map(|c| c.sample_rate())
|
||||||
.unwrap_or(44100);
|
.unwrap_or(44100);
|
||||||
(stream, handle, rate)
|
(Arc::new(handle), rate)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||||
@@ -254,12 +256,12 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Channels: main thread ←→ audio-stream thread.
|
// Channels: main thread ←→ audio-stream thread.
|
||||||
// init_tx/rx : (OutputStreamHandle, actual_rate) sent once at startup.
|
// init_tx/rx : (Arc<rodio::MixerDeviceSink>, actual_rate) sent once at startup.
|
||||||
// reopen_tx/rx: (desired_rate, reply_tx) — triggers a stream re-open.
|
// reopen_tx/rx: (desired_rate, reply_tx) — triggers a stream re-open.
|
||||||
let (init_tx, init_rx) =
|
let (init_tx, init_rx) =
|
||||||
std::sync::mpsc::sync_channel::<(rodio::OutputStreamHandle, u32)>(0);
|
std::sync::mpsc::sync_channel::<(Arc<rodio::MixerDeviceSink>, u32)>(0);
|
||||||
let (reopen_tx, reopen_rx) =
|
let (reopen_tx, reopen_rx) =
|
||||||
std::sync::mpsc::sync_channel::<(u32, bool, Option<String>, std::sync::mpsc::SyncSender<rodio::OutputStreamHandle>)>(4);
|
std::sync::mpsc::sync_channel::<(u32, bool, Option<String>, std::sync::mpsc::SyncSender<Arc<rodio::MixerDeviceSink>>)>(4);
|
||||||
|
|
||||||
let thread = std::thread::Builder::new()
|
let thread = std::thread::Builder::new()
|
||||||
.name("psysonic-audio-stream".into())
|
.name("psysonic-audio-stream".into())
|
||||||
@@ -280,7 +282,8 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
|||||||
// Thread priority is kept at default during standard-mode playback.
|
// Thread priority is kept at default during standard-mode playback.
|
||||||
// It is escalated to Max only when a Hi-Res stream reopen is requested,
|
// It is escalated to Max only when a Hi-Res stream reopen is requested,
|
||||||
// to prevent PipeWire underruns at high quantum sizes (8192 frames).
|
// to prevent PipeWire underruns at high quantum sizes (8192 frames).
|
||||||
let (mut _stream, handle, rate) = open_stream_for_device_and_rate(None, 0);
|
let (mut _stream, rate) = open_stream_for_device_and_rate(None, 0);
|
||||||
|
let handle = _stream.clone();
|
||||||
init_tx.send((handle, rate)).ok();
|
init_tx.send((handle, rate)).ok();
|
||||||
|
|
||||||
// Keep the stream alive and handle sample-rate / device-switch requests.
|
// Keep the stream alive and handle sample-rate / device-switch requests.
|
||||||
@@ -310,7 +313,8 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
|||||||
std::env::set_var("PULSE_LATENCY_MSEC", latency_ms.to_string());
|
std::env::set_var("PULSE_LATENCY_MSEC", latency_ms.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
let (new_stream, new_handle, _actual) = open_stream_for_device_and_rate(device_name.as_deref(), desired_rate);
|
let (new_stream, _actual) = open_stream_for_device_and_rate(device_name.as_deref(), desired_rate);
|
||||||
|
let new_handle = new_stream.clone();
|
||||||
_stream = new_stream;
|
_stream = new_stream;
|
||||||
reply_tx.send(new_handle).ok();
|
reply_tx.send(new_handle).ok();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use std::sync::Arc;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use rodio::Sink;
|
use rodio::Player;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tauri::{AppHandle, Emitter, Manager};
|
use tauri::{AppHandle, Emitter, Manager};
|
||||||
|
|
||||||
@@ -96,8 +96,146 @@ pub(crate) fn content_type_to_hint(ct: &str) -> Option<String> {
|
|||||||
else if ct.contains("flac") { Some("flac".into()) }
|
else if ct.contains("flac") { Some("flac".into()) }
|
||||||
else if ct.contains("wav") || ct.contains("wave") { Some("wav".into()) }
|
else if ct.contains("wav") || ct.contains("wave") { Some("wav".into()) }
|
||||||
else if ct.contains("opus") { Some("opus".into()) }
|
else if ct.contains("opus") { Some("opus".into()) }
|
||||||
|
// AAC/ALAC in MP4 — Navidrome/nginx often send `audio/mp4`; without a hint we skipped ranged open.
|
||||||
|
else if ct.contains("audio/mp4") || ct.contains("x-m4a") || ct.contains("/m4a") {
|
||||||
|
Some("m4a".into())
|
||||||
|
}
|
||||||
else { None }
|
else { None }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `Content-Disposition: attachment; filename="…"` from some Subsonic proxies.
|
||||||
|
pub(crate) fn format_hint_from_content_disposition(cd: &str) -> Option<String> {
|
||||||
|
fn ext_ok(ext: &str) -> Option<String> {
|
||||||
|
let ext = ext.trim_matches(|c| c == '"' || c == '\'' || c == ' ').split(';').next()?.trim();
|
||||||
|
if !(1..=5).contains(&ext.len()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if !ext.chars().all(|c| c.is_ascii_alphanumeric()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let e = ext.to_ascii_lowercase();
|
||||||
|
if matches!(
|
||||||
|
e.as_str(),
|
||||||
|
"mp3" | "flac" | "ogg" | "oga" | "opus" | "m4a" | "mp4" | "aac" | "wav" | "wave" | "ape" | "wv"
|
||||||
|
| "webm" | "mka"
|
||||||
|
) {
|
||||||
|
Some(e)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn ext_from_filename(path: &str) -> Option<String> {
|
||||||
|
let base = path.rsplit('/').next()?.trim_matches(|c| c == '"' || c == ' ');
|
||||||
|
if base.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let ext = base.rsplit('.').next()?;
|
||||||
|
if ext == base {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
ext_ok(ext)
|
||||||
|
}
|
||||||
|
for part in cd.split(';') {
|
||||||
|
let part = part.trim();
|
||||||
|
if let Some(rest) = part.strip_prefix("filename*=") {
|
||||||
|
// RFC 5987: `charset'lang'value`
|
||||||
|
let value = rest.split("''").nth(1).unwrap_or(rest).trim().trim_matches('"');
|
||||||
|
if let Some(ext) = ext_from_filename(value) {
|
||||||
|
return Some(ext);
|
||||||
|
}
|
||||||
|
} else if let Some(rest) = part.strip_prefix("filename=") {
|
||||||
|
let value = rest.trim().trim_matches('"');
|
||||||
|
if let Some(ext) = ext_from_filename(value) {
|
||||||
|
return Some(ext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subsonic [`song.suffix`](https://www.subsonic.org/pages/api.jsp#getSong) — stream.view URLs
|
||||||
|
/// usually have no file extension; this supplies `format_hint` for ranged open.
|
||||||
|
pub(crate) fn normalize_stream_suffix_for_hint(suffix: Option<&str>) -> Option<String> {
|
||||||
|
let s = suffix?.trim();
|
||||||
|
if s.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let e = s.to_ascii_lowercase();
|
||||||
|
if matches!(
|
||||||
|
e.as_str(),
|
||||||
|
"mp3" | "flac" | "ogg" | "oga" | "opus" | "m4a" | "mp4" | "aac" | "wav" | "wave" | "ape" | "wv"
|
||||||
|
| "webm" | "mka"
|
||||||
|
) {
|
||||||
|
Some(e)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Max prefix length for an optional `Range` probe GET when ranged open needs a format hint.
|
||||||
|
pub(crate) const STREAM_FORMAT_SNIFF_PROBE_BYTES: usize = 256 * 1024;
|
||||||
|
|
||||||
|
fn id3v2_tag_len(data: &[u8]) -> usize {
|
||||||
|
if data.len() >= 10 && data[0..3] == *b"ID3" {
|
||||||
|
let size = ((data[6] as usize & 0x7f) << 21)
|
||||||
|
| ((data[7] as usize & 0x7f) << 14)
|
||||||
|
| ((data[8] as usize & 0x7f) << 7)
|
||||||
|
| (data[9] as usize & 0x7f);
|
||||||
|
10usize.saturating_add(size)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn adts_frame_sync(b0: u8, b1: u8) -> bool {
|
||||||
|
b0 == 0xff && (b1 & 0xf6) == 0xf0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mp3_frame_sync(b0: u8, b1: u8) -> bool {
|
||||||
|
b0 == 0xff && (b1 & 0xe0) == 0xe0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Magic-byte sniff on the start of an HTTP body when headers / Subsonic suffix / path
|
||||||
|
/// did not yield a Symphonia [`Hint`] extension (needed for `RangedHttpSource`).
|
||||||
|
pub(crate) fn sniff_stream_format_extension(data: &[u8]) -> Option<String> {
|
||||||
|
if data.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if data.len() >= 4 && data[0..4] == *b"fLaC" {
|
||||||
|
return Some("flac".into());
|
||||||
|
}
|
||||||
|
if data.len() >= 4 && data[0..4] == *b"OggS" {
|
||||||
|
return Some("ogg".into());
|
||||||
|
}
|
||||||
|
if data.len() >= 12 && data[0..4] == *b"RIFF" && data[8..12] == *b"WAVE" {
|
||||||
|
return Some("wav".into());
|
||||||
|
}
|
||||||
|
// ISO-BMFF — `ftyp` inside a box; scan a small window (large `free`/`skip` before `ftyp` is rare but exists).
|
||||||
|
let scan = data.len().min(4096).saturating_sub(4);
|
||||||
|
for i in 0..=scan {
|
||||||
|
if data[i..i + 4] == *b"ftyp" {
|
||||||
|
return Some("m4a".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// EBML — WebM / Matroska (.mka)
|
||||||
|
if data.len() >= 4 && data[0] == 0x1a && data[1] == 0x45 && data[2] == 0xdf && data[3] == 0xa3 {
|
||||||
|
return Some("mka".into());
|
||||||
|
}
|
||||||
|
// AAC ADTS
|
||||||
|
let id3 = id3v2_tag_len(data);
|
||||||
|
if id3 < data.len().saturating_sub(2) && adts_frame_sync(data[id3], data[id3 + 1]) {
|
||||||
|
return Some("aac".into());
|
||||||
|
}
|
||||||
|
if data.len() >= 2 && adts_frame_sync(data[0], data[1]) {
|
||||||
|
return Some("aac".into());
|
||||||
|
}
|
||||||
|
// MPEG layer III / II — after ID3
|
||||||
|
let off = id3;
|
||||||
|
if off + 2 <= data.len() && mp3_frame_sync(data[off], data[off + 1]) {
|
||||||
|
return Some("mp3".into());
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
// ─── Event payloads ───────────────────────────────────────────────────────────
|
// ─── Event payloads ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Clone, Serialize)]
|
#[derive(Clone, Serialize)]
|
||||||
@@ -529,7 +667,7 @@ pub(crate) fn loudness_ui_current_gain_db(gain_linear: f32) -> Option<f32> {
|
|||||||
gain_linear_to_db(gain_linear)
|
gain_linear_to_db(gain_linear)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn ramp_sink_volume(sink: Arc<Sink>, from: f32, to: f32) {
|
pub(crate) fn ramp_sink_volume(sink: Arc<Player>, from: f32, to: f32) {
|
||||||
let from = from.clamp(0.0, 1.0);
|
let from = from.clamp(0.0, 1.0);
|
||||||
let to = to.clamp(0.0, 1.0);
|
let to = to.clamp(0.0, 1.0);
|
||||||
if (to - from).abs() < 0.002 {
|
if (to - from).abs() < 0.002 {
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ use std::sync::atomic::Ordering;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use rodio::{Sink, Source};
|
use rodio::Player;
|
||||||
|
use rodio::Source;
|
||||||
use tauri::{AppHandle, Emitter, State};
|
use tauri::{AppHandle, Emitter, State};
|
||||||
|
|
||||||
use super::decode::SizedDecoder;
|
use super::decode::SizedDecoder;
|
||||||
@@ -184,7 +185,7 @@ pub async fn audio_preview_play(
|
|||||||
// starts). Symphonia FLAC without SEEKTABLE may fail try_seek; preview
|
// starts). Symphonia FLAC without SEEKTABLE may fail try_seek; preview
|
||||||
// then plays from 0, which is acceptable.
|
// then plays from 0, which is acceptable.
|
||||||
// No EQ / no crossfade / no ReplayGain — preview stays simple.
|
// No EQ / no crossfade / no ReplayGain — preview stays simple.
|
||||||
let mut source = decoder.convert_samples::<f32>();
|
let mut source = decoder;
|
||||||
if start_sec > 0.5 {
|
if start_sec > 0.5 {
|
||||||
let _ = source.try_seek(Duration::from_secs_f64(start_sec));
|
let _ = source.try_seek(Duration::from_secs_f64(start_sec));
|
||||||
}
|
}
|
||||||
@@ -193,10 +194,7 @@ pub async fn audio_preview_play(
|
|||||||
let source = PriorityBoostSource::new(source);
|
let source = PriorityBoostSource::new(source);
|
||||||
|
|
||||||
// ── Build secondary sink on the existing OutputStream ────────────────────
|
// ── Build secondary sink on the existing OutputStream ────────────────────
|
||||||
let sink = Arc::new(
|
let sink = Arc::new(Player::connect_new(state.stream_handle.lock().unwrap().mixer()));
|
||||||
Sink::try_new(&*state.stream_handle.lock().unwrap())
|
|
||||||
.map_err(|e| format!("preview: sink new: {e}"))?,
|
|
||||||
);
|
|
||||||
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
|
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
|
||||||
sink.append(source);
|
sink.append(source);
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ const EQ_CHECK_INTERVAL: usize = 1024;
|
|||||||
|
|
||||||
pub(crate) struct EqSource<S: Source<Item = f32>> {
|
pub(crate) struct EqSource<S: Source<Item = f32>> {
|
||||||
inner: S,
|
inner: S,
|
||||||
sample_rate: u32,
|
sample_rate: rodio::SampleRate,
|
||||||
channels: u16,
|
channels: rodio::ChannelCount,
|
||||||
gains: Arc<[AtomicU32; 10]>,
|
gains: Arc<[AtomicU32; 10]>,
|
||||||
enabled: Arc<AtomicBool>,
|
enabled: Arc<AtomicBool>,
|
||||||
pre_gain: Arc<AtomicU32>,
|
pre_gain: Arc<AtomicU32>,
|
||||||
@@ -30,16 +30,16 @@ impl<S: Source<Item = f32>> EqSource<S> {
|
|||||||
let sample_rate = inner.sample_rate();
|
let sample_rate = inner.sample_rate();
|
||||||
let channels = inner.channels();
|
let channels = inner.channels();
|
||||||
let filters = std::array::from_fn(|band| {
|
let filters = std::array::from_fn(|band| {
|
||||||
let freq = EQ_BANDS_HZ[band].clamp(20.0, (sample_rate as f32 / 2.0) - 100.0);
|
let freq = EQ_BANDS_HZ[band].clamp(20.0, (sample_rate.get() as f32 / 2.0) - 100.0);
|
||||||
std::array::from_fn(|_| {
|
std::array::from_fn(|_| {
|
||||||
let coeffs = Coefficients::<f32>::from_params(
|
let coeffs = Coefficients::<f32>::from_params(
|
||||||
FilterType::PeakingEQ(0.0),
|
FilterType::PeakingEQ(0.0),
|
||||||
(sample_rate as f32).hz(),
|
(sample_rate.get() as f32).hz(),
|
||||||
freq.hz(),
|
freq.hz(),
|
||||||
EQ_Q,
|
EQ_Q,
|
||||||
).unwrap_or_else(|_| Coefficients::<f32>::from_params(
|
).unwrap_or_else(|_| Coefficients::<f32>::from_params(
|
||||||
FilterType::PeakingEQ(0.0),
|
FilterType::PeakingEQ(0.0),
|
||||||
(sample_rate as f32).hz(),
|
(sample_rate.get() as f32).hz(),
|
||||||
1000.0f32.hz(),
|
1000.0f32.hz(),
|
||||||
EQ_Q,
|
EQ_Q,
|
||||||
).unwrap());
|
).unwrap());
|
||||||
@@ -60,10 +60,10 @@ impl<S: Source<Item = f32>> EqSource<S> {
|
|||||||
let gain_db = f32::from_bits(self.gains[band].load(Ordering::Relaxed));
|
let gain_db = f32::from_bits(self.gains[band].load(Ordering::Relaxed));
|
||||||
if (gain_db - self.current_gains[band]).abs() > 0.01 {
|
if (gain_db - self.current_gains[band]).abs() > 0.01 {
|
||||||
self.current_gains[band] = gain_db;
|
self.current_gains[band] = gain_db;
|
||||||
let freq = EQ_BANDS_HZ[band].clamp(20.0, (self.sample_rate as f32 / 2.0) - 100.0);
|
let freq = EQ_BANDS_HZ[band].clamp(20.0, (self.sample_rate.get() as f32 / 2.0) - 100.0);
|
||||||
if let Ok(coeffs) = Coefficients::<f32>::from_params(
|
if let Ok(coeffs) = Coefficients::<f32>::from_params(
|
||||||
FilterType::PeakingEQ(gain_db),
|
FilterType::PeakingEQ(gain_db),
|
||||||
(self.sample_rate as f32).hz(),
|
(self.sample_rate.get() as f32).hz(),
|
||||||
freq.hz(),
|
freq.hz(),
|
||||||
EQ_Q,
|
EQ_Q,
|
||||||
) {
|
) {
|
||||||
@@ -88,12 +88,12 @@ impl<S: Source<Item = f32>> Iterator for EqSource<S> {
|
|||||||
self.sample_counter = self.sample_counter.wrapping_add(1);
|
self.sample_counter = self.sample_counter.wrapping_add(1);
|
||||||
|
|
||||||
if !self.enabled.load(Ordering::Relaxed) {
|
if !self.enabled.load(Ordering::Relaxed) {
|
||||||
self.channel_idx = (self.channel_idx + 1) % self.channels as usize;
|
self.channel_idx = (self.channel_idx + 1) % self.channels.get() as usize;
|
||||||
return Some(sample);
|
return Some(sample);
|
||||||
}
|
}
|
||||||
|
|
||||||
let ch = self.channel_idx.min(1);
|
let ch = self.channel_idx.min(1);
|
||||||
self.channel_idx = (self.channel_idx + 1) % self.channels as usize;
|
self.channel_idx = (self.channel_idx + 1) % self.channels.get() as usize;
|
||||||
|
|
||||||
let pre_gain_db = f32::from_bits(self.pre_gain.load(Ordering::Relaxed));
|
let pre_gain_db = f32::from_bits(self.pre_gain.load(Ordering::Relaxed));
|
||||||
let pre_gain_factor = 10_f32.powf(pre_gain_db / 20.0);
|
let pre_gain_factor = 10_f32.powf(pre_gain_db / 20.0);
|
||||||
@@ -106,9 +106,9 @@ impl<S: Source<Item = f32>> Iterator for EqSource<S> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<S: Source<Item = f32>> Source for EqSource<S> {
|
impl<S: Source<Item = f32>> Source for EqSource<S> {
|
||||||
fn current_frame_len(&self) -> Option<usize> { self.inner.current_frame_len() }
|
fn current_span_len(&self) -> Option<usize> { self.inner.current_span_len() }
|
||||||
fn channels(&self) -> u16 { self.channels }
|
fn channels(&self) -> rodio::ChannelCount { self.channels }
|
||||||
fn sample_rate(&self) -> u32 { self.sample_rate }
|
fn sample_rate(&self) -> rodio::SampleRate { self.sample_rate }
|
||||||
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
||||||
|
|
||||||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||||
@@ -116,10 +116,10 @@ impl<S: Source<Item = f32>> Source for EqSource<S> {
|
|||||||
for band in 0..10 {
|
for band in 0..10 {
|
||||||
let gain_db = f32::from_bits(self.gains[band].load(Ordering::Relaxed));
|
let gain_db = f32::from_bits(self.gains[band].load(Ordering::Relaxed));
|
||||||
self.current_gains[band] = gain_db;
|
self.current_gains[band] = gain_db;
|
||||||
let freq = EQ_BANDS_HZ[band].clamp(20.0, (self.sample_rate as f32 / 2.0) - 100.0);
|
let freq = EQ_BANDS_HZ[band].clamp(20.0, (self.sample_rate.get() as f32 / 2.0) - 100.0);
|
||||||
if let Ok(coeffs) = Coefficients::<f32>::from_params(
|
if let Ok(coeffs) = Coefficients::<f32>::from_params(
|
||||||
FilterType::PeakingEQ(gain_db),
|
FilterType::PeakingEQ(gain_db),
|
||||||
(self.sample_rate as f32).hz(),
|
(self.sample_rate.get() as f32).hz(),
|
||||||
freq.hz(),
|
freq.hz(),
|
||||||
EQ_Q,
|
EQ_Q,
|
||||||
) {
|
) {
|
||||||
@@ -141,8 +141,8 @@ impl<S: Source<Item = f32>> Source for EqSource<S> {
|
|||||||
|
|
||||||
pub(crate) struct DynSource {
|
pub(crate) struct DynSource {
|
||||||
inner: Box<dyn Source<Item = f32> + Send>,
|
inner: Box<dyn Source<Item = f32> + Send>,
|
||||||
channels: u16,
|
channels: rodio::ChannelCount,
|
||||||
sample_rate: u32,
|
sample_rate: rodio::SampleRate,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DynSource {
|
impl DynSource {
|
||||||
@@ -159,9 +159,9 @@ impl Iterator for DynSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Source for DynSource {
|
impl Source for DynSource {
|
||||||
fn current_frame_len(&self) -> Option<usize> { self.inner.current_frame_len() }
|
fn current_span_len(&self) -> Option<usize> { self.inner.current_span_len() }
|
||||||
fn channels(&self) -> u16 { self.channels }
|
fn channels(&self) -> rodio::ChannelCount { self.channels }
|
||||||
fn sample_rate(&self) -> u32 { self.sample_rate }
|
fn sample_rate(&self) -> rodio::SampleRate { self.sample_rate }
|
||||||
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
||||||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||||
self.inner.try_seek(pos)
|
self.inner.try_seek(pos)
|
||||||
@@ -189,11 +189,11 @@ pub(crate) struct EqualPowerFadeIn<S: Source<Item = f32>> {
|
|||||||
impl<S: Source<Item = f32>> EqualPowerFadeIn<S> {
|
impl<S: Source<Item = f32>> EqualPowerFadeIn<S> {
|
||||||
pub(crate) fn new(inner: S, fade_dur: Duration) -> Self {
|
pub(crate) fn new(inner: S, fade_dur: Duration) -> Self {
|
||||||
let sample_rate = inner.sample_rate();
|
let sample_rate = inner.sample_rate();
|
||||||
let channels = inner.channels() as u64;
|
let channels = inner.channels().get() as u64;
|
||||||
let fade_samples = if fade_dur.is_zero() {
|
let fade_samples = if fade_dur.is_zero() {
|
||||||
0
|
0
|
||||||
} else {
|
} else {
|
||||||
(fade_dur.as_secs_f64() * sample_rate as f64 * channels as f64) as u64
|
(fade_dur.as_secs_f64() * sample_rate.get() as f64 * channels as f64) as u64
|
||||||
};
|
};
|
||||||
Self { inner, sample_count: 0, fade_samples }
|
Self { inner, sample_count: 0, fade_samples }
|
||||||
}
|
}
|
||||||
@@ -215,9 +215,9 @@ impl<S: Source<Item = f32>> Iterator for EqualPowerFadeIn<S> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<S: Source<Item = f32>> Source for EqualPowerFadeIn<S> {
|
impl<S: Source<Item = f32>> Source for EqualPowerFadeIn<S> {
|
||||||
fn current_frame_len(&self) -> Option<usize> { self.inner.current_frame_len() }
|
fn current_span_len(&self) -> Option<usize> { self.inner.current_span_len() }
|
||||||
fn channels(&self) -> u16 { self.inner.channels() }
|
fn channels(&self) -> rodio::ChannelCount { self.inner.channels() }
|
||||||
fn sample_rate(&self) -> u32 { self.inner.sample_rate() }
|
fn sample_rate(&self) -> rodio::SampleRate { self.inner.sample_rate() }
|
||||||
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
||||||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||||
// For mid-track seeks: skip straight to unity gain so the new position
|
// For mid-track seeks: skip straight to unity gain so the new position
|
||||||
@@ -294,9 +294,9 @@ impl<S: Source<Item = f32>> Iterator for TriggeredFadeOut<S> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<S: Source<Item = f32>> Source for TriggeredFadeOut<S> {
|
impl<S: Source<Item = f32>> Source for TriggeredFadeOut<S> {
|
||||||
fn current_frame_len(&self) -> Option<usize> { self.inner.current_frame_len() }
|
fn current_span_len(&self) -> Option<usize> { self.inner.current_span_len() }
|
||||||
fn channels(&self) -> u16 { self.inner.channels() }
|
fn channels(&self) -> rodio::ChannelCount { self.inner.channels() }
|
||||||
fn sample_rate(&self) -> u32 { self.inner.sample_rate() }
|
fn sample_rate(&self) -> rodio::SampleRate { self.inner.sample_rate() }
|
||||||
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
||||||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||||
// If we seek back during a fade, cancel the fade.
|
// If we seek back during a fade, cancel the fade.
|
||||||
@@ -340,9 +340,9 @@ impl<S: Source<Item = f32>> Iterator for NotifyingSource<S> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<S: Source<Item = f32>> Source for NotifyingSource<S> {
|
impl<S: Source<Item = f32>> Source for NotifyingSource<S> {
|
||||||
fn current_frame_len(&self) -> Option<usize> { self.inner.current_frame_len() }
|
fn current_span_len(&self) -> Option<usize> { self.inner.current_span_len() }
|
||||||
fn channels(&self) -> u16 { self.inner.channels() }
|
fn channels(&self) -> rodio::ChannelCount { self.inner.channels() }
|
||||||
fn sample_rate(&self) -> u32 { self.inner.sample_rate() }
|
fn sample_rate(&self) -> rodio::SampleRate { self.inner.sample_rate() }
|
||||||
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
||||||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||||
// If we seek backwards the source is no longer exhausted.
|
// If we seek backwards the source is no longer exhausted.
|
||||||
@@ -381,9 +381,9 @@ impl<S: Source<Item = f32>> Iterator for CountingSource<S> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<S: Source<Item = f32>> Source for CountingSource<S> {
|
impl<S: Source<Item = f32>> Source for CountingSource<S> {
|
||||||
fn current_frame_len(&self) -> Option<usize> { self.inner.current_frame_len() }
|
fn current_span_len(&self) -> Option<usize> { self.inner.current_span_len() }
|
||||||
fn channels(&self) -> u16 { self.inner.channels() }
|
fn channels(&self) -> rodio::ChannelCount { self.inner.channels() }
|
||||||
fn sample_rate(&self) -> u32 { self.inner.sample_rate() }
|
fn sample_rate(&self) -> rodio::SampleRate { self.inner.sample_rate() }
|
||||||
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
||||||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||||
// Reset counter only after confirming the inner seek succeeded.
|
// Reset counter only after confirming the inner seek succeeded.
|
||||||
@@ -392,8 +392,8 @@ impl<S: Source<Item = f32>> Source for CountingSource<S> {
|
|||||||
// a permanent desync between displayed time and actual audio.
|
// a permanent desync between displayed time and actual audio.
|
||||||
let result = self.inner.try_seek(pos);
|
let result = self.inner.try_seek(pos);
|
||||||
if result.is_ok() {
|
if result.is_ok() {
|
||||||
let samples = (pos.as_secs_f64() * self.inner.sample_rate() as f64
|
let samples = (pos.as_secs_f64() * self.inner.sample_rate().get() as f64
|
||||||
* self.inner.channels() as f64) as u64;
|
* self.inner.channels().get() as f64) as u64;
|
||||||
self.counter.store(samples, Ordering::Relaxed);
|
self.counter.store(samples, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
result
|
result
|
||||||
@@ -471,9 +471,9 @@ impl<S: Source<Item = f32>> Iterator for PriorityBoostSource<S> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<S: Source<Item = f32>> Source for PriorityBoostSource<S> {
|
impl<S: Source<Item = f32>> Source for PriorityBoostSource<S> {
|
||||||
fn current_frame_len(&self) -> Option<usize> { self.inner.current_frame_len() }
|
fn current_span_len(&self) -> Option<usize> { self.inner.current_span_len() }
|
||||||
fn channels(&self) -> u16 { self.inner.channels() }
|
fn channels(&self) -> rodio::ChannelCount { self.inner.channels() }
|
||||||
fn sample_rate(&self) -> u32 { self.inner.sample_rate() }
|
fn sample_rate(&self) -> rodio::SampleRate { self.inner.sample_rate() }
|
||||||
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
||||||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||||
self.inner.try_seek(pos)
|
self.inner.try_seek(pos)
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ use std::sync::{Arc, Mutex};
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use ringbuf::{HeapConsumer, HeapProducer};
|
use ringbuf::{HeapCons, HeapProd};
|
||||||
|
use ringbuf::traits::{Consumer, Observer, Producer};
|
||||||
use symphonia::core::io::MediaSource;
|
use symphonia::core::io::MediaSource;
|
||||||
use tauri::{AppHandle, Emitter};
|
use tauri::{AppHandle, Emitter};
|
||||||
|
|
||||||
@@ -161,21 +162,21 @@ fn parse_icy_meta(raw: &[u8]) -> Option<IcyMeta> {
|
|||||||
|
|
||||||
// ── AudioStreamReader — SPSC consumer → std::io::Read ────────────────────────
|
// ── AudioStreamReader — SPSC consumer → std::io::Read ────────────────────────
|
||||||
//
|
//
|
||||||
// Bridges HeapConsumer<u8> (non-blocking) into the synchronous Read interface
|
// Bridges HeapCons<u8> (non-blocking) into the synchronous Read interface
|
||||||
// that Symphonia requires. Designed to run inside tokio::task::spawn_blocking.
|
// that Symphonia requires. Designed to run inside tokio::task::spawn_blocking.
|
||||||
//
|
//
|
||||||
// Empty buffer: sleeps RADIO_YIELD_MS ms, retries. Never busy-spins.
|
// Empty buffer: sleeps RADIO_YIELD_MS ms, retries. Never busy-spins.
|
||||||
// Timeout: after RADIO_READ_TIMEOUT_SECS with no data → TimedOut.
|
// Timeout: after RADIO_READ_TIMEOUT_SECS with no data → TimedOut.
|
||||||
// Generation: if gen_arc != self.gen → Ok(0) (EOF; new track started).
|
// Generation: if gen_arc != self.gen → Ok(0) (EOF; new track started).
|
||||||
// Reconnect: audio_resume sends a fresh HeapConsumer via new_cons_rx.
|
// Reconnect: audio_resume sends a fresh HeapCons via new_cons_rx.
|
||||||
// On the next read() we drain the channel (keep latest) and swap.
|
// On the next read() we drain the channel (keep latest) and swap.
|
||||||
|
|
||||||
pub(crate) struct AudioStreamReader {
|
pub(crate) struct AudioStreamReader {
|
||||||
pub(crate) cons: HeapConsumer<u8>,
|
pub(crate) cons: Mutex<HeapCons<u8>>,
|
||||||
/// Delivers fresh consumers on hard-pause reconnect (unbounded; drain to latest).
|
/// Delivers fresh consumers on hard-pause reconnect (unbounded; drain to latest).
|
||||||
/// Wrapped in Mutex so AudioStreamReader is Sync (required by symphonia::MediaSource).
|
/// Wrapped in Mutex so AudioStreamReader is Sync (required by symphonia::MediaSource).
|
||||||
/// No real contention: only the audio thread ever calls read().
|
/// No real contention: only the audio thread ever calls read().
|
||||||
pub(crate) new_cons_rx: Mutex<std::sync::mpsc::Receiver<HeapConsumer<u8>>>,
|
pub(crate) new_cons_rx: Mutex<std::sync::mpsc::Receiver<HeapCons<u8>>>,
|
||||||
pub(crate) deadline: std::time::Instant,
|
pub(crate) deadline: std::time::Instant,
|
||||||
pub(crate) gen_arc: Arc<AtomicU64>,
|
pub(crate) gen_arc: Arc<AtomicU64>,
|
||||||
pub(crate) gen: u64,
|
pub(crate) gen: u64,
|
||||||
@@ -196,12 +197,12 @@ impl Read for AudioStreamReader {
|
|||||||
}
|
}
|
||||||
// Drain reconnect channel; keep only the most recently delivered consumer
|
// Drain reconnect channel; keep only the most recently delivered consumer
|
||||||
// so a double-tap of resume doesn't leave stale data in place.
|
// so a double-tap of resume doesn't leave stale data in place.
|
||||||
let mut newest: Option<HeapConsumer<u8>> = None;
|
let mut newest: Option<HeapCons<u8>> = None;
|
||||||
while let Ok(c) = self.new_cons_rx.lock().unwrap().try_recv() {
|
while let Ok(c) = self.new_cons_rx.lock().unwrap().try_recv() {
|
||||||
newest = Some(c);
|
newest = Some(c);
|
||||||
}
|
}
|
||||||
if let Some(c) = newest {
|
if let Some(c) = newest {
|
||||||
self.cons = c;
|
*self.cons.lock().unwrap() = c;
|
||||||
self.deadline =
|
self.deadline =
|
||||||
std::time::Instant::now() + Duration::from_secs(RADIO_READ_TIMEOUT_SECS);
|
std::time::Instant::now() + Duration::from_secs(RADIO_READ_TIMEOUT_SECS);
|
||||||
}
|
}
|
||||||
@@ -209,10 +210,10 @@ impl Read for AudioStreamReader {
|
|||||||
if self.gen_arc.load(Ordering::SeqCst) != self.gen {
|
if self.gen_arc.load(Ordering::SeqCst) != self.gen {
|
||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
let available = self.cons.len();
|
let available = self.cons.lock().unwrap().occupied_len();
|
||||||
if available > 0 {
|
if available > 0 {
|
||||||
let n = buf.len().min(available);
|
let n = buf.len().min(available);
|
||||||
let read = self.cons.pop_slice(&mut buf[..n]);
|
let read = self.cons.lock().unwrap().pop_slice(&mut buf[..n]);
|
||||||
self.pos += read as u64;
|
self.pos += read as u64;
|
||||||
// Reset deadline: data arrived, so connection is alive.
|
// Reset deadline: data arrived, so connection is alive.
|
||||||
self.deadline =
|
self.deadline =
|
||||||
@@ -402,8 +403,8 @@ pub(crate) struct RadioSharedFlags {
|
|||||||
pub(crate) is_paused: AtomicBool,
|
pub(crate) is_paused: AtomicBool,
|
||||||
/// Set by download task on hard disconnect; cleared on resume-reconnect.
|
/// Set by download task on hard disconnect; cleared on resume-reconnect.
|
||||||
pub(crate) is_hard_paused: AtomicBool,
|
pub(crate) is_hard_paused: AtomicBool,
|
||||||
/// Delivers a fresh HeapConsumer<u8> to AudioStreamReader on reconnect.
|
/// Delivers a fresh HeapCons<u8> to AudioStreamReader on reconnect.
|
||||||
pub(crate) new_cons_tx: Mutex<std::sync::mpsc::Sender<HeapConsumer<u8>>>,
|
pub(crate) new_cons_tx: Mutex<std::sync::mpsc::Sender<HeapCons<u8>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Live state for the current radio session, stored in AudioEngine.
|
/// Live state for the current radio session, stored in AudioEngine.
|
||||||
@@ -446,7 +447,7 @@ pub(crate) async fn radio_download_task(
|
|||||||
mut initial_response: Option<reqwest::Response>,
|
mut initial_response: Option<reqwest::Response>,
|
||||||
http_client: reqwest::Client,
|
http_client: reqwest::Client,
|
||||||
url: String,
|
url: String,
|
||||||
mut prod: HeapProducer<u8>,
|
mut prod: HeapProd<u8>,
|
||||||
flags: Arc<RadioSharedFlags>,
|
flags: Arc<RadioSharedFlags>,
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
) {
|
) {
|
||||||
@@ -513,14 +514,14 @@ pub(crate) async fn radio_download_task(
|
|||||||
let since = stall_since.get_or_insert(std::time::Instant::now());
|
let since = stall_since.get_or_insert(std::time::Instant::now());
|
||||||
if since.elapsed() >= Duration::from_secs(RADIO_HARD_PAUSE_SECS) {
|
if since.elapsed() >= Duration::from_secs(RADIO_HARD_PAUSE_SECS) {
|
||||||
let fill_pct = ((1.0
|
let fill_pct = ((1.0
|
||||||
- prod.free_len() as f32 / RADIO_BUF_CAPACITY as f32)
|
- prod.vacant_len() as f32 / RADIO_BUF_CAPACITY as f32)
|
||||||
* 100.0) as u32;
|
* 100.0) as u32;
|
||||||
crate::app_eprintln!(
|
crate::app_eprintln!(
|
||||||
"[radio] hard pause: {fill_pct}% full, \
|
"[radio] hard pause: {fill_pct}% full, \
|
||||||
paused >{RADIO_HARD_PAUSE_SECS}s → disconnecting"
|
paused >{RADIO_HARD_PAUSE_SECS}s → disconnecting"
|
||||||
);
|
);
|
||||||
flags.is_hard_paused.store(true, Ordering::Release);
|
flags.is_hard_paused.store(true, Ordering::Release);
|
||||||
return; // Drop HeapProducer → TCP connection released.
|
return; // Drop HeapProd → TCP connection released.
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
stall_since = None;
|
stall_since = None;
|
||||||
@@ -597,7 +598,7 @@ pub(crate) async fn track_download_task(
|
|||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
url: String,
|
url: String,
|
||||||
initial_response: reqwest::Response,
|
initial_response: reqwest::Response,
|
||||||
mut prod: HeapProducer<u8>,
|
mut prod: HeapProd<u8>,
|
||||||
done: Arc<AtomicBool>,
|
done: Arc<AtomicBool>,
|
||||||
promote_cache_slot: Arc<Mutex<Option<PreloadedTrack>>>,
|
promote_cache_slot: Arc<Mutex<Option<PreloadedTrack>>>,
|
||||||
normalization_engine: Arc<AtomicU32>,
|
normalization_engine: Arc<AtomicU32>,
|
||||||
@@ -718,6 +719,10 @@ pub(crate) async fn track_download_task(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !capture_over_limit && !capture.is_empty() {
|
if !capture_over_limit && !capture.is_empty() {
|
||||||
|
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||||
|
done.store(true, Ordering::SeqCst);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if let Some(track_id) = cache_track_id {
|
if let Some(track_id) = cache_track_id {
|
||||||
crate::app_deprintln!(
|
crate::app_deprintln!(
|
||||||
"[stream] legacy stream: capture complete track_id={} capture_mib={:.2} — full-track analysis (cpu-seed queue)",
|
"[stream] legacy stream: capture complete track_id={} capture_mib={:.2} — full-track analysis (cpu-seed queue)",
|
||||||
@@ -731,6 +736,10 @@ pub(crate) async fn track_download_task(
|
|||||||
crate::app_eprintln!("[analysis] track seed failed for {}: {}", track_id, e);
|
crate::app_eprintln!("[analysis] track seed failed for {}: {}", track_id, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||||
|
done.store(true, Ordering::SeqCst);
|
||||||
|
return;
|
||||||
|
}
|
||||||
*promote_cache_slot.lock().unwrap() = Some(PreloadedTrack {
|
*promote_cache_slot.lock().unwrap() = Some(PreloadedTrack {
|
||||||
url: url.clone(),
|
url: url.clone(),
|
||||||
data: capture,
|
data: capture,
|
||||||
@@ -785,9 +794,15 @@ pub(crate) async fn ranged_download_task(
|
|||||||
let mut reconnects: u32 = 0;
|
let mut reconnects: u32 = 0;
|
||||||
let mut next_response: Option<reqwest::Response> = Some(initial_response);
|
let mut next_response: Option<reqwest::Response> = Some(initial_response);
|
||||||
let dl_started = Instant::now();
|
let dl_started = Instant::now();
|
||||||
let mut next_progress_mb: usize = 1;
|
let mut next_progress_mb: usize = 0;
|
||||||
let mut last_partial_loudness_emit = Instant::now() - Duration::from_secs(5);
|
let mut last_partial_loudness_emit = Instant::now() - Duration::from_secs(5);
|
||||||
|
|
||||||
|
crate::app_deprintln!(
|
||||||
|
"[stream] ranged dl start: total={} KiB (~{:.2} MiB)",
|
||||||
|
total_size.saturating_div(1024),
|
||||||
|
total_size as f64 / (1024.0 * 1024.0)
|
||||||
|
);
|
||||||
|
|
||||||
'outer: loop {
|
'outer: loop {
|
||||||
let response = if let Some(r) = next_response.take() {
|
let response = if let Some(r) = next_response.take() {
|
||||||
r
|
r
|
||||||
@@ -889,8 +904,12 @@ pub(crate) async fn ranged_download_task(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mb = downloaded / (1024 * 1024);
|
let mb = downloaded / (1024 * 1024);
|
||||||
if mb >= next_progress_mb {
|
while mb >= next_progress_mb {
|
||||||
let pct = (downloaded as f64 / total_size as f64 * 100.0) as u32;
|
let pct = if total_size > 0 {
|
||||||
|
(downloaded as f64 / total_size as f64 * 100.0) as u32
|
||||||
|
} else {
|
||||||
|
0u32
|
||||||
|
};
|
||||||
crate::app_deprintln!(
|
crate::app_deprintln!(
|
||||||
"[stream] dl progress: {} MB / {} MB ({}%)",
|
"[stream] dl progress: {} MB / {} MB ({}%)",
|
||||||
mb,
|
mb,
|
||||||
@@ -940,6 +959,9 @@ pub(crate) async fn ranged_download_task(
|
|||||||
crate::app_eprintln!("[analysis] ranged seed failed for {}: {}", track_id, e);
|
crate::app_eprintln!("[analysis] ranged seed failed for {}: {}", track_id, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||||
|
return;
|
||||||
|
}
|
||||||
*promote_cache_slot.lock().unwrap() = Some(PreloadedTrack { url, data });
|
*promote_cache_slot.lock().unwrap() = Some(PreloadedTrack { url, data });
|
||||||
crate::app_deprintln!("[stream] promoted to stream_completed_cache for replay");
|
crate::app_deprintln!("[stream] promoted to stream_completed_cache for replay");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,14 +215,7 @@ fn cache_and_return(
|
|||||||
/// logged so the renderer's terminal output shows exactly where the
|
/// logged so the renderer's terminal output shows exactly where the
|
||||||
/// connection breaks. Release builds stay completely silent.
|
/// connection breaks. Release builds stay completely silent.
|
||||||
fn try_connect() -> Option<DiscordIpcClient> {
|
fn try_connect() -> Option<DiscordIpcClient> {
|
||||||
let mut client = match DiscordIpcClient::new(DISCORD_APP_ID) {
|
let mut client = DiscordIpcClient::new(DISCORD_APP_ID);
|
||||||
Ok(c) => c,
|
|
||||||
Err(_e) => {
|
|
||||||
#[cfg(debug_assertions)]
|
|
||||||
crate::app_eprintln!("[discord] new() failed (app_id={}): {}", DISCORD_APP_ID, _e);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if let Err(_e) = client.connect() {
|
if let Err(_e) = client.connect() {
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
crate::app_eprintln!("[discord] connect() failed: {} (Discord desktop running?)", _e);
|
crate::app_eprintln!("[discord] connect() failed: {} (Discord desktop running?)", _e);
|
||||||
|
|||||||
@@ -446,8 +446,9 @@ async fn analysis_cpu_seed_worker_loop(
|
|||||||
/// Submit full-buffer analysis; serializes with other producers. `high_priority` mirrors
|
/// Submit full-buffer analysis; serializes with other producers. `high_priority` mirrors
|
||||||
/// HTTP backfill head insertion for the currently playing track.
|
/// HTTP backfill head insertion for the currently playing track.
|
||||||
///
|
///
|
||||||
/// Emits `analysis:waveform-updated` once here when the DB row is ready (Upserted or cache hit),
|
/// Emits `analysis:waveform-updated` when analysis **wrote** new waveform data (`Upserted`).
|
||||||
/// so `audio` and other callers do not duplicate IPC.
|
/// Cache-hit skips (`SkippedWaveformCacheHit`) omit the event so the frontend does not
|
||||||
|
/// re-run loudness refresh / waveform IPC for rows that were already current.
|
||||||
pub(crate) async fn submit_analysis_cpu_seed(
|
pub(crate) async fn submit_analysis_cpu_seed(
|
||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
track_id: String,
|
track_id: String,
|
||||||
@@ -467,11 +468,7 @@ pub(crate) async fn submit_analysis_cpu_seed(
|
|||||||
Ok(res) => res?,
|
Ok(res) => res?,
|
||||||
Err(_) => return Err("cpu-seed: result channel dropped".to_string()),
|
Err(_) => return Err("cpu-seed: result channel dropped".to_string()),
|
||||||
};
|
};
|
||||||
if matches!(
|
if matches!(outcome, analysis_cache::SeedFromBytesOutcome::Upserted) {
|
||||||
outcome,
|
|
||||||
analysis_cache::SeedFromBytesOutcome::Upserted
|
|
||||||
| analysis_cache::SeedFromBytesOutcome::SkippedWaveformCacheHit
|
|
||||||
) {
|
|
||||||
let _ = app.emit(
|
let _ = app.emit(
|
||||||
"analysis:waveform-updated",
|
"analysis:waveform-updated",
|
||||||
WaveformUpdatedPayload {
|
WaveformUpdatedPayload {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
|||||||
+8
-6
@@ -252,16 +252,18 @@ pub(crate) fn get_embedded_lyrics(path: String) -> Option<String> {
|
|||||||
|
|
||||||
// ── FLAC / Vorbis / Opus / M4A: generic lofty tag API ────────────────────
|
// ── FLAC / Vorbis / Opus / M4A: generic lofty tag API ────────────────────
|
||||||
// Vorbis SYNCEDLYRICS stores a complete LRC string in a plain comment field.
|
// Vorbis SYNCEDLYRICS stores a complete LRC string in a plain comment field.
|
||||||
// It is not a known lofty ItemKey, so access it via ItemKey::Unknown.
|
// In newer lofty versions, construct dynamic keys via ItemKey::from_key.
|
||||||
let tagged = probe.read().ok()?;
|
let tagged = probe.read().ok()?;
|
||||||
for tag in tagged.tags() {
|
for tag in tagged.tags() {
|
||||||
if let Some(lrc) = tag.get_string(&ItemKey::Unknown("SYNCEDLYRICS".to_owned())) {
|
if let Some(sync_key) = ItemKey::from_key(tag.tag_type(), "SYNCEDLYRICS") {
|
||||||
let lrc = lrc.trim();
|
if let Some(lrc) = tag.get_string(sync_key) {
|
||||||
if !lrc.is_empty() {
|
let lrc = lrc.trim();
|
||||||
return Some(lrc.to_owned());
|
if !lrc.is_empty() {
|
||||||
|
return Some(lrc.to_owned());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(plain) = tag.get_string(&ItemKey::Lyrics) {
|
if let Some(plain) = tag.get_string(ItemKey::Lyrics) {
|
||||||
let plain = plain.trim();
|
let plain = plain.trim();
|
||||||
if !plain.is_empty() {
|
if !plain.is_empty() {
|
||||||
return Some(plain.to_owned());
|
return Some(plain.to_owned());
|
||||||
|
|||||||
+10
@@ -21,6 +21,11 @@ pub(crate) async fn stream_to_file(response: reqwest::Response, dest_path: &std:
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn enqueue_analysis_seed(app: &tauri::AppHandle, track_id: &str, bytes: &[u8]) -> Result<bool, String> {
|
pub(crate) async fn enqueue_analysis_seed(app: &tauri::AppHandle, track_id: &str, bytes: &[u8]) -> Result<bool, String> {
|
||||||
|
if let Some(cache) = app.try_state::<analysis_cache::AnalysisCache>() {
|
||||||
|
if cache.cpu_seed_redundant_for_track(track_id).unwrap_or(false) {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
let high = analysis_backfill_is_current_track(app, track_id);
|
let high = analysis_backfill_is_current_track(app, track_id);
|
||||||
let outcome = submit_analysis_cpu_seed(
|
let outcome = submit_analysis_cpu_seed(
|
||||||
app.clone(),
|
app.clone(),
|
||||||
@@ -51,6 +56,11 @@ pub(crate) async fn enqueue_analysis_seed_from_file(
|
|||||||
track_id: &str,
|
track_id: &str,
|
||||||
file_path: &std::path::Path,
|
file_path: &std::path::Path,
|
||||||
) {
|
) {
|
||||||
|
if let Some(cache) = app.try_state::<analysis_cache::AnalysisCache>() {
|
||||||
|
if cache.cpu_seed_redundant_for_track(track_id).unwrap_or(false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
let bytes = match tokio::fs::read(file_path).await {
|
let bytes = match tokio::fs::read(file_path).await {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(_) => return,
|
Err(_) => return,
|
||||||
|
|||||||
@@ -83,7 +83,12 @@ pub(crate) fn build_tray_icon(app: &tauri::AppHandle) -> tauri::Result<TrayIcon>
|
|||||||
let tray_builder = TrayIconBuilder::new()
|
let tray_builder = TrayIconBuilder::new()
|
||||||
.icon(app.default_window_icon().unwrap().clone())
|
.icon(app.default_window_icon().unwrap().clone())
|
||||||
.menu(&menu)
|
.menu(&menu)
|
||||||
.tooltip(&tooltip_with_icon);
|
.tooltip(&tooltip_with_icon)
|
||||||
|
// tray-icon defaults to opening the context menu on every WM_LBUTTONUP when this is true.
|
||||||
|
// A left double-click emits Down, Up, DoubleClick, Up — the final Up re-opens the menu right
|
||||||
|
// after we hide the window from DoubleClick. We only use left double-click for show/hide
|
||||||
|
// (see on_tray_icon_event); keep the menu on right-click like typical Windows tray apps.
|
||||||
|
.show_menu_on_left_click(false);
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
let tray_builder = TrayIconBuilder::new()
|
let tray_builder = TrayIconBuilder::new()
|
||||||
.icon(app.default_window_icon().unwrap().clone())
|
.icon(app.default_window_icon().unwrap().clone())
|
||||||
|
|||||||
+46
-45
@@ -15,38 +15,39 @@ import { WindowVisibilityProvider } from './hooks/useWindowVisibility';
|
|||||||
import LiveSearch from './components/LiveSearch';
|
import LiveSearch from './components/LiveSearch';
|
||||||
import NowPlayingDropdown from './components/NowPlayingDropdown';
|
import NowPlayingDropdown from './components/NowPlayingDropdown';
|
||||||
import QueuePanel from './components/QueuePanel';
|
import QueuePanel from './components/QueuePanel';
|
||||||
// Eager — main browsing flow, loaded on first paint
|
|
||||||
import Home from './pages/Home';
|
|
||||||
import Albums from './pages/Albums';
|
|
||||||
import Artists from './pages/Artists';
|
|
||||||
import ArtistDetail from './pages/ArtistDetail';
|
|
||||||
import NewReleases from './pages/NewReleases';
|
|
||||||
import Favorites from './pages/Favorites';
|
|
||||||
import RandomMix from './pages/RandomMix';
|
|
||||||
import RandomLanding from './pages/RandomLanding';
|
|
||||||
import Login from './pages/Login';
|
|
||||||
import AlbumDetail from './pages/AlbumDetail';
|
|
||||||
import MostPlayed from './pages/MostPlayed';
|
|
||||||
import RandomAlbums from './pages/RandomAlbums';
|
|
||||||
import LuckyMixPage from './pages/LuckyMix';
|
|
||||||
import SearchResults from './pages/SearchResults';
|
|
||||||
import Playlists from './pages/Playlists';
|
|
||||||
import PlaylistDetail from './pages/PlaylistDetail';
|
|
||||||
import NowPlayingPage from './pages/NowPlaying';
|
|
||||||
|
|
||||||
// Lazy — visited rarely or on-demand. Each becomes its own chunk so the
|
// Route-level lazy loading: keeps the non-page graph (shell, player, stores) in
|
||||||
// initial bundle stays smaller and these pages don't block first paint.
|
// the entry chunk; each page is fetched when its route is first visited.
|
||||||
const Settings = lazy(() => import('./pages/Settings'));
|
const Home = lazy(() => import('./pages/Home'));
|
||||||
const Statistics = lazy(() => import('./pages/Statistics'));
|
const Albums = lazy(() => import('./pages/Albums'));
|
||||||
const Help = lazy(() => import('./pages/Help'));
|
const Artists = lazy(() => import('./pages/Artists'));
|
||||||
const WhatsNew = lazy(() => import('./pages/WhatsNew'));
|
const ArtistDetail = lazy(() => import('./pages/ArtistDetail'));
|
||||||
const DeviceSync = lazy(() => import('./pages/DeviceSync'));
|
const NewReleases = lazy(() => import('./pages/NewReleases'));
|
||||||
|
const Favorites = lazy(() => import('./pages/Favorites'));
|
||||||
|
const RandomMix = lazy(() => import('./pages/RandomMix'));
|
||||||
|
const RandomLanding = lazy(() => import('./pages/RandomLanding'));
|
||||||
|
const Login = lazy(() => import('./pages/Login'));
|
||||||
|
const AlbumDetail = lazy(() => import('./pages/AlbumDetail'));
|
||||||
|
const MostPlayed = lazy(() => import('./pages/MostPlayed'));
|
||||||
|
const RandomAlbums = lazy(() => import('./pages/RandomAlbums'));
|
||||||
|
const LuckyMixPage = lazy(() => import('./pages/LuckyMix'));
|
||||||
|
const SearchResults = lazy(() => import('./pages/SearchResults'));
|
||||||
|
const Playlists = lazy(() => import('./pages/Playlists'));
|
||||||
|
const PlaylistDetail = lazy(() => import('./pages/PlaylistDetail'));
|
||||||
|
const NowPlayingPage = lazy(() => import('./pages/NowPlaying'));
|
||||||
|
const Tracks = lazy(() => import('./pages/Tracks'));
|
||||||
|
const Settings = lazy(() => import('./pages/Settings'));
|
||||||
|
const Statistics = lazy(() => import('./pages/Statistics'));
|
||||||
|
const Help = lazy(() => import('./pages/Help'));
|
||||||
|
const WhatsNew = lazy(() => import('./pages/WhatsNew'));
|
||||||
|
const DeviceSync = lazy(() => import('./pages/DeviceSync'));
|
||||||
const OfflineLibrary = lazy(() => import('./pages/OfflineLibrary'));
|
const OfflineLibrary = lazy(() => import('./pages/OfflineLibrary'));
|
||||||
const LabelAlbums = lazy(() => import('./pages/LabelAlbums'));
|
const LabelAlbums = lazy(() => import('./pages/LabelAlbums'));
|
||||||
const AdvancedSearch = lazy(() => import('./pages/AdvancedSearch'));
|
const AdvancedSearch = lazy(() => import('./pages/AdvancedSearch'));
|
||||||
const FolderBrowser = lazy(() => import('./pages/FolderBrowser'));
|
const FolderBrowser = lazy(() => import('./pages/FolderBrowser'));
|
||||||
const InternetRadio = lazy(() => import('./pages/InternetRadio'));
|
const InternetRadio = lazy(() => import('./pages/InternetRadio'));
|
||||||
const Tracks = lazy(() => import('./pages/Tracks'));
|
const Genres = lazy(() => import('./pages/Genres'));
|
||||||
|
const GenreDetail = lazy(() => import('./pages/GenreDetail'));
|
||||||
import MiniPlayer from './components/MiniPlayer';
|
import MiniPlayer from './components/MiniPlayer';
|
||||||
import { initMiniPlayerBridgeOnMain } from './utils/miniPlayerBridge';
|
import { initMiniPlayerBridgeOnMain } from './utils/miniPlayerBridge';
|
||||||
import FullscreenPlayer from './components/FullscreenPlayer';
|
import FullscreenPlayer from './components/FullscreenPlayer';
|
||||||
@@ -63,8 +64,6 @@ import { APP_MAIN_SCROLL_VIEWPORT_ID } from './constants/appScroll';
|
|||||||
import ConnectionIndicator from './components/ConnectionIndicator';
|
import ConnectionIndicator from './components/ConnectionIndicator';
|
||||||
import LastfmIndicator from './components/LastfmIndicator';
|
import LastfmIndicator from './components/LastfmIndicator';
|
||||||
import OfflineBanner from './components/OfflineBanner';
|
import OfflineBanner from './components/OfflineBanner';
|
||||||
import Genres from './pages/Genres';
|
|
||||||
import GenreDetail from './pages/GenreDetail';
|
|
||||||
import ExportPickerModal from './components/ExportPickerModal';
|
import ExportPickerModal from './components/ExportPickerModal';
|
||||||
import AppUpdater from './components/AppUpdater';
|
import AppUpdater from './components/AppUpdater';
|
||||||
import TitleBar from './components/TitleBar';
|
import TitleBar from './components/TitleBar';
|
||||||
@@ -1316,7 +1315,7 @@ export default function App() {
|
|||||||
else if (e.key === 'psysonic_font') useFontStore.persist.rehydrate();
|
else if (e.key === 'psysonic_font') useFontStore.persist.rehydrate();
|
||||||
else if (e.key === 'psysonic_keybindings') useKeybindingsStore.persist.rehydrate();
|
else if (e.key === 'psysonic_keybindings') useKeybindingsStore.persist.rehydrate();
|
||||||
else if (e.key === 'psysonic_language' && e.newValue) {
|
else if (e.key === 'psysonic_language' && e.newValue) {
|
||||||
import('./i18n').then(m => m.default.changeLanguage(e.newValue!));
|
i18n.changeLanguage(e.newValue);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('storage', onStorage);
|
window.addEventListener('storage', onStorage);
|
||||||
@@ -1402,19 +1401,21 @@ export default function App() {
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<PasteClipboardHandler />
|
<PasteClipboardHandler />
|
||||||
<TauriEventBridge />
|
<TauriEventBridge />
|
||||||
<Routes>
|
<Suspense fallback={null}>
|
||||||
<Route path="/login" element={<Login />} />
|
<Routes>
|
||||||
<Route
|
<Route path="/login" element={<Login />} />
|
||||||
path="/*"
|
<Route
|
||||||
element={
|
path="/*"
|
||||||
<RequireAuth>
|
element={
|
||||||
<DragDropProvider>
|
<RequireAuth>
|
||||||
<AppShell />
|
<DragDropProvider>
|
||||||
</DragDropProvider>
|
<AppShell />
|
||||||
</RequireAuth>
|
</DragDropProvider>
|
||||||
}
|
</RequireAuth>
|
||||||
/>
|
}
|
||||||
</Routes>
|
/>
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
{exportPickerOpen && <ExportPickerModal onConfirm={handleExport} onClose={() => setExportPickerOpen(false)} />}
|
{exportPickerOpen && <ExportPickerModal onConfirm={handleExport} onClose={() => setExportPickerOpen(false)} />}
|
||||||
<ZipDownloadOverlay />
|
<ZipDownloadOverlay />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -278,7 +278,6 @@ function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId }: { albumIds
|
|||||||
|
|
||||||
const handleAddWithToast = async (pl: SubsonicPlaylist, songIds: string[]) => {
|
const handleAddWithToast = async (pl: SubsonicPlaylist, songIds: string[]) => {
|
||||||
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
|
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
|
||||||
const { usePlaylistStore } = await import('../store/playlistStore');
|
|
||||||
const { showToast } = await import('../utils/toast');
|
const { showToast } = await import('../utils/toast');
|
||||||
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||||
|
|
||||||
@@ -342,7 +341,6 @@ function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId }: { albumIds
|
|||||||
|
|
||||||
const handleCreateWithToast = async (songIds: string[]) => {
|
const handleCreateWithToast = async (songIds: string[]) => {
|
||||||
const { createPlaylist } = await import('../api/subsonic');
|
const { createPlaylist } = await import('../api/subsonic');
|
||||||
const { usePlaylistStore } = await import('../store/playlistStore');
|
|
||||||
const { showToast } = await import('../utils/toast');
|
const { showToast } = await import('../utils/toast');
|
||||||
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||||
|
|
||||||
@@ -409,7 +407,6 @@ function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId }: { albumIds
|
|||||||
const { createPlaylist } = await import('../api/subsonic');
|
const { createPlaylist } = await import('../api/subsonic');
|
||||||
const pl = await createPlaylist(name, songIds);
|
const pl = await createPlaylist(name, songIds);
|
||||||
if (pl?.id) {
|
if (pl?.id) {
|
||||||
const { usePlaylistStore } = await import('../store/playlistStore');
|
|
||||||
usePlaylistStore.getState().touchPlaylist(pl.id);
|
usePlaylistStore.getState().touchPlaylist(pl.id);
|
||||||
showToast(
|
showToast(
|
||||||
t('playlists.createAndAddSuccess', { count: songIds.length, playlist: pl.name || name }),
|
t('playlists.createAndAddSuccess', { count: songIds.length, playlist: pl.name || name }),
|
||||||
@@ -522,7 +519,6 @@ function MultiArtistToPlaylistSubmenu({ artistIds, onDone, triggerId }: { artist
|
|||||||
|
|
||||||
const handleAddWithToast = async (pl: SubsonicPlaylist, songIds: string[]) => {
|
const handleAddWithToast = async (pl: SubsonicPlaylist, songIds: string[]) => {
|
||||||
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
|
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
|
||||||
const { usePlaylistStore } = await import('../store/playlistStore');
|
|
||||||
const { showToast } = await import('../utils/toast');
|
const { showToast } = await import('../utils/toast');
|
||||||
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||||
|
|
||||||
@@ -628,7 +624,6 @@ function MultiArtistToPlaylistSubmenu({ artistIds, onDone, triggerId }: { artist
|
|||||||
const { createPlaylist } = await import('../api/subsonic');
|
const { createPlaylist } = await import('../api/subsonic');
|
||||||
const pl = await createPlaylist(name, songIds);
|
const pl = await createPlaylist(name, songIds);
|
||||||
if (pl?.id) {
|
if (pl?.id) {
|
||||||
const { usePlaylistStore } = await import('../store/playlistStore');
|
|
||||||
usePlaylistStore.getState().touchPlaylist(pl.id);
|
usePlaylistStore.getState().touchPlaylist(pl.id);
|
||||||
showToast(
|
showToast(
|
||||||
t('playlists.createAndAddSuccess', { count: songIds.length, playlist: pl.name || name }),
|
t('playlists.createAndAddSuccess', { count: songIds.length, playlist: pl.name || name }),
|
||||||
@@ -762,7 +757,6 @@ function SinglePlaylistToPlaylistSubmenu({ playlist, onDone, triggerId }: { play
|
|||||||
|
|
||||||
const handleAddToNewPlaylist = async (targetId: string, targetName: string) => {
|
const handleAddToNewPlaylist = async (targetId: string, targetName: string) => {
|
||||||
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
|
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
|
||||||
const { usePlaylistStore } = await import('../store/playlistStore');
|
|
||||||
const { showToast } = await import('../utils/toast');
|
const { showToast } = await import('../utils/toast');
|
||||||
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||||
|
|
||||||
@@ -782,7 +776,6 @@ function SinglePlaylistToPlaylistSubmenu({ playlist, onDone, triggerId }: { play
|
|||||||
|
|
||||||
const handleAdd = async (targetId: string, targetName: string) => {
|
const handleAdd = async (targetId: string, targetName: string) => {
|
||||||
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
|
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
|
||||||
const { usePlaylistStore } = await import('../store/playlistStore');
|
|
||||||
const { showToast } = await import('../utils/toast');
|
const { showToast } = await import('../utils/toast');
|
||||||
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||||
|
|
||||||
@@ -910,7 +903,6 @@ function MultiPlaylistToPlaylistSubmenu({ playlists, onDone, triggerId }: { play
|
|||||||
|
|
||||||
const handleMergeToNewPlaylist = async (targetId: string, targetName: string) => {
|
const handleMergeToNewPlaylist = async (targetId: string, targetName: string) => {
|
||||||
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
|
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
|
||||||
const { usePlaylistStore } = await import('../store/playlistStore');
|
|
||||||
const { showToast } = await import('../utils/toast');
|
const { showToast } = await import('../utils/toast');
|
||||||
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||||
|
|
||||||
@@ -941,7 +933,6 @@ function MultiPlaylistToPlaylistSubmenu({ playlists, onDone, triggerId }: { play
|
|||||||
|
|
||||||
const handleMerge = async (targetId: string, targetName: string) => {
|
const handleMerge = async (targetId: string, targetName: string) => {
|
||||||
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
|
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
|
||||||
const { usePlaylistStore } = await import('../store/playlistStore');
|
|
||||||
const { showToast } = await import('../utils/toast');
|
const { showToast } = await import('../utils/toast');
|
||||||
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||||
|
|
||||||
@@ -1673,7 +1664,6 @@ export default function ContextMenu() {
|
|||||||
{playlistId && playlistSongIndex !== undefined && (
|
{playlistId && playlistSongIndex !== undefined && (
|
||||||
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
|
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
|
||||||
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
|
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
|
||||||
const { usePlaylistStore } = await import('../store/playlistStore');
|
|
||||||
const { showToast } = await import('../utils/toast');
|
const { showToast } = await import('../utils/toast');
|
||||||
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||||
try {
|
try {
|
||||||
@@ -1931,7 +1921,6 @@ export default function ContextMenu() {
|
|||||||
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
|
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
|
||||||
const { showToast } = await import('../utils/toast');
|
const { showToast } = await import('../utils/toast');
|
||||||
const { deletePlaylist } = await import('../api/subsonic');
|
const { deletePlaylist } = await import('../api/subsonic');
|
||||||
const { usePlaylistStore } = await import('../store/playlistStore');
|
|
||||||
const { removeId } = usePlaylistStore.getState();
|
const { removeId } = usePlaylistStore.getState();
|
||||||
try {
|
try {
|
||||||
await deletePlaylist(playlist.id);
|
await deletePlaylist(playlist.id);
|
||||||
@@ -2145,7 +2134,6 @@ export default function ContextMenu() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
|
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
|
||||||
const { showToast } = await import('../utils/toast');
|
const { showToast } = await import('../utils/toast');
|
||||||
const { usePlaylistStore } = await import('../store/playlistStore');
|
|
||||||
const { deletePlaylist } = await import('../api/subsonic');
|
const { deletePlaylist } = await import('../api/subsonic');
|
||||||
const { removeId } = usePlaylistStore.getState();
|
const { removeId } = usePlaylistStore.getState();
|
||||||
const deletedIds: string[] = [];
|
const deletedIds: string[] = [];
|
||||||
|
|||||||
@@ -39,6 +39,26 @@ const NEW_RELEASES_UNREAD_STORAGE_PREFIX = 'psy_new_releases_unread_seen_v1';
|
|||||||
const NEW_RELEASES_UNREAD_SAMPLE_SIZE = 80;
|
const NEW_RELEASES_UNREAD_SAMPLE_SIZE = 80;
|
||||||
const NEW_RELEASES_UNREAD_POLL_MS = 2 * 60 * 1000;
|
const NEW_RELEASES_UNREAD_POLL_MS = 2 * 60 * 1000;
|
||||||
const NEW_RELEASES_RESET_DELAY_MS = 5_000;
|
const NEW_RELEASES_RESET_DELAY_MS = 5_000;
|
||||||
|
/** Max album ids persisted per server/scope; cap must not drop the latest "newest" batch when marking read. */
|
||||||
|
const NEW_RELEASES_SEEN_MAX_IDS = 500;
|
||||||
|
|
||||||
|
/** Merge previous seen IDs with the current `getAlbumList(newest)` sample: newest batch is kept in full first, then older seen until `maxIds` (localStorage budget). */
|
||||||
|
function mergeSeenNewReleaseIdsCap(prevSeen: string[], newestBatch: string[], maxIds: number): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const id of newestBatch) {
|
||||||
|
if (!id || seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
out.push(id);
|
||||||
|
}
|
||||||
|
for (const id of prevSeen) {
|
||||||
|
if (out.length >= maxIds) break;
|
||||||
|
if (!id || seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
out.push(id);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function isSmartPlaylistName(name: string): boolean {
|
function isSmartPlaylistName(name: string): boolean {
|
||||||
return (name ?? '').toLowerCase().startsWith(SMART_PREFIX);
|
return (name ?? '').toLowerCase().startsWith(SMART_PREFIX);
|
||||||
@@ -197,7 +217,7 @@ export default function Sidebar({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const writeSeenNewReleaseIdsByKey = useCallback((key: string, ids: string[]) => {
|
const writeSeenNewReleaseIdsByKey = useCallback((key: string, ids: string[]) => {
|
||||||
const normalized = Array.from(new Set(ids.filter(Boolean))).slice(0, 500);
|
const normalized = Array.from(new Set(ids.filter(Boolean))).slice(0, NEW_RELEASES_SEEN_MAX_IDS);
|
||||||
localStorage.setItem(key, JSON.stringify(normalized));
|
localStorage.setItem(key, JSON.stringify(normalized));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -238,11 +258,16 @@ export default function Sidebar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (markAsSeen) {
|
if (markAsSeen) {
|
||||||
writeSeenNewReleaseIds([...seenIds, ...newestIds]);
|
// Prepend the live newest sample so a full `seenIds` list + slice(500)
|
||||||
|
// cannot silently discard freshly "read" albums (fixes badge coming back).
|
||||||
|
writeSeenNewReleaseIds(mergeSeenNewReleaseIdsCap(seenIds, newestIds, NEW_RELEASES_SEEN_MAX_IDS));
|
||||||
// Keep server-wide baseline in sync so scope fallback never resurrects
|
// Keep server-wide baseline in sync so scope fallback never resurrects
|
||||||
// already-viewed items after opening the New Releases page.
|
// already-viewed items after opening the New Releases page.
|
||||||
const allScopeSeen = readSeenNewReleaseIdsByKey(newReleasesSeenAllScopeStorageKey);
|
const allScopeSeen = readSeenNewReleaseIdsByKey(newReleasesSeenAllScopeStorageKey);
|
||||||
writeSeenNewReleaseIdsByKey(newReleasesSeenAllScopeStorageKey, [...allScopeSeen, ...newestIds]);
|
writeSeenNewReleaseIdsByKey(
|
||||||
|
newReleasesSeenAllScopeStorageKey,
|
||||||
|
mergeSeenNewReleaseIdsCap(allScopeSeen, newestIds, NEW_RELEASES_SEEN_MAX_IDS),
|
||||||
|
);
|
||||||
if (isCurrent()) setNewReleasesUnreadCount(0);
|
if (isCurrent()) setNewReleasesUnreadCount(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ const CONTRIBUTORS = [
|
|||||||
'Queue: drag rows outside to remove with trash ghost (PR #420)',
|
'Queue: drag rows outside to remove with trash ghost (PR #420)',
|
||||||
'Tauri: modularize audio and lib command layers (PR #422)',
|
'Tauri: modularize audio and lib command layers (PR #422)',
|
||||||
'Shortcuts: action registry, dynamic CLI help, 9 new input targets + F1 help binding (PR #435)',
|
'Shortcuts: action registry, dynamic CLI help, 9 new input targets + F1 help binding (PR #435)',
|
||||||
|
'Environment upgrade & hot-cache playback — replay via RAM/disk on same-track and queue-end resume, playback-source icon stays correct after resume/undo/gapless, sidebar new-releases 500-id cap merge, Windows tray double-click fix, lazy-loaded routes, rodio 0.22 migration (PR #463)',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { invoke } from '@tauri-apps/api/core';
|
|||||||
import { isHotCachePreviousTrackUnderGrace } from '../utils/hotCacheGate';
|
import { isHotCachePreviousTrackUnderGrace } from '../utils/hotCacheGate';
|
||||||
import type { Track } from './playerStore';
|
import type { Track } from './playerStore';
|
||||||
import { emitAnalysisStorageChanged } from './analysisSync';
|
import { emitAnalysisStorageChanged } from './analysisSync';
|
||||||
|
import { useAuthStore } from './authStore';
|
||||||
|
|
||||||
/** How many queue slots after the current index are eviction-protected (1 = current + next only). */
|
/** How many queue slots after the current index are eviction-protected (1 = current + next only). */
|
||||||
export const HOT_CACHE_PROTECT_AFTER_CURRENT = 1;
|
export const HOT_CACHE_PROTECT_AFTER_CURRENT = 1;
|
||||||
@@ -67,17 +68,13 @@ function evictionReasonForTier(tier: number): string {
|
|||||||
return labels[tier] ?? `tier-${tier}`;
|
return labels[tier] ?? `tier-${tier}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Settings → Logging → Debug, same as `emitNormalizationDebug` / lucky-mix. Dynamic `authStore` import avoids a static cycle (auth → player → hot-cache). */
|
/** Settings → Logging → Debug, same as `emitNormalizationDebug` / lucky-mix. */
|
||||||
function hotCacheFrontendDebug(payload: Record<string, unknown>): void {
|
function hotCacheFrontendDebug(payload: Record<string, unknown>): void {
|
||||||
void import('./authStore')
|
if (useAuthStore.getState().loggingMode !== 'debug') return;
|
||||||
.then(({ useAuthStore }) => {
|
void invoke('frontend_debug_log', {
|
||||||
if (useAuthStore.getState().loggingMode !== 'debug') return;
|
scope: 'hot-cache',
|
||||||
return invoke('frontend_debug_log', {
|
message: JSON.stringify(payload),
|
||||||
scope: 'hot-cache',
|
}).catch(() => {});
|
||||||
message: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useHotCacheStore = create<HotCacheState>()(
|
export const useHotCacheStore = create<HotCacheState>()(
|
||||||
|
|||||||
+259
-110
@@ -478,6 +478,10 @@ function queueUndoRestoreAudioEngine(opts: {
|
|||||||
);
|
);
|
||||||
const replayGainPeak = isReplayGainActive() ? (track.replayGainPeak ?? null) : null;
|
const replayGainPeak = isReplayGainActive() ? (track.replayGainPeak ?? null) : null;
|
||||||
const url = resolvePlaybackUrl(track.id, authState.activeServerId ?? '');
|
const url = resolvePlaybackUrl(track.id, authState.activeServerId ?? '');
|
||||||
|
recordEnginePlayUrl(track.id, url);
|
||||||
|
usePlayerStore.setState({
|
||||||
|
currentPlaybackSource: playbackSourceHintForResolvedUrl(track.id, authState.activeServerId ?? '', url),
|
||||||
|
});
|
||||||
const keepPreloadHint = usePlayerStore.getState().enginePreloadedTrackId === track.id;
|
const keepPreloadHint = usePlayerStore.getState().enginePreloadedTrackId === track.id;
|
||||||
setDeferHotCachePrefetch(true);
|
setDeferHotCachePrefetch(true);
|
||||||
invoke('audio_play', {
|
invoke('audio_play', {
|
||||||
@@ -492,6 +496,7 @@ function queueUndoRestoreAudioEngine(opts: {
|
|||||||
manual: false,
|
manual: false,
|
||||||
hiResEnabled: authState.enableHiRes,
|
hiResEnabled: authState.enableHiRes,
|
||||||
analysisTrackId: track.id,
|
analysisTrackId: track.id,
|
||||||
|
streamFormatSuffix: track.suffix ?? null,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (playGeneration !== generation) return;
|
if (playGeneration !== generation) return;
|
||||||
@@ -1187,6 +1192,31 @@ async function promoteCompletedStreamToHotCache(track: Track, serverId: string,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks the **actual** `audio_play` URL family: `getPlaybackSourceKind()` can
|
||||||
|
* report `hot` for RAM preload or disk index while the engine still uses HTTP.
|
||||||
|
* Rebind-to-local seeks need this, not the UI hint alone.
|
||||||
|
*/
|
||||||
|
let lastOpenedWithHttpTrackId: string | null = null;
|
||||||
|
|
||||||
|
function recordEnginePlayUrl(trackId: string, url: string): void {
|
||||||
|
lastOpenedWithHttpTrackId = url.startsWith('psysonic-local://') ? null : trackId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Matches `playTrack` / PlayerBar: stream vs hot-cache vs offline file from resolved `audio_play` URL. */
|
||||||
|
function playbackSourceHintForResolvedUrl(trackId: string, serverId: string, url: string): PlaybackSourceKind {
|
||||||
|
if (!url.startsWith('psysonic-local://')) return 'stream';
|
||||||
|
return useOfflineStore.getState().getLocalUrl(trackId, serverId) ? 'offline' : 'hot';
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRebindPlaybackToHotCache(trackId: string, serverId: string): boolean {
|
||||||
|
if (!serverId) return false;
|
||||||
|
if (!lastOpenedWithHttpTrackId || !sameQueueTrackId(lastOpenedWithHttpTrackId, trackId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return resolvePlaybackUrl(trackId, serverId).startsWith('psysonic-local://');
|
||||||
|
}
|
||||||
|
|
||||||
// Track ID that has already been sent to audio_chain_preload (gapless chain).
|
// Track ID that has already been sent to audio_chain_preload (gapless chain).
|
||||||
let gaplessPreloadingId: string | null = null;
|
let gaplessPreloadingId: string | null = null;
|
||||||
// Track ID that has already been sent to audio_preload (byte pre-download).
|
// Track ID that has already been sent to audio_preload (byte pre-download).
|
||||||
@@ -1479,11 +1509,24 @@ function handleAudioEnded() {
|
|||||||
buffered: 0,
|
buffered: 0,
|
||||||
});
|
});
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (repeatMode === 'one' && currentTrack) {
|
void (async () => {
|
||||||
usePlayerStore.getState().playTrack(currentTrack, queue, false);
|
if (repeatMode === 'one' && currentTrack) {
|
||||||
} else {
|
const authState = useAuthStore.getState();
|
||||||
usePlayerStore.getState().next(false);
|
const repeatPromoteSid = authState.activeServerId;
|
||||||
}
|
if (authState.hotCacheEnabled && repeatPromoteSid) {
|
||||||
|
// Same-track repeat never hit `playTrack`'s prev→promote path; flush
|
||||||
|
// Rust `stream_completed_cache` to disk so `resolvePlaybackUrl` uses local.
|
||||||
|
await promoteCompletedStreamToHotCache(
|
||||||
|
currentTrack,
|
||||||
|
repeatPromoteSid,
|
||||||
|
authState.hotCacheDownloadDir || null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
usePlayerStore.getState().playTrack(currentTrack, queue, false);
|
||||||
|
} else {
|
||||||
|
usePlayerStore.getState().next(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
}, 150);
|
}, 150);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1519,6 +1562,10 @@ function handleAudioTrackSwitched(duration: number) {
|
|||||||
|
|
||||||
if (!nextTrack) return;
|
if (!nextTrack) return;
|
||||||
|
|
||||||
|
const switchServerId = useAuthStore.getState().activeServerId ?? '';
|
||||||
|
const switchResolvedUrl = resolvePlaybackUrl(nextTrack.id, switchServerId);
|
||||||
|
const switchPlaybackSource = playbackSourceHintForResolvedUrl(nextTrack.id, switchServerId, switchResolvedUrl);
|
||||||
|
|
||||||
usePlayerStore.setState({
|
usePlayerStore.setState({
|
||||||
currentTrack: nextTrack,
|
currentTrack: nextTrack,
|
||||||
waveformBins: null,
|
waveformBins: null,
|
||||||
@@ -1532,6 +1579,7 @@ function handleAudioTrackSwitched(duration: number) {
|
|||||||
buffered: 0,
|
buffered: 0,
|
||||||
scrobbled: false,
|
scrobbled: false,
|
||||||
lastfmLoved: false,
|
lastfmLoved: false,
|
||||||
|
currentPlaybackSource: switchPlaybackSource,
|
||||||
});
|
});
|
||||||
emitNormalizationDebug('track-switched', {
|
emitNormalizationDebug('track-switched', {
|
||||||
trackId: nextTrack.id,
|
trackId: nextTrack.id,
|
||||||
@@ -2436,110 +2484,168 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
track.duration && track.duration > 0 ? Math.max(0, Math.min(1, initialTime / track.duration)) : 0;
|
track.duration && track.duration > 0 ? Math.max(0, Math.min(1, initialTime / track.duration)) : 0;
|
||||||
|
|
||||||
const authState = useAuthStore.getState();
|
const authState = useAuthStore.getState();
|
||||||
const url = resolvePlaybackUrl(track.id, authState.activeServerId ?? '');
|
// Same-track replay: Rust `fetch_data` consumes `stream_completed_cache` with
|
||||||
const preloadedTrackId = get().enginePreloadedTrackId;
|
// `take()` once; a second replay would full HTTP-range again unless we flush
|
||||||
const keepPreloadHint = preloadedTrackId === track.id;
|
// RAM to hot disk first (promote was only run when switching to another track).
|
||||||
const playbackSourceHint = getPlaybackSourceKind(
|
const needSameTrackHotPromote =
|
||||||
track.id,
|
Boolean(
|
||||||
authState.activeServerId ?? '',
|
prevTrack
|
||||||
keepPreloadHint ? track.id : null,
|
&& sameQueueTrackId(prevTrack.id, track.id)
|
||||||
);
|
&& authState.hotCacheEnabled
|
||||||
if (import.meta.env.DEV) {
|
&& authState.activeServerId,
|
||||||
console.info('[psysonic][playTrack-source]', {
|
|
||||||
trackId: track.id,
|
|
||||||
resolvedUrl: url,
|
|
||||||
preloadedTrackId,
|
|
||||||
keepPreloadHint,
|
|
||||||
playbackSourceHint,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set state immediately so the UI updates before the download completes.
|
|
||||||
// currentRadio: null ensures the PlayerBar switches out of radio mode right away.
|
|
||||||
set({
|
|
||||||
currentTrack: track,
|
|
||||||
currentRadio: null,
|
|
||||||
waveformBins: null,
|
|
||||||
...deriveNormalizationSnapshot(track, newQueue, idx >= 0 ? idx : 0),
|
|
||||||
queue: newQueue,
|
|
||||||
queueIndex: idx >= 0 ? idx : 0,
|
|
||||||
progress: initialProgress,
|
|
||||||
buffered: 0,
|
|
||||||
currentTime: initialTime,
|
|
||||||
scrobbled: false,
|
|
||||||
lastfmLoved: false,
|
|
||||||
isPlaying: true, // optimistic — reverted on error
|
|
||||||
currentPlaybackSource: playbackSourceHint,
|
|
||||||
enginePreloadedTrackId: keepPreloadHint ? track.id : null,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (
|
|
||||||
prevTrack
|
|
||||||
&& prevTrack.id !== track.id
|
|
||||||
&& authState.hotCacheEnabled
|
|
||||||
&& authState.activeServerId
|
|
||||||
) {
|
|
||||||
void promoteCompletedStreamToHotCache(
|
|
||||||
prevTrack,
|
|
||||||
authState.activeServerId,
|
|
||||||
authState.hotCacheDownloadDir || null,
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
void refreshWaveformForTrack(track.id);
|
const runPlayTrackBody = () => {
|
||||||
void refreshLoudnessForTrack(track.id);
|
const authStateNow = useAuthStore.getState();
|
||||||
setDeferHotCachePrefetch(true);
|
const url = resolvePlaybackUrl(track.id, authStateNow.activeServerId ?? '');
|
||||||
const playIdx = idx >= 0 ? idx : 0;
|
recordEnginePlayUrl(track.id, url);
|
||||||
const nextNeighbour = playIdx + 1 < newQueue.length ? newQueue[playIdx + 1] : null;
|
const preloadedTrackId = get().enginePreloadedTrackId;
|
||||||
const replayGainDb = resolveReplayGainDb(
|
const keepPreloadHint = preloadedTrackId === track.id;
|
||||||
track, prevTrack, nextNeighbour,
|
const playbackSourceHint = playbackSourceHintForResolvedUrl(
|
||||||
isReplayGainActive(), authState.replayGainMode,
|
track.id,
|
||||||
);
|
authStateNow.activeServerId ?? '',
|
||||||
const replayGainPeak = isReplayGainActive() ? (track.replayGainPeak ?? null) : null;
|
url,
|
||||||
invoke('audio_play', {
|
);
|
||||||
url,
|
if (import.meta.env.DEV) {
|
||||||
volume: state.volume,
|
console.info('[psysonic][playTrack-source]', {
|
||||||
durationHint: track.duration,
|
trackId: track.id,
|
||||||
replayGainDb,
|
resolvedUrl: url,
|
||||||
replayGainPeak,
|
preloadedTrackId,
|
||||||
loudnessGainDb: loudnessGainDbForEngineBind(track.id),
|
keepPreloadHint,
|
||||||
preGainDb: authState.replayGainPreGainDb,
|
playbackSourceHint,
|
||||||
fallbackDb: authState.replayGainFallbackDb,
|
});
|
||||||
manual,
|
}
|
||||||
hiResEnabled: authState.enableHiRes,
|
|
||||||
analysisTrackId: track.id,
|
// Set state immediately so the UI updates before the download completes.
|
||||||
})
|
// currentRadio: null ensures the PlayerBar switches out of radio mode right away.
|
||||||
.then(() => {
|
set({
|
||||||
if (playGeneration !== gen) return;
|
currentTrack: track,
|
||||||
if (keepPreloadHint) {
|
currentRadio: null,
|
||||||
usePlayerStore.setState({ enginePreloadedTrackId: null });
|
waveformBins: null,
|
||||||
}
|
...deriveNormalizationSnapshot(track, newQueue, idx >= 0 ? idx : 0),
|
||||||
})
|
queue: newQueue,
|
||||||
.catch((err: unknown) => {
|
queueIndex: idx >= 0 ? idx : 0,
|
||||||
if (playGeneration !== gen) return;
|
progress: initialProgress,
|
||||||
setDeferHotCachePrefetch(false);
|
buffered: 0,
|
||||||
console.error('[psysonic] audio_play failed:', err);
|
currentTime: initialTime,
|
||||||
set({ isPlaying: false });
|
scrobbled: false,
|
||||||
setTimeout(() => {
|
lastfmLoved: false,
|
||||||
if (playGeneration !== gen) return;
|
isPlaying: true, // optimistic — reverted on error
|
||||||
get().next(false);
|
currentPlaybackSource: playbackSourceHint,
|
||||||
}, 500);
|
enginePreloadedTrackId: keepPreloadHint ? track.id : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Report Now Playing to Navidrome (for Live/getNowPlaying) + Last.fm
|
if (
|
||||||
const { nowPlayingEnabled: npEnabled, scrobblingEnabled: lfmEnabled, lastfmSessionKey: lfmKey } = useAuthStore.getState();
|
prevTrack
|
||||||
if (npEnabled) reportNowPlaying(track.id);
|
&& !sameQueueTrackId(prevTrack.id, track.id)
|
||||||
if (lfmKey) {
|
&& authStateNow.hotCacheEnabled
|
||||||
if (lfmEnabled) lastfmUpdateNowPlaying(track, lfmKey);
|
) {
|
||||||
lastfmGetTrackLoved(track.title, track.artist, lfmKey).then(loved => {
|
const prevPromoteSid = authStateNow.activeServerId;
|
||||||
const cacheKey = `${track.title}::${track.artist}`;
|
if (prevPromoteSid) {
|
||||||
usePlayerStore.setState(s => ({
|
void promoteCompletedStreamToHotCache(
|
||||||
lastfmLoved: loved,
|
prevTrack,
|
||||||
lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: loved },
|
prevPromoteSid,
|
||||||
}));
|
authStateNow.hotCacheDownloadDir || null,
|
||||||
});
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void refreshWaveformForTrack(track.id);
|
||||||
|
void refreshLoudnessForTrack(track.id);
|
||||||
|
setDeferHotCachePrefetch(true);
|
||||||
|
const playIdx = idx >= 0 ? idx : 0;
|
||||||
|
const nextNeighbour = playIdx + 1 < newQueue.length ? newQueue[playIdx + 1] : null;
|
||||||
|
const replayGainDb = resolveReplayGainDb(
|
||||||
|
track, prevTrack, nextNeighbour,
|
||||||
|
isReplayGainActive(), authStateNow.replayGainMode,
|
||||||
|
);
|
||||||
|
const replayGainPeak = isReplayGainActive() ? (track.replayGainPeak ?? null) : null;
|
||||||
|
invoke('audio_play', {
|
||||||
|
url,
|
||||||
|
volume: state.volume,
|
||||||
|
durationHint: track.duration,
|
||||||
|
replayGainDb,
|
||||||
|
replayGainPeak,
|
||||||
|
loudnessGainDb: loudnessGainDbForEngineBind(track.id),
|
||||||
|
preGainDb: authStateNow.replayGainPreGainDb,
|
||||||
|
fallbackDb: authStateNow.replayGainFallbackDb,
|
||||||
|
manual,
|
||||||
|
hiResEnabled: authStateNow.enableHiRes,
|
||||||
|
analysisTrackId: track.id,
|
||||||
|
streamFormatSuffix: track.suffix ?? null,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
if (playGeneration !== gen) return;
|
||||||
|
if (keepPreloadHint) {
|
||||||
|
usePlayerStore.setState({ enginePreloadedTrackId: null });
|
||||||
|
}
|
||||||
|
const durSeek = track.duration && track.duration > 0 ? track.duration : null;
|
||||||
|
const seekTo = initialTime;
|
||||||
|
const canSeekAfterPlay =
|
||||||
|
seekTo > 0.05 && (durSeek == null || seekTo < durSeek - 0.05);
|
||||||
|
if (canSeekAfterPlay) {
|
||||||
|
void invoke('audio_seek', { seconds: seekTo })
|
||||||
|
.then(() => {
|
||||||
|
if (playGeneration !== gen) return;
|
||||||
|
setSeekTarget(seekTo);
|
||||||
|
if (seekFallbackVisualTarget?.trackId === track.id) {
|
||||||
|
seekFallbackVisualTarget = null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (seekFallbackVisualTarget?.trackId === track.id) {
|
||||||
|
seekFallbackVisualTarget = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (playGeneration !== gen) return;
|
||||||
|
setDeferHotCachePrefetch(false);
|
||||||
|
console.error('[psysonic] audio_play failed:', err);
|
||||||
|
set({ isPlaying: false });
|
||||||
|
setTimeout(() => {
|
||||||
|
if (playGeneration !== gen) return;
|
||||||
|
get().next(false);
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Report Now Playing to Navidrome (for Live/getNowPlaying) + Last.fm
|
||||||
|
const { nowPlayingEnabled: npEnabled, scrobblingEnabled: lfmEnabled, lastfmSessionKey: lfmKey } = useAuthStore.getState();
|
||||||
|
if (npEnabled) reportNowPlaying(track.id);
|
||||||
|
if (lfmKey) {
|
||||||
|
if (lfmEnabled) lastfmUpdateNowPlaying(track, lfmKey);
|
||||||
|
lastfmGetTrackLoved(track.title, track.artist, lfmKey).then(loved => {
|
||||||
|
const cacheKey = `${track.title}::${track.artist}`;
|
||||||
|
usePlayerStore.setState(s => ({
|
||||||
|
lastfmLoved: loved,
|
||||||
|
lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: loved },
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
syncQueueToServer(newQueue, track, initialTime);
|
||||||
|
touchHotCacheOnPlayback(track.id, authStateNow.activeServerId ?? '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const hotPromoteSid = authState.activeServerId;
|
||||||
|
if (needSameTrackHotPromote && hotPromoteSid) {
|
||||||
|
void promoteCompletedStreamToHotCache(
|
||||||
|
track,
|
||||||
|
hotPromoteSid,
|
||||||
|
authState.hotCacheDownloadDir || null,
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
if (playGeneration !== gen) return;
|
||||||
|
runPlayTrackBody();
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (playGeneration !== gen) return;
|
||||||
|
setDeferHotCachePrefetch(false);
|
||||||
|
console.error('[psysonic] same-track hot promote / play body failed:', err);
|
||||||
|
set({ isPlaying: false });
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
runPlayTrackBody();
|
||||||
}
|
}
|
||||||
syncQueueToServer(newQueue, track, initialTime);
|
|
||||||
touchHotCacheOnPlayback(track.id, authState.activeServerId ?? '');
|
|
||||||
},
|
},
|
||||||
|
|
||||||
reseedQueueForInstantMix: (track) => {
|
reseedQueueForInstantMix: (track) => {
|
||||||
@@ -2666,13 +2772,28 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
set({ isPlaying: true });
|
set({ isPlaying: true });
|
||||||
touchHotCacheOnPlayback(currentTrack.id, useAuthStore.getState().activeServerId ?? '');
|
touchHotCacheOnPlayback(currentTrack.id, useAuthStore.getState().activeServerId ?? '');
|
||||||
} else {
|
} else {
|
||||||
// Cold start (app relaunch) — fetch fresh track data for replay gain, then play.
|
// Engine has no loaded paused stream (app relaunch, or track ended and user
|
||||||
|
// hits play — `isAudioPaused` is false after `audio:ended`). Flush any
|
||||||
|
// `stream_completed_cache` from the prior play to hot disk before resolving URL.
|
||||||
const gen = ++playGeneration;
|
const gen = ++playGeneration;
|
||||||
const vol = get().volume;
|
const vol = get().volume;
|
||||||
set({ isPlaying: true });
|
set({ isPlaying: true });
|
||||||
|
|
||||||
// Fetch fresh track data from server to get replay gain metadata
|
void (async () => {
|
||||||
getSong(currentTrack.id).then(freshSong => {
|
const authHot = useAuthStore.getState();
|
||||||
|
const resumePromoteSid = authHot.activeServerId;
|
||||||
|
if (authHot.hotCacheEnabled && resumePromoteSid) {
|
||||||
|
await promoteCompletedStreamToHotCache(
|
||||||
|
currentTrack,
|
||||||
|
resumePromoteSid,
|
||||||
|
authHot.hotCacheDownloadDir || null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (playGeneration !== gen) return;
|
||||||
|
|
||||||
|
// Fetch fresh track data from server to get replay gain metadata
|
||||||
|
getSong(currentTrack.id).then(freshSong => {
|
||||||
|
if (playGeneration !== gen) return;
|
||||||
const trackToPlay = freshSong ? songToTrack(freshSong) : currentTrack;
|
const trackToPlay = freshSong ? songToTrack(freshSong) : currentTrack;
|
||||||
// Update store with fresh track data if available
|
// Update store with fresh track data if available
|
||||||
if (freshSong) set({ currentTrack: trackToPlay });
|
if (freshSong) set({ currentTrack: trackToPlay });
|
||||||
@@ -2685,6 +2806,8 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
const coldServerId = useAuthStore.getState().activeServerId ?? '';
|
const coldServerId = useAuthStore.getState().activeServerId ?? '';
|
||||||
setDeferHotCachePrefetch(true);
|
setDeferHotCachePrefetch(true);
|
||||||
const coldUrl = resolvePlaybackUrl(trackToPlay.id, coldServerId);
|
const coldUrl = resolvePlaybackUrl(trackToPlay.id, coldServerId);
|
||||||
|
set({ currentPlaybackSource: playbackSourceHintForResolvedUrl(trackToPlay.id, coldServerId, coldUrl) });
|
||||||
|
recordEnginePlayUrl(trackToPlay.id, coldUrl);
|
||||||
touchHotCacheOnPlayback(trackToPlay.id, coldServerId);
|
touchHotCacheOnPlayback(trackToPlay.id, coldServerId);
|
||||||
invoke('audio_play', {
|
invoke('audio_play', {
|
||||||
url: coldUrl,
|
url: coldUrl,
|
||||||
@@ -2698,6 +2821,7 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
manual: false,
|
manual: false,
|
||||||
hiResEnabled: useAuthStore.getState().enableHiRes,
|
hiResEnabled: useAuthStore.getState().enableHiRes,
|
||||||
analysisTrackId: trackToPlay.id,
|
analysisTrackId: trackToPlay.id,
|
||||||
|
streamFormatSuffix: trackToPlay.suffix ?? null,
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
if (playGeneration === gen && currentTime > 1) {
|
if (playGeneration === gen && currentTime > 1) {
|
||||||
invoke('audio_seek', { seconds: currentTime }).catch(console.error);
|
invoke('audio_seek', { seconds: currentTime }).catch(console.error);
|
||||||
@@ -2721,6 +2845,8 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
const coldServerId = useAuthStore.getState().activeServerId ?? '';
|
const coldServerId = useAuthStore.getState().activeServerId ?? '';
|
||||||
setDeferHotCachePrefetch(true);
|
setDeferHotCachePrefetch(true);
|
||||||
const coldUrl = resolvePlaybackUrl(currentTrack.id, coldServerId);
|
const coldUrl = resolvePlaybackUrl(currentTrack.id, coldServerId);
|
||||||
|
set({ currentPlaybackSource: playbackSourceHintForResolvedUrl(currentTrack.id, coldServerId, coldUrl) });
|
||||||
|
recordEnginePlayUrl(currentTrack.id, coldUrl);
|
||||||
touchHotCacheOnPlayback(currentTrack.id, coldServerId);
|
touchHotCacheOnPlayback(currentTrack.id, coldServerId);
|
||||||
invoke('audio_play', {
|
invoke('audio_play', {
|
||||||
url: coldUrl,
|
url: coldUrl,
|
||||||
@@ -2734,6 +2860,7 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
manual: false,
|
manual: false,
|
||||||
hiResEnabled: useAuthStore.getState().enableHiRes,
|
hiResEnabled: useAuthStore.getState().enableHiRes,
|
||||||
analysisTrackId: currentTrack.id,
|
analysisTrackId: currentTrack.id,
|
||||||
|
streamFormatSuffix: currentTrack.suffix ?? null,
|
||||||
}).catch((err: unknown) => {
|
}).catch((err: unknown) => {
|
||||||
if (playGeneration !== gen) return;
|
if (playGeneration !== gen) return;
|
||||||
setDeferHotCachePrefetch(false);
|
setDeferHotCachePrefetch(false);
|
||||||
@@ -2742,6 +2869,7 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
});
|
});
|
||||||
syncQueueToServer(queue, currentTrack, currentTime);
|
syncQueueToServer(queue, currentTrack, currentTime);
|
||||||
});
|
});
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -2923,10 +3051,17 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
previous: () => {
|
previous: () => {
|
||||||
const { queue, queueIndex } = get();
|
const { queue, queueIndex, currentTrack } = get();
|
||||||
const currentTime = getPlaybackProgressSnapshot().currentTime;
|
const currentTime = getPlaybackProgressSnapshot().currentTime;
|
||||||
if (currentTime > 3) {
|
if (currentTime > 3) {
|
||||||
// Restart current track from the beginning.
|
// Restart current track from the beginning.
|
||||||
|
const authState = useAuthStore.getState();
|
||||||
|
const sid = authState.activeServerId ?? '';
|
||||||
|
if (currentTrack && shouldRebindPlaybackToHotCache(currentTrack.id, sid)) {
|
||||||
|
seekFallbackVisualTarget = { trackId: currentTrack.id, seconds: 0, setAtMs: Date.now() };
|
||||||
|
get().playTrack(currentTrack, queue, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
invoke('audio_seek', { seconds: 0 }).catch(console.error);
|
invoke('audio_seek', { seconds: 0 }).catch(console.error);
|
||||||
set({ progress: 0, currentTime: 0 });
|
set({ progress: 0, currentTime: 0 });
|
||||||
return;
|
return;
|
||||||
@@ -2947,6 +3082,20 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
if (seekDebounce) clearTimeout(seekDebounce);
|
if (seekDebounce) clearTimeout(seekDebounce);
|
||||||
seekDebounce = setTimeout(() => {
|
seekDebounce = setTimeout(() => {
|
||||||
seekDebounce = null;
|
seekDebounce = null;
|
||||||
|
const s0 = get();
|
||||||
|
if (!s0.currentTrack) return;
|
||||||
|
const authSeek = useAuthStore.getState();
|
||||||
|
const sidSeek = authSeek.activeServerId ?? '';
|
||||||
|
if (shouldRebindPlaybackToHotCache(s0.currentTrack.id, sidSeek)) {
|
||||||
|
seekFallbackVisualTarget = {
|
||||||
|
trackId: s0.currentTrack.id,
|
||||||
|
seconds: time,
|
||||||
|
setAtMs: Date.now(),
|
||||||
|
};
|
||||||
|
clearSeekFallbackRetry();
|
||||||
|
s0.playTrack(s0.currentTrack, s0.queue, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
invoke('audio_seek', { seconds: time }).then(() => {
|
invoke('audio_seek', { seconds: time }).then(() => {
|
||||||
// Arm stale-progress guard only after backend acknowledged seek.
|
// Arm stale-progress guard only after backend acknowledged seek.
|
||||||
setSeekTarget(time);
|
setSeekTarget(time);
|
||||||
|
|||||||
+21
-13
@@ -24,7 +24,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
envPrefix: ["VITE_", "TAURI_ENV_*"],
|
envPrefix: ["VITE_", "TAURI_ENV_*"],
|
||||||
build: {
|
build: {
|
||||||
target: process.env.TAURI_ENV_PLATFORM === "windows" ? "chrome105" : "safari13",
|
target: process.env.TAURI_ENV_PLATFORM === "windows" ? "chrome109" : "safari16",
|
||||||
minify: !process.env.TAURI_ENV_DEBUG ? "esbuild" : false,
|
minify: !process.env.TAURI_ENV_DEBUG ? "esbuild" : false,
|
||||||
sourcemap: !!process.env.TAURI_ENV_DEBUG,
|
sourcemap: !!process.env.TAURI_ENV_DEBUG,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
@@ -32,18 +32,26 @@ export default defineConfig({
|
|||||||
// Vendor chunks isolate dependencies that change rarely from app code,
|
// Vendor chunks isolate dependencies that change rarely from app code,
|
||||||
// so a normal app update doesn't invalidate the cached vendor bundles
|
// so a normal app update doesn't invalidate the cached vendor bundles
|
||||||
// (helps especially with the Tauri updater pulling deltas).
|
// (helps especially with the Tauri updater pulling deltas).
|
||||||
manualChunks: {
|
manualChunks(id) {
|
||||||
react: ["react", "react-dom", "react-router-dom"],
|
if (!id.includes("node_modules")) return undefined;
|
||||||
tauri: [
|
if (id.includes("/react/") || id.includes("/react-dom/") || id.includes("/react-router-dom/")) {
|
||||||
"@tauri-apps/api",
|
return "react";
|
||||||
"@tauri-apps/plugin-shell",
|
}
|
||||||
"@tauri-apps/plugin-dialog",
|
if (
|
||||||
"@tauri-apps/plugin-fs",
|
id.includes("/@tauri-apps/api/") ||
|
||||||
"@tauri-apps/plugin-process",
|
id.includes("/@tauri-apps/plugin-shell/") ||
|
||||||
"@tauri-apps/plugin-store",
|
id.includes("/@tauri-apps/plugin-dialog/") ||
|
||||||
"@tauri-apps/plugin-updater",
|
id.includes("/@tauri-apps/plugin-fs/") ||
|
||||||
],
|
id.includes("/@tauri-apps/plugin-process/") ||
|
||||||
i18n: ["i18next", "react-i18next"],
|
id.includes("/@tauri-apps/plugin-store/") ||
|
||||||
|
id.includes("/@tauri-apps/plugin-updater/")
|
||||||
|
) {
|
||||||
|
return "tauri";
|
||||||
|
}
|
||||||
|
if (id.includes("/i18next/") || id.includes("/react-i18next/")) {
|
||||||
|
return "i18n";
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user