Compare commits

...

23 Commits

Author SHA1 Message Date
cucadmuh 8cdb770250 docs(changelog): attribute 1.48.1 entries, fix spacing; refresh WHATS_NEW
CHANGELOG [1.48.1]: add missing blank line between the Opus seek-crash entry
and the next heading, and add "By @Psychotoxical" attribution (no PR number)
to the six entries that were committed directly to the hotfix branch (playback
freeze, macOS headphone restart, Discord cover art, minimize-to-tray, and the
two Windows media-controls fixes from issue #1102).

WHATS_NEW [1.48.1]: rewrite the section to reflect the full user-visible 1.48.1
changelog per the What's New rules — themed Fixed groups (Playback and audio;
Offline, Now Playing, and Navidrome; Themes and integrations; Other), ordered
by user impact, without author/PR/issue references.
2026-06-17 01:19:00 +03:00
cucadmuh 817e3c4685 chore(release): set hotfix line version to 1.48.1-dev
The fix/1.48.1 hotfix branch inherited 1.48.0 from release. No promotion
workflow bumps the patch digit, so set the base to 1.48.1 here (synced across
package.json, package-lock.json, Cargo.toml, Cargo.lock workspace crates, and
tauri.conf.json via scripts/sync-tauri-version-from-package.js) so the next
"Promote main to next" run (source_branch=fix/1.48.1) cuts 1.48.1-rc.N rather
than recomputing an already-shipped 1.48.0 tag.
2026-06-17 01:00:59 +03:00
Psychotoxical 76d028127d fix(windows): transcode WebP cover to PNG for the media controls (#1102)
Windows SMTC could not render our cached WebP album covers: souvlaki loads the
file and SetThumbnail/set_metadata succeed, but the lock screen and Quick
Settings media tile showed a blank cover, because the OS thumbnail decoder does
not handle WebP even with the Store WebP extension installed.

Transcode local file:// WebP covers to PNG (libwebp decode then image PNG
encode, into a single reusable temp file) before handing them to the OS media
controls, gated to Windows. macOS (ImageIO) and Linux pass through unchanged.
2026-06-16 23:51:19 +02:00
Psychotoxical 4fd85f2dd4 fix(windows): show app name in media controls via AppUserModelID (#1102)
The Windows system media controls (Quick Settings media tile, lock screen,
third-party media flyouts) labelled playback as "Unknown application" with no
icon, because souvlaki creates the SMTC via GetForWindow and Windows resolves
the source name from the process AppUserModelID, which was never set.

Call SetCurrentProcessExplicitAppUserModelID early in run() so the process has
an explicit identity that matches the installer shortcut's AppUserModelID;
Windows then resolves the name and icon to Psysonic.
2026-06-16 23:44:11 +02:00
cucadmuh bca0acbaff fix(library): use partial indexes for §6.9 remap lookup (#1105)
* fix(library): use partial indexes for §6.9 remap lookup

The delta-sync remap detection ran a single lookup with an OR across
content_hash and server_path. SQLite could not use the partial
idx_track_remap_hash / idx_track_remap_path indexes for it: a partial
index is only applied when the query's WHERE provably implies the index
predicate (… != ''), and an OR spanning two columns blocks the per-branch
index plan. The query degraded to a full track scan on every incoming
row → O(rows × catalog), causing multi-minute stalls on large libraries
(observed upsert_batch_remap exec_ms=162001 on a ~200k-track Navidrome
sync, which in turn blocked all other writers on the single write mutex).

Split it into two single-column lookups that each repeat the index
predicate so the planner picks the matching partial index (SEARCH, not
SCAN); hash is checked first, matching §6.9 strong-key priority. Adds an
EXPLAIN QUERY PLAN regression test asserting index usage.

* docs(changelog): note remap-lookup sync stall fix (PR #1105)
2026-06-16 23:41:46 +03:00
Psychotoxical d3d53f1a25 docs(changelog): add 1.48.1 entry for macOS minimize-to-tray close fix 2026-06-16 21:01:49 +02:00
Psychotoxical acd6f12aba fix(window): honour minimize-to-tray on the macOS close button
The macOS red close button always emitted app:force-quit, which exits
unconditionally and never checked the minimizeToTray setting — so on macOS
the window closed the app even with "Minimize to Tray" enabled. Route the
main-window close through window:close-requested on all platforms, so JS
decides hide-vs-exit from the setting (default off = unchanged quit). The
tray "Exit" item still force-quits.

Fixes #1103
2026-06-16 21:00:18 +02:00
Psychotoxical 8f93f30e6f fix(discord): resolve cover profile from the store, not getPlaybackServerId
Follow-up to the dual-address cover fix: a playback/active cover scope was
routed through getPlaybackServerId(), which returns a `string` that can be
empty or an index-key (not a profile id) for locally-cached tracks. The
`?? activeServerId` fallback never fired (empty string isn't nullish), so no
profile matched and the URL came back null — which the presence layer caches
per cover id, producing intermittent "cover shows, then doesn't".

A playback/active scope always means the active server (a cross-server track
gets an explicit `server` scope), so resolve the active profile directly.
2026-06-16 16:45:38 +02:00
Psychotoxical 6f55b8464c docs(changelog): add 1.48.1 entry for Discord cover art on dual-address profiles 2026-06-16 16:26:56 +02:00
Psychotoxical 5f15784b7d fix(discord): use the public server address for Rich Presence cover art
The Discord cover URL was built via the connect endpoint, which prefers the
LAN address — but Discord fetches the image from its own servers, so a LAN
address is unreachable and the cover falls back to the app icon. This is a
dual-address regression: before a second (public) address could be added,
the only configured URL was the public one.

Build the Discord large-image URL via serverShareBaseUrl (public preferred,
like share links / Orbit invites) instead of the connect URL. Adds a test.
2026-06-16 16:10:23 +02:00
cucadmuh 8bfde08199 fix(audio): fix Opus/Ogg seek crash (symphonia do_seek panic) (#1100)
* fix(audio): fix Opus/Ogg seek crash by keeping random-access sources seekable through probe

Scrubbing the seekbar on Opus/Ogg files (then pressing Stop) crashed the whole
app. symphonia 0.6's Ogg demuxer records the physical stream's byte range only
when the source is seekable during the probe, but ProbeSeekGate hid seekability
there — so phys_byte_range_end stayed None and the first seek hit
Option::unwrap() on None on the cpal audio thread. That poisoned the engine
mutexes and aborted the process at the non-unwinding cpal FFI boundary (the
"crash on Stop" was a downstream symptom).

- Keep Ogg/Opus seekable through the probe on random-access sources (local
  files, in-memory) so the demuxer computes its seek bounds and seeking works
  for real. Progressive ranged-HTTP keeps the gate to avoid forcing a full
  download before playback starts.
- Contain any demuxer unwind inside SizedDecoder::try_seek (catch_unwind) so a
  panic on the audio thread can no longer poison engine state — covers streamed
  Ogg (still gated) and any future demuxer panic.
- Thread a random_access flag through PlayInput::SeekableMedia and new_streaming.

* docs(changelog): add 1.48.1 entry for the Opus/Ogg seek crash fix

Also note the Opus seek crash fix in WHATS_NEW.md (1.48.1).
2026-06-16 16:26:37 +03:00
Psychotoxical 49f9f03e5e docs(changelog): broaden 1.48.1 device-change entry to cover pause and stop 2026-06-16 14:51:48 +02:00
Psychotoxical 9034882bf6 fix(audio): bump generation on paused device reopen to stop spurious audio:ended
Third defence for #1094: on a device change while paused/stopped,
reopen_output_stream stopped the old sink without bumping the engine
generation (the bump only happened on a successful internal resume). The
still-running progress task could then flip done_flag and emit a spurious
audio:ended, which the frontend turns into a restart. Bump the generation
before sink.stop() in the non-playing case so the progress task bails out;
the active-playback path keeps bumping inside try_resume_after_device_change.
2026-06-16 14:49:49 +02:00
Psychotoxical 588dd8c48d fix(audio): don't restart playback on device change when engine is paused
Defence-in-depth for #1094: the device-changed/-reset handlers restarted
playback based on the UI `isPlaying` flag alone, which can be stale or
desynced at the moment of a device change. Gate the restart on the
engine-paused flag as well (`isPlaying && !getIsAudioPaused()`), so a
paused engine never auto-restarts regardless of how `isPlaying` got set;
when paused it just resets for the cold path. Adds a regression test.
2026-06-16 14:43:23 +02:00
Psychotoxical 9b24957595 docs(changelog): add 1.48.1 entry for the paused-playback-on-device-change fix 2026-06-16 14:14:38 +02:00
Psychotoxical f04bfb3d35 fix(media-controls): honour explicit Play/Pause from OS media keys
Toggle, Play and Pause from the OS media-control bridge (souvlaki) were
all collapsed onto the play-pause toggle event. On an audio-route change
(e.g. macOS sending an explicit Pause when headphones disconnect) this
turned the pause into a toggle, resuming paused playback on the new
output device.

Map Play and Pause to dedicated media:play / media:pause events (the
frontend handlers already exist); only a real toggle key emits
media:play-pause. Paused playback now stays paused across device changes.

Fixes #1094
2026-06-16 14:10:49 +02:00
Psychotoxical ff874a62f9 docs(changelog): correct 1.48.1 entry — freeze is not queue-size dependent 2026-06-15 22:41:35 +02:00
Psychotoxical a9f650c0fc docs(changelog): add 1.48.1 entry for the large-queue track-change freeze 2026-06-15 22:32:17 +02:00
Psychotoxical 7e91a5b2a1 fix(queue): stop re-walking the whole queue on every track change
QueueHeader aggregated total/future duration in a useMemo keyed on queueIndex,
so every skip ran a synchronous O(n) pass over the entire queue (resolveQueueTrack
per item). On very large queues this blocked the main thread for seconds — the
UI froze on skip and on the device-switch playTrack fallback (#1072; the freeze
half of #1090). QueuePanel is always mounted, so it hit even with the queue
collapsed.

Build a cumulative-duration prefix keyed on queue/resolver-version only; the
future-tracks total is now an O(1) lookup per skip. Display output unchanged.
2026-06-15 22:11:56 +02:00
github-actions[bot] de1924d5e3 chore(nix): refresh lock + npmDepsHash for v1.48.0 2026-06-14 20:19:27 +00:00
github-actions[bot] 85e91f5375 chore(release): finalize release version 1.48.0 2026-06-14 20:01:50 +00:00
github-actions[bot] 20cbdd123d chore(nix): refresh lock + npmDepsHash for v1.48.0-rc.4 2026-06-14 19:49:07 +00:00
github-actions[bot] 098788b56f chore(release): bump next channel to 1.48.0-rc.4 2026-06-14 19:32:01 +00:00
25 changed files with 553 additions and 102 deletions
+57
View File
@@ -9,6 +9,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
>
## [1.48.1] - 2026-06-15
## Fixed
### Playback freeze on track changes
**By [@Psychotoxical](https://github.com/Psychotoxical)**
* Changing tracks — skipping, or the automatic advance at the end of a song — could freeze the interface for several seconds while audio kept playing (the progress bar and lyrics stopped updating). The queue header recomputed its duration totals on every track change instead of only when the queue itself changes; it now recomputes only on queue changes, so track changes stay instant.
* This also resolves output-device changes not being applied on Windows: the same freeze was blocking playback from following the newly selected device.
### Paused or stopped playback restarting on headphone disconnect (macOS)
**By [@Psychotoxical](https://github.com/Psychotoxical)**
* On macOS, pausing or stopping playback and then disconnecting headphones (or otherwise switching the audio output device) could make playback restart on the newly selected device. Playback now reliably stays paused or stopped across a device change.
### Crash when seeking Opus/Ogg files
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1100](https://github.com/Psychotoxical/psysonic/pull/1100)**
* Scrubbing the seekbar on an Opus/Ogg file — and then pressing Stop — crashed the whole app (a 1.48 regression from the Symphonia 0.6 migration). The Ogg demuxer recorded its seek bounds only when the source was seekable during the format probe, but probing hid seekability, so the first seek panicked on the audio thread (`Option::unwrap()` on `None`) and took the process down at the audio backend boundary.
* Local and in-memory Opus/Ogg sources now stay seekable through the probe, so seeking works correctly. As a safety net, any decoder panic during a seek is contained instead of crashing the app; for Opus/Ogg streamed over HTTP, seeking is a no-op for now rather than a crash.
### Discord Rich Presence cover art missing with two server addresses
**By [@Psychotoxical](https://github.com/Psychotoxical)**
* When a server profile had both a local and a public address, Discord Rich Presence showed the placeholder icon instead of the album cover. The cover URL used the local address, which Discord's servers can't reach; it now uses the public address (the same one used for share links).
### "Minimize to Tray" ignored on the macOS close button
**By [@Psychotoxical](https://github.com/Psychotoxical)**
* On macOS, closing the window with the red close button always quit the app, even with "Minimize to Tray" enabled. The close button now respects the setting — with it on, the window hides to the tray instead of quitting, the same as the tray icon's "Hide".
### Library sync stalling for many seconds on large Navidrome collections
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1105](https://github.com/Psychotoxical/psysonic/pull/1105)**
* On large libraries (reported with ~200,000 tracks on Navidrome), background library sync could lock up database writes for minutes at a time — playback history, ratings and other saves piled up waiting behind it.
* Root cause: the track id-remap step ran a database lookup that couldn't use its indexes and scanned the entire track table once per incoming track, so the cost grew with the square of the library size. The lookup now uses the proper indexes, bringing it back to a fast, near-instant operation.
### Album cover missing in Windows media controls
**By [@Psychotoxical](https://github.com/Psychotoxical)**
* On Windows, the system media controls (the Quick Settings media tile, the lock screen and third-party media flyouts) showed the track title and artist but no album cover. Windows could not decode the cached WebP cover art for its thumbnail, even with the Store WebP extension installed. The cover is now converted to PNG before it is handed to the media controls, so the artwork shows again. macOS and Linux are unaffected.
### Windows media controls showed "Unknown application"
**By [@Psychotoxical](https://github.com/Psychotoxical)**
* On Windows, the system media controls (the Quick Settings media tile, the lock screen and third-party media flyouts) labelled playback as "Unknown application" with no icon. The app now registers an explicit application identity at startup so Windows shows "Psysonic" and its icon as the playback source.
## [1.48.0]
## Added
+23
View File
@@ -7,6 +7,29 @@ current line before promoting to `next` / `release`. Technical details and PR cr
Within each section, order by **user impact** (most noticeable first) — not PR merge order.
`CHANGELOG.md` keeps strict PR order inside Added / Changed / Fixed.
## [1.48.1]
## Fixed
### Playback and audio
- Changing tracks — skipping, or the automatic advance at the end of a song — no longer freezes the interface for a few seconds: the progress bar and lyrics keep updating, and on **Windows** a change of output device now takes effect right away.
- Seeking an **Opus/Ogg** track — and then pressing **Stop** — no longer crashes the app.
- **macOS:** pausing or stopping playback and then unplugging headphones (or switching the output device) no longer makes playback restart — it stays paused or stopped.
### Offline, Now Playing, and Navidrome
- On large **Navidrome** libraries, background library sync no longer locks up database writes for minutes at a time, so play history, ratings, and other saves go through without long delays.
### Themes and integrations
- **Discord** Rich Presence shows the album cover again when a server profile has both a local and a public address.
### Other
- **Windows:** the system media controls (Quick Settings media tile, lock screen, and third-party flyouts) now show the album cover and display **Psysonic** with its icon instead of "Unknown application".
- **macOS:** closing the window with the red close button now respects **Minimize to Tray** — with it on, the window hides to the tray instead of quitting.
## [1.48.0]
## Highlights
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1779560665,
"narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
"lastModified": 1781074563,
"narHash": "sha256-md8WlXOlfnIeHeOScMTTHFyf2d6iaTwPl2apR5EQ3P4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
"rev": "9ae611a455b90cf061d8f332b977e387bda8e1ca",
"type": "github"
},
"original": {
+1 -1
View File
@@ -1,3 +1,3 @@
{
"npmDepsHash": "sha256-nwCrZ0enhyLNkS15sgSR9CuQX9cif4fLDNAL2yUPG8s="
"npmDepsHash": "sha256-ndXqYgws77qokAXznbQ6BXhXUo3VIaiF1AVs5jCkNCo="
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "psysonic",
"version": "1.48.0-dev",
"version": "1.48.1-dev",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "psysonic",
"version": "1.48.0-dev",
"version": "1.48.1-dev",
"dependencies": {
"@fontsource-variable/dm-sans": "^5.2.8",
"@fontsource-variable/figtree": "^5.2.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.48.0-dev",
"version": "1.48.1-dev",
"private": true,
"scripts": {
"check:css-imports": "node scripts/check-css-import-graph.mjs",
+7 -7
View File
@@ -4114,7 +4114,7 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.48.0-dev"
version = "1.48.1-dev"
dependencies = [
"biquad",
"dasp_sample",
@@ -4167,7 +4167,7 @@ dependencies = [
[[package]]
name = "psysonic-analysis"
version = "1.48.0-dev"
version = "1.48.1-dev"
dependencies = [
"ebur128",
"futures-util",
@@ -4186,7 +4186,7 @@ dependencies = [
[[package]]
name = "psysonic-audio"
version = "1.48.0-dev"
version = "1.48.1-dev"
dependencies = [
"biquad",
"dasp_sample",
@@ -4216,7 +4216,7 @@ dependencies = [
[[package]]
name = "psysonic-core"
version = "1.48.0-dev"
version = "1.48.1-dev"
dependencies = [
"libc",
"serde",
@@ -4225,7 +4225,7 @@ dependencies = [
[[package]]
name = "psysonic-integration"
version = "1.48.0-dev"
version = "1.48.1-dev"
dependencies = [
"discord-rich-presence",
"futures-util",
@@ -4242,7 +4242,7 @@ dependencies = [
[[package]]
name = "psysonic-library"
version = "1.48.0-dev"
version = "1.48.1-dev"
dependencies = [
"psysonic-core",
"psysonic-integration",
@@ -4257,7 +4257,7 @@ dependencies = [
[[package]]
name = "psysonic-syncfs"
version = "1.48.0-dev"
version = "1.48.1-dev"
dependencies = [
"futures-util",
"id3",
+1 -1
View File
@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "1.48.0-dev"
version = "1.48.1-dev"
edition = "2021"
rust-version = "1.95"
license = "GPL-3.0-or-later"
+57 -19
View File
@@ -169,8 +169,14 @@ impl SizedDecoder {
// Symphonia 0.6 scans trailing metadata on seekable sources — hide
// seekability during probe (same as `new_streaming`) so preview does not
// read the entire in-memory file before the first sample.
let probe_seek_gate = (!crate::stream::container_hint_is_mp4(format_hint))
.then(|| Arc::new(AtomicBool::new(false)));
//
// Exception: Ogg (Vorbis/Opus/…) must stay seekable through the probe,
// otherwise its demuxer never records `phys_byte_range_end` and the first
// seek panics (see `container_hint_is_ogg`). This source is fully
// in-memory, so the trailing-metadata scan it re-enables is free.
let gate_needed = !crate::stream::container_hint_is_mp4(format_hint)
&& !crate::stream::container_hint_is_ogg(format_hint);
let probe_seek_gate = gate_needed.then(|| Arc::new(AtomicBool::new(false)));
let media: Box<dyn MediaSource> = match &probe_seek_gate {
Some(gate) => Box::new(ProbeSeekGate {
inner: Box::new(source),
@@ -315,19 +321,33 @@ impl SizedDecoder {
/// Build a decoder from any `MediaSource` (e.g. track-stream or radio).
/// Uses `enable_gapless: false` — live streams are not seekable; gapless
/// trimming requires seeking to read the LAME/iTunSMPB end-padding info.
/// `source_random_access`: the underlying source can cheaply seek to EOF
/// (e.g. a local file), so the probe-time trailing-metadata / stream-end scan
/// is not a full download. Progressive sources (ranged HTTP) pass `false`.
pub(crate) fn new_streaming(
media: Box<dyn MediaSource>,
format_hint: Option<&str>,
source_tag: &str,
source_random_access: bool,
) -> Result<Self, String> {
// For non-MP4 progressive streams, hide seekability during the probe so
// Symphonia 0.6 skips its trailing-metadata scan (which would seek to EOF
// and block until the whole file is downloaded). Re-enabled right after.
// MP4 keeps seekability (its demuxer needs it to find `moov`; tail is
// prefetched separately).
//
// Ogg also keeps seekability through the probe, but only on random-access
// sources: its demuxer records `phys_byte_range_end` during the probe and
// panics on the first seek otherwise (see `container_hint_is_ogg`). On a
// local file the stream-end scan is cheap; on a progressive ranged stream
// it would force a full download, so there we keep the gate and accept
// that seeking is a no-op (the panic itself is contained in `try_seek`).
let stream_len = media.byte_len();
let probe_seek_gate = (!crate::stream::container_hint_is_mp4(format_hint))
.then(|| Arc::new(AtomicBool::new(false)));
let ogg_needs_seekable_probe =
source_random_access && crate::stream::container_hint_is_ogg(format_hint);
let gate_needed = !crate::stream::container_hint_is_mp4(format_hint)
&& !ogg_needs_seekable_probe;
let probe_seek_gate = gate_needed.then(|| Arc::new(AtomicBool::new(false)));
let media: Box<dyn MediaSource> = match &probe_seek_gate {
Some(gate) => Box::new(ProbeSeekGate { inner: media, seekable: gate.clone() }),
None => media,
@@ -588,20 +608,36 @@ impl Source for SizedDecoder {
let to_skip = self.current_frame_offset % self.channels().get() as usize;
let seek_res = self
.format
.seek(SeekMode::Accurate, SeekTo::Time { time, track_id: None })
.map_err(|e| rodio::source::SeekError::Other(
std::sync::Arc::new(std::io::Error::other(e.to_string()))
))?;
// symphonia 0.6's OGG demuxer can `panic!` (e.g. `Option::unwrap()` on
// `None` in `OggReader::do_seek`) on some streams instead of returning
// an `Err`. `try_seek` runs on rodio's cpal output thread, so an escaping
// panic poisons the engine mutexes and then aborts the whole process at
// the non-unwinding cpal FFI boundary (the "crash on Stop" is a downstream
// symptom of that poison). Contain the unwind here — including the packet
// reads in `refine_position`, which can hit the same broken demuxer state —
// and surface it as a recoverable `SeekError` so the engine stays alive
// (the seek becomes a no-op rather than killing playback).
let seek_outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let seek_res = self
.format
.seek(SeekMode::Accurate, SeekTo::Time { time, track_id: None })
.map_err(|e| e.to_string())?;
self.refine_position(seek_res)?;
Ok::<(), String>(())
}));
self.refine_position(seek_res)
.map_err(|e| rodio::source::SeekError::Other(
std::sync::Arc::new(std::io::Error::other(e))
))?;
self.current_frame_offset += to_skip;
Ok(())
match seek_outcome {
Ok(Ok(())) => {
self.current_frame_offset += to_skip;
Ok(())
}
Ok(Err(e)) => Err(rodio::source::SeekError::Other(std::sync::Arc::new(
std::io::Error::other(e),
))),
Err(_panic) => Err(rodio::source::SeekError::Other(std::sync::Arc::new(
std::io::Error::other("seek panicked inside the demuxer (contained)"),
))),
}
}
}
@@ -1019,8 +1055,9 @@ mod tests {
#[test]
fn new_streaming_constructs_from_synthetic_wav() {
let wav = synthetic_wav_bytes(0.5);
let decoder = SizedDecoder::new_streaming(seekable_source(wav), Some("wav"), "test-stream")
.expect("streaming WAV decode setup");
let decoder =
SizedDecoder::new_streaming(seekable_source(wav), Some("wav"), "test-stream", true)
.expect("streaming WAV decode setup");
assert_eq!(decoder.spec.rate(), 44_100);
assert_eq!(decoder.spec.channels().count(), 1);
// Live streams report no total duration.
@@ -1033,6 +1070,7 @@ mod tests {
seekable_source(vec![0x00u8; 64]),
None,
"test-stream",
true,
);
assert!(result.is_err());
}
@@ -87,6 +87,7 @@ pub(crate) async fn try_resume_after_device_change(
reader: Box::new(LocalFileSource { file, len }),
format_hint: url_format_hint(url),
tag: "LocalFile[device-resume]",
random_access: true,
mp4_probe_gate: None,
}
}
@@ -82,6 +82,15 @@ pub(crate) async fn reopen_output_stream(
if !opened {
return false;
}
// When we're not actively playing (paused/stopped), bump the generation
// before stopping the old sink so the still-running progress task sees the
// mismatch and bails out instead of emitting a spurious `audio:ended` —
// which would otherwise trigger a frontend restart of paused playback
// (#1094). The active-playback path bumps inside
// `try_resume_after_device_change`, so only guard the non-playing case here.
if !snapshot.is_playing {
engine.generation.fetch_add(1, Ordering::SeqCst);
}
if let Some(s) = current.lock().unwrap().sink.take() {
s.stop();
}
@@ -40,6 +40,9 @@ pub(crate) enum PlayInput {
reader: Box<dyn MediaSource>,
format_hint: Option<String>,
tag: &'static str,
/// Source can cheaply seek to EOF (local file). Drives whether Ogg keeps
/// seekability through the probe so its seek path does not panic.
random_access: bool,
/// When set, Symphonia probe waits for moov (tail or fast-start prefix).
mp4_probe_gate: Option<super::stream::RangedMp4ProbeGate>,
},
@@ -201,6 +204,7 @@ fn open_local_file_input(
reader: Box::new(reader),
format_hint: local_hint,
tag: "local-file",
random_access: true,
mp4_probe_gate: None,
})
}
@@ -345,6 +349,7 @@ async fn open_ranged_or_streaming_input(
reader: Box::new(reader),
format_hint: stream_hint,
tag: "ranged-stream",
random_access: false,
mp4_probe_gate,
}));
}
@@ -282,7 +282,7 @@ async fn open_preview_decoder(
};
let hint = stream_hint.clone();
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new_streaming(Box::new(reader), hint.as_deref(), "preview-stream")
SizedDecoder::new_streaming(Box::new(reader), hint.as_deref(), "preview-stream", false)
})
.await
.map_err(|e| format!("preview: decoder thread: {e}"))??;
@@ -124,7 +124,7 @@ pub async fn audio_play_radio(
let hint_clone = fmt_hint.clone();
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new_streaming(Box::new(reader), hint_clone.as_deref(), "radio")
SizedDecoder::new_streaming(Box::new(reader), hint_clone.as_deref(), "radio", false)
})
.await
.map_err(|e| e.to_string())??;
@@ -345,6 +345,7 @@ async fn build_source_from_play_input(
reader,
format_hint: media_hint,
tag,
random_access,
mp4_probe_gate,
} => {
if let Some(gate) = mp4_probe_gate.as_ref() {
@@ -354,7 +355,7 @@ async fn build_source_from_play_input(
}
}
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new_streaming(reader, media_hint.as_deref(), tag)
SizedDecoder::new_streaming(reader, media_hint.as_deref(), tag, random_access)
})
.await
.map_err(|e| e.to_string())??;
@@ -375,7 +376,12 @@ async fn build_source_from_play_input(
PlayInput::Streaming { reader, format_hint: stream_hint } => {
is_seekable = false;
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new_streaming(Box::new(reader), stream_hint.as_deref(), "track-stream")
SizedDecoder::new_streaming(
Box::new(reader),
stream_hint.as_deref(),
"track-stream",
false,
)
})
.await
.map_err(|e| e.to_string())??;
@@ -21,6 +21,23 @@ pub(crate) use mp4::{
container_hint_is_mp4, isobmff_buffer_looks_complete, log_isobmff_buffer_diagnostic,
mp4_needs_tail_prefetch, mp4_suspect_zero_holes,
};
/// True when the container hint denotes an Ogg-encapsulated stream (Vorbis,
/// Opus, Speex, FLAC-in-Ogg).
///
/// symphonia 0.6's Ogg demuxer records the physical stream's byte range at
/// construction time, but only when the source reports `is_seekable()` *during
/// the probe*. If seekability is hidden then (see `ProbeSeekGate`),
/// `phys_byte_range_end` stays `None` and the first real seek panics with
/// `Option::unwrap()` on `None` (`demuxer.rs:180`). Sources that can cheaply
/// seek to EOF must therefore stay seekable through the probe for Ogg.
pub(crate) fn container_hint_is_ogg(hint: Option<&str>) -> bool {
let Some(h) = hint else { return false };
matches!(
h.to_ascii_lowercase().as_str(),
"ogg" | "oga" | "ogx" | "opus" | "spx"
)
}
pub(crate) use local_file::LocalFileSource;
pub(crate) use radio::{RadioLiveState, RadioSharedFlags, radio_download_task};
pub(crate) use ranged_http::{RangedHttpSource, ranged_download_task};
@@ -448,7 +448,10 @@ impl<'a> TrackRepository<'a> {
let mut remapped: Vec<RemapEntry> = Vec::new();
let mut upsert = tx.prepare_cached(UPSERT_SQL)?;
let mut remap_lookup = if unstable_track_ids {
Some(tx.prepare_cached(REMAP_LOOKUP_SQL)?)
Some((
tx.prepare_cached(REMAP_LOOKUP_BY_HASH_SQL)?,
tx.prepare_cached(REMAP_LOOKUP_BY_PATH_SQL)?,
))
} else {
None
};
@@ -459,11 +462,12 @@ impl<'a> TrackRepository<'a> {
// then do we retarget children to the new id, since
// child tables FK→track(server_id, id) and would refuse
// an UPDATE pointing at an id that doesn't exist yet.
let detected_old: Option<String> = if let Some(ref mut lookup) = remap_lookup {
detect_remap_target_cached(lookup, r)?
} else {
None
};
let detected_old: Option<String> =
if let Some((ref mut by_hash, ref mut by_path)) = remap_lookup {
detect_remap_target_cached(by_hash, by_path, r)?
} else {
None
};
upsert.execute(params![
r.server_id,
@@ -543,38 +547,76 @@ impl<'a> TrackRepository<'a> {
}
}
const REMAP_LOOKUP_SQL: &str = r#"
// Two single-column lookups instead of one `OR` across `content_hash`
// and `server_path`. The combined `OR` form could not use the partial
// `idx_track_remap_hash` / `idx_track_remap_path` indexes — SQLite only
// applies a partial index when the query's WHERE provably implies the
// index predicate (`… != ''`), and an `OR` spanning two columns blocks
// the per-branch index plan. The result was a full `track` scan per
// incoming row → O(rows × catalog) on large libraries (observed:
// `upsert_batch_remap exec_ms=162001` on a ~200k-track Navidrome sync).
// Each statement below repeats the index predicate so the planner picks
// the matching partial index (SEARCH, not SCAN); hash wins over path,
// matching §6.9's strong-key priority.
const REMAP_LOOKUP_BY_HASH_SQL: &str = r#"
SELECT id FROM track
WHERE server_id = ?1
AND deleted = 0
AND id != ?2
AND (
(?3 IS NOT NULL AND content_hash = ?3)
OR (?4 IS NOT NULL AND server_path = ?4)
)
AND content_hash IS NOT NULL
AND content_hash != ''
AND content_hash = ?2
AND id != ?3
LIMIT 1
"#;
const REMAP_LOOKUP_BY_PATH_SQL: &str = r#"
SELECT id FROM track
WHERE server_id = ?1
AND deleted = 0
AND server_path IS NOT NULL
AND server_path != ''
AND server_path = ?2
AND id != ?3
LIMIT 1
"#;
/// Run the `SELECT old.id` half of §6.9 — returns `Some(old_id)` if a
/// non-deleted row with a different id on this server matches the
/// incoming row's `content_hash` or `server_path`.
/// incoming row's `content_hash` or `server_path`. Hash is the stronger
/// key, so it is checked first.
fn detect_remap_target_cached(
lookup: &mut rusqlite::Statement<'_>,
by_hash: &mut rusqlite::Statement<'_>,
by_path: &mut rusqlite::Statement<'_>,
incoming: &TrackRow,
) -> rusqlite::Result<Option<String>> {
// Empty-string sentinels are *not* eligible — spec §6.9 explicitly
// excludes them so the file-tree default never collides.
let hash = incoming.content_hash.as_deref().filter(|s| !s.is_empty());
let path = incoming.server_path.as_deref().filter(|s| !s.is_empty());
if hash.is_none() && path.is_none() {
return Ok(None);
if let Some(hash) = hash {
let old = by_hash
.query_row(params![incoming.server_id, hash, incoming.id], |row| {
row.get::<_, String>(0)
})
.optional()?;
if old.is_some() {
return Ok(old);
}
}
lookup
.query_row(
params![incoming.server_id, incoming.id, hash, path],
|row| row.get::<_, String>(0),
)
.optional()
if let Some(path) = path {
let old = by_path
.query_row(params![incoming.server_id, path, incoming.id], |row| {
row.get::<_, String>(0)
})
.optional()?;
if old.is_some() {
return Ok(old);
}
}
Ok(None)
}
/// Run the §6.9 retarget half — UPDATE every FK-bound child to the
@@ -1247,6 +1289,48 @@ mod tests {
assert_eq!(count, 2, "both rows kept; identity-less rows can't shadow");
}
#[test]
fn remap_lookup_uses_partial_indexes_not_full_scan() {
// Regression: the §6.9 remap lookup must hit
// idx_track_remap_hash / idx_track_remap_path. The prior
// `OR`-based query fell back to a full `track` scan on every
// incoming row → O(rows × catalog) stalls on large libraries
// (`upsert_batch_remap exec_ms=162001` on a ~200k Navidrome sync).
let store = LibraryStore::open_in_memory();
let plan = |sql: &str| -> String {
store
.with_conn("misc", |c| {
let mut stmt = c.prepare(&format!("EXPLAIN QUERY PLAN {sql}"))?;
let rows: rusqlite::Result<Vec<String>> = stmt
.query_map(params!["s1", "v", "id"], |r| r.get::<_, String>(3))?
.collect();
rows
})
.unwrap()
.join("\n")
};
let hash_plan = plan(REMAP_LOOKUP_BY_HASH_SQL);
assert!(
hash_plan.contains("idx_track_remap_hash"),
"hash lookup must use idx_track_remap_hash, got: {hash_plan}"
);
assert!(
!hash_plan.contains("SCAN"),
"hash lookup must not full-scan track, got: {hash_plan}"
);
let path_plan = plan(REMAP_LOOKUP_BY_PATH_SQL);
assert!(
path_plan.contains("idx_track_remap_path"),
"path lookup must use idx_track_remap_path, got: {path_plan}"
);
assert!(
!path_plan.contains("SCAN"),
"path lookup must not full-scan track, got: {path_plan}"
);
}
#[test]
fn remap_is_noop_when_new_id_matches_existing_id() {
// Standard delta-sync: same id, same hash. Must not trigger
+41 -20
View File
@@ -64,7 +64,28 @@ fn on_second_instance<R: tauri::Runtime>(
}
}
/// Windows: associate this process with an explicit AppUserModelID. Windows uses
/// it to name the app in taskbar grouping and the SMTC media controls; without it
/// the media tile reads "Unknown application". Must match the AppUserModelID the
/// installer sets on the Start-menu shortcut so the name/icon resolve.
#[cfg(target_os = "windows")]
fn set_app_user_model_id() {
use windows::core::w;
use windows::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
// SAFETY: a Win32 call with a static wide string; errors are non-fatal.
unsafe {
let _ = SetCurrentProcessExplicitAppUserModelID(w!("dev.psysonic.player"));
}
}
pub fn run() {
// Windows: bind this process to an explicit AppUserModelID before any window
// or the SMTC media controls are created, so the OS can resolve the app
// name/icon for taskbar grouping and the media tile (#1102 follow-up: the
// Quick-Settings / lock-screen media tile showed "Unknown application").
#[cfg(target_os = "windows")]
set_app_user_model_id();
// Linux: second `psysonic --player …` forwards over D-Bus before heavy startup.
#[cfg(target_os = "linux")]
{
@@ -502,11 +523,20 @@ pub fn run() {
let app_handle = app.handle().clone();
if let Err(e) = controls.attach(move |event: MediaControlEvent| {
match event {
MediaControlEvent::Toggle
| MediaControlEvent::Play
| MediaControlEvent::Pause => {
// Keep Play/Pause distinct from Toggle: the OS
// (notably macOS on audio-route changes, e.g. a
// headphone disconnect) sends an explicit Pause,
// and collapsing all three into a toggle would
// resume paused playback on the new device (#1094).
MediaControlEvent::Toggle => {
let _ = app_handle.emit("media:play-pause", ());
}
MediaControlEvent::Play => {
let _ = app_handle.emit("media:play", ());
}
MediaControlEvent::Pause => {
let _ = app_handle.emit("media:pause", ());
}
MediaControlEvent::Next => {
let _ = app_handle.emit("media:next", ());
}
@@ -599,24 +629,15 @@ pub fn run() {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
if window.label() == "main" {
api.prevent_close();
#[cfg(target_os = "macos")]
{
// On macOS the red close button quits the app entirely.
// Route through JS so playback position + Orbit state get
// flushed; exit_app on the way back stops the audio engine.
let _ = window.emit("app:force-quit", ());
}
#[cfg(not(target_os = "macos"))]
{
// Pause rendering before JS decides whether to hide to tray or exit.
if let Some(w) = window.app_handle().get_webview_window("main") {
let _ = w.eval(PAUSE_RENDERING_JS);
}
// Let JS decide: minimize to tray or exit, based on user setting.
let _ = window.emit("window:close-requested", ());
// All platforms: pause rendering, then let JS decide hide-to-tray
// vs exit based on the minimizeToTray setting. macOS previously
// always force-quit on the red close button, ignoring the setting
// (#1103). The tray "Exit" item still emits app:force-quit for an
// unconditional quit.
if let Some(w) = window.app_handle().get_webview_window("main") {
let _ = w.eval(PAUSE_RENDERING_JS);
}
let _ = window.emit("window:close-requested", ());
} else if window.label() == "mini" {
// Native close on the mini: hide instead of destroying so
// state is preserved, and restore the main window.
@@ -84,6 +84,15 @@ pub(crate) fn mpris_set_metadata(
let duration = duration_secs.map(Duration::from_secs_f64);
let mut guard = controls.lock().unwrap();
let Some(ctrl) = guard.as_mut() else { return Ok(()); };
// #1102: Windows SMTC cannot render our cached WebP covers. souvlaki loads
// the file and SetThumbnail/set_metadata succeed, but the lock screen and
// Quick-Settings media tile show a blank cover (the OS thumbnail decoder
// does not handle WebP, even with the Store WebP extension installed).
// Transcode local WebP covers to PNG for the OS media controls; macOS
// (ImageIO) decodes WebP fine, so other platforms pass through unchanged.
let cover_url = smtc_cover_url(cover_url);
ctrl.set_metadata(MediaMetadata {
title: title.as_deref(),
artist: artist.as_deref(),
@@ -94,6 +103,48 @@ pub(crate) fn mpris_set_metadata(
.map_err(|e| format!("MPRIS set_metadata failed: {e:?}"))
}
/// Rewrite a cached WebP cover URL to a PNG the OS media controls can render.
/// Windows SMTC cannot decode WebP thumbnails (#1102); other platforms and any
/// non-`file://`/non-WebP URL pass through unchanged.
fn smtc_cover_url(cover_url: Option<String>) -> Option<String> {
#[cfg(target_os = "windows")]
{
if let Some(url) = cover_url.as_deref() {
if let Some(path) = url.strip_prefix("file://") {
let is_webp = std::path::Path::new(path)
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("webp"));
if is_webp {
match webp_file_to_temp_png(path) {
Ok(png) => return Some(format!("file://{png}")),
Err(e) => {
crate::app_eprintln!("[mpris] cover WebP->PNG transcode failed: {e}")
}
}
}
}
}
}
cover_url
}
/// Decode a WebP file (libwebp, the same codec that wrote the cover cache) and
/// re-encode it as a PNG in the temp dir, returning the native path. A single
/// reusable file is fine: souvlaki reads it synchronously inside `set_metadata`,
/// and the controls mutex serializes calls so it is never written concurrently.
#[cfg(target_os = "windows")]
fn webp_file_to_temp_png(webp_path: &str) -> Result<String, String> {
let bytes = std::fs::read(webp_path).map_err(|e| e.to_string())?;
let decoded = webp::Decoder::new(&bytes)
.decode()
.ok_or_else(|| "WebP decode returned None".to_string())?;
let img = decoded.to_image();
let out = std::env::temp_dir().join("psysonic-smtc-cover.png");
img.save_with_format(&out, image::ImageFormat::Png)
.map_err(|e| e.to_string())?;
Ok(out.to_string_lossy().into_owned())
}
#[tauri::command]
pub(crate) fn mpris_set_playback(
controls: tauri::State<MprisControls>,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.48.0-dev",
"version": "1.48.1-dev",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",
+20 -12
View File
@@ -44,23 +44,31 @@ export function QueueHeader({
// H1 mitigation: a mass-resolve burst (queue restore, prefetch window slide)
// bumps `version` dozens of times in one frame; useDeferredValue coalesces
// the burst into a single low-priority commit so long queues do not block
// the main thread on every cache tick. The aggregation itself is a single
// pass — one loop produces both totals so a 50k-track queue costs one walk,
// not two.
// the main thread on every cache tick.
//
// The O(n) walk is keyed on `queue`/`deferredVersion` only — NOT `queueIndex`.
// A skip moves only `queueIndex`, so it must not re-walk the whole queue: that
// synchronous O(n) pass on every track change froze the UI for seconds on very
// large queues (#1072, and the device-switch fallback in #1090). Instead we
// build a cumulative-duration prefix (`cumSecs[i]` = summed duration of tracks
// [0, i)) once per queue/cache change, then derive the future total as an O(1)
// lookup below. A 50k-track queue costs one walk per queue/cache change, zero
// per track change.
const version = useSyncExternalStore(subscribeQueueResolver, getQueueResolverVersion);
const deferredVersion = useDeferredValue(version);
const { totalSecs, futureTracksDuration } = useMemo(() => {
if (queue.length === 0) return { totalSecs: 0, futureTracksDuration: 0 };
let total = 0;
let future = 0;
const { totalSecs, cumSecs } = useMemo(() => {
const cum = new Float64Array(queue.length + 1);
for (let i = 0; i < queue.length; i += 1) {
const dur = resolveQueueTrack(queue[i]).duration || 0;
total += dur;
if (i > queueIndex) future += dur;
cum[i + 1] = cum[i] + (resolveQueueTrack(queue[i]).duration || 0);
}
return { totalSecs: total, futureTracksDuration: future };
return { totalSecs: cum[queue.length], cumSecs: cum };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [queue, queueIndex, deferredVersion]);
}, [queue, deferredVersion]);
// Tracks strictly after the current index — O(1) per skip.
const futureTracksDuration = Math.max(
0,
totalSecs - cumSecs[Math.min(queueIndex + 1, queue.length)],
);
const currentDuration = queue[queueIndex] ? resolveQueueTrack(queue[queueIndex]).duration : 0;
const remainingSecs = Math.max(0, (currentDuration ?? 0) - currentTime + futureTracksDuration);
+69
View File
@@ -0,0 +1,69 @@
/**
* coverArtUrlForDiscord Discord fetches the large image from its own servers,
* so the URL must use the public address, not the LAN-preferred connect URL
* (regression from the dual-address feature).
*/
import { beforeEach, describe, expect, it } from 'vitest';
import { resetAllStores } from '@/test/helpers/storeReset';
import { makeServer } from '@/test/helpers/factories';
import { useAuthStore } from '@/store/authStore';
import { coverArtUrlForDiscord } from './discord';
import type { CoverArtRef } from '../types';
function refForServer(serverId: string, url: string): CoverArtRef {
return {
cacheKind: 'album',
cacheEntityId: 'al-1',
fetchCoverArtId: 'al-1',
serverScope: { kind: 'server', serverId, url, username: 'tester', password: 'pw' },
};
}
beforeEach(() => {
resetAllStores();
});
describe('coverArtUrlForDiscord', () => {
it('uses the public address on a dual-address profile, not the LAN one', async () => {
const server = makeServer({
url: 'http://192.168.1.50:4533',
alternateUrl: 'https://music.example.com',
});
useAuthStore.setState({ servers: [server], activeServerId: server.id } as never);
const url = await coverArtUrlForDiscord(refForServer(server.id, server.url));
expect(url).toContain('music.example.com');
expect(url).not.toContain('192.168.1.50');
});
it('returns the single configured address when there is no alternate', async () => {
const server = makeServer({ url: 'https://music.example.com', alternateUrl: undefined });
useAuthStore.setState({ servers: [server], activeServerId: server.id } as never);
const url = await coverArtUrlForDiscord(refForServer(server.id, server.url));
expect(url).toContain('music.example.com');
});
it('resolves a playback scope to the active profile (public address)', async () => {
// playback scope is the common case for locally-cached tracks; it must
// resolve to the active server, not yield a null cover.
const server = makeServer({
url: 'http://192.168.1.50:4533',
alternateUrl: 'https://music.example.com',
});
useAuthStore.setState({ servers: [server], activeServerId: server.id } as never);
const ref: CoverArtRef = {
cacheKind: 'album',
cacheEntityId: 'al-1',
fetchCoverArtId: 'al-1',
serverScope: { kind: 'playback' },
};
const url = await coverArtUrlForDiscord(ref);
expect(url).toContain('music.example.com');
expect(url).not.toContain('192.168.1.50');
});
});
+46 -7
View File
@@ -1,13 +1,52 @@
import { buildCoverArtFetchUrl } from '../fetchUrl';
import { buildCoverArtUrlForServer } from '../../api/subsonicStreamUrl';
import { serverShareBaseUrl } from '../../utils/server/serverEndpoint';
import { useAuthStore } from '../../store/authStore';
import type { CoverArtRef } from '../types';
/**
* Discord large image always the HTTPS fetch URL, never a local cache path.
* Discord Rich Presence images are fetched by Discord's own servers, so the
* large_image must be a key or an https:// URL they can reach. A `file://` path
* to the on-disk webp cache (what MPRIS uses) is meaningless to Discord and
* silently falls back to the app icon so we hand it the getCoverArt URL.
* Discord large image an https:// URL Discord's own servers can reach.
*
* Unlike every other cover fetch we must NOT use the connect URL: that prefers
* the LAN address (fast for the app itself), but Discord fetches the image
* remotely, so a `http://192.168.x.x` address is unreachable and falls back to
* the app icon. Discord is an external consumer just like a share link, so use
* `serverShareBaseUrl` (public address preferred when both are set).
*
* Resolve the profile straight from the store: a `playback`/`active` scope
* always means the active server (a cross-server track gets an explicit
* `server` scope), so we never route through `getPlaybackServerId()`, whose
* empty-string / index-key returns previously yielded a null cover URL on
* locally-cached tracks.
*/
export async function coverArtUrlForDiscord(ref: CoverArtRef): Promise<string | null> {
return buildCoverArtFetchUrl(ref, 800) || null;
const { serverScope, fetchCoverArtId } = ref;
const auth = useAuthStore.getState();
const profile =
serverScope.kind === 'server'
? auth.servers.find(s => s.id === serverScope.serverId)
: auth.servers.find(s => s.id === auth.activeServerId);
if (profile) {
return buildCoverArtUrlForServer(
serverShareBaseUrl(profile),
profile.username,
profile.password,
fetchCoverArtId,
800,
) || null;
}
// Server scope carries its own URL/creds even when not a saved profile.
if (serverScope.kind === 'server') {
return buildCoverArtUrlForServer(
serverShareBaseUrl({ url: serverScope.url }),
serverScope.username,
serverScope.password,
fetchCoverArtId,
800,
) || null;
}
return null;
}
@@ -18,6 +18,7 @@ import { makeTrack } from '@/test/helpers/factories';
import { usePlayerStore } from '@/store/playerStore';
import { useAuthStore } from '@/store/authStore';
import { getSeekFallbackVisualTarget, setSeekFallbackVisualTarget } from '@/store/seekFallbackState';
import { setIsAudioPaused } from '@/store/engineState';
import { useAudioDeviceBridge } from './useAudioDeviceBridge';
const track = makeTrack({ id: 't1', duration: 300 });
@@ -29,6 +30,8 @@ function mountBridge() {
beforeEach(() => {
resetAllStores();
setSeekFallbackVisualTarget(null);
// Module-level engine flag isn't covered by resetAllStores — reset explicitly.
setIsAudioPaused(false);
// Default: a track is playing.
usePlayerStore.setState({ currentTrack: track, isPlaying: true });
});
@@ -92,6 +95,19 @@ describe('audio:device-changed', () => {
expect(playTrack).not.toHaveBeenCalled();
});
it('does not restart when the engine is paused even if isPlaying is stale-true (#1094)', () => {
const playTrack = vi.fn();
const resetAudioPause = vi.fn();
usePlayerStore.setState({ playTrack, resetAudioPause, isPlaying: true } as never);
setIsAudioPaused(true);
mountBridge();
emitTauriEvent('audio:device-changed', 45.0);
expect(playTrack).not.toHaveBeenCalled();
expect(resetAudioPause).toHaveBeenCalled();
});
});
// ─── audio:device-reset ─────────────────────────────────────────────────────
@@ -3,6 +3,7 @@ import { listen } from '@tauri-apps/api/event';
import { usePlayerStore } from '../../store/playerStore';
import { useAuthStore } from '../../store/authStore';
import { setSeekFallbackVisualTarget } from '../../store/seekFallbackState';
import { getIsAudioPaused } from '../../store/engineState';
/** Audio output device lifecycle: device switches (Bluetooth headphones, USB
* DAC, ) and pinned-device-unplugged fallbacks emitted by the Rust
@@ -23,7 +24,10 @@ export function useAudioDeviceBridge() {
const resumeAt = typeof event.payload === 'number' ? event.payload : 0;
const { currentTrack, isPlaying, playTrack, resetAudioPause } = usePlayerStore.getState();
if (!currentTrack) return;
if (isPlaying) {
// Only restart playback when transport is *provably* active. `isPlaying`
// alone can be stale/desynced on a device change (#1094); the engine-paused
// flag is the source of truth — if paused, just reset for the cold path.
if (isPlaying && !getIsAudioPaused()) {
if (resumeAt > 0.5 && currentTrack.duration > 0) {
setSeekFallbackVisualTarget({
trackId: currentTrack.id,
@@ -55,7 +59,10 @@ export function useAudioDeviceBridge() {
const resumeAt = typeof event.payload === 'number' ? event.payload : 0;
const { currentTrack, isPlaying, playTrack, resetAudioPause } = usePlayerStore.getState();
if (!currentTrack) return;
if (isPlaying) {
// Only restart playback when transport is *provably* active. `isPlaying`
// alone can be stale/desynced on a device change (#1094); the engine-paused
// flag is the source of truth — if paused, just reset for the cold path.
if (isPlaying && !getIsAudioPaused()) {
if (resumeAt > 0.5 && currentTrack.duration > 0) {
setSeekFallbackVisualTarget({
trackId: currentTrack.id,