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:
cucadmuh
2026-05-05 22:00:29 +03:00
committed by GitHub
parent 54e774ef24
commit 8d8c1aa8a3
29 changed files with 2381 additions and 2511 deletions
+43
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
{ {
"npmDepsHash": "sha256-OOQmA7IQhD9WztDek4IA/2/v33zt9MHSHSbUCOaqbrY=" "npmDepsHash": "sha256-3zwfk7gxSYXoiOwNyPHP+K9P7yAksGKXQvwtvic/IdQ="
} }
+812 -1158
View File
File diff suppressed because it is too large Load Diff
+21 -20
View File
@@ -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"
} }
} }
+643 -899
View File
File diff suppressed because it is too large Load Diff
+14 -22
View File
@@ -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/tauris WebKitGTK stack — used only to turn off kinetic wheel scrolling. # Match wry/tauris 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" }
+9
View File
@@ -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
View File
@@ -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())?;
+48 -31
View File
@@ -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,
}) })
+4 -1
View File
@@ -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()
}) })
} }
+11 -5
View File
@@ -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;
} }
+39 -35
View File
@@ -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();
} }
+140 -2
View File
@@ -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 {
+4 -6
View File
@@ -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);
+38 -38
View File
@@ -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)
+40 -18
View File
@@ -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");
} }
+1 -8
View File
@@ -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);
+4 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+6 -1
View File
@@ -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
View File
@@ -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>
-12
View File
@@ -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[] = [];
+28 -3
View File
@@ -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;
} }
+1
View File
@@ -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)',
], ],
}, },
{ {
+7 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}, },
}, },
}, },