Compare commits

...

19 Commits

Author SHA1 Message Date
Psychotoxical 3130e0ebc7 diag(#1102): log mpris cover_url + set_metadata result 2026-06-16 21:46:30 +02: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
24 changed files with 340 additions and 83 deletions
+29
View File
@@ -9,6 +9,35 @@ 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
* 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)
* 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
* 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
* 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".
## [1.48.0]
## Added
+6
View File
@@ -7,6 +7,12 @@ 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
- Fixed an issue with Opus playback where seeking the seekbar could crash the app.
## [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.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "psysonic",
"version": "1.48.0-dev",
"version": "1.48.0",
"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.0",
"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.0"
dependencies = [
"biquad",
"dasp_sample",
@@ -4167,7 +4167,7 @@ dependencies = [
[[package]]
name = "psysonic-analysis"
version = "1.48.0-dev"
version = "1.48.0"
dependencies = [
"ebur128",
"futures-util",
@@ -4186,7 +4186,7 @@ dependencies = [
[[package]]
name = "psysonic-audio"
version = "1.48.0-dev"
version = "1.48.0"
dependencies = [
"biquad",
"dasp_sample",
@@ -4216,7 +4216,7 @@ dependencies = [
[[package]]
name = "psysonic-core"
version = "1.48.0-dev"
version = "1.48.0"
dependencies = [
"libc",
"serde",
@@ -4225,7 +4225,7 @@ dependencies = [
[[package]]
name = "psysonic-integration"
version = "1.48.0-dev"
version = "1.48.0"
dependencies = [
"discord-rich-presence",
"futures-util",
@@ -4242,7 +4242,7 @@ dependencies = [
[[package]]
name = "psysonic-library"
version = "1.48.0-dev"
version = "1.48.0"
dependencies = [
"psysonic-core",
"psysonic-integration",
@@ -4257,7 +4257,7 @@ dependencies = [
[[package]]
name = "psysonic-syncfs"
version = "1.48.0-dev"
version = "1.48.0"
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.0"
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};
+20 -20
View File
@@ -502,11 +502,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 +608,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,14 +84,21 @@ 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(()); };
ctrl.set_metadata(MediaMetadata {
// DIAG #1102: log the cover URL going to the OS media controls + whether
// set_metadata succeeds (a throwing cover_url would also drop title/artist).
crate::app_eprintln!("[mpris-cover] cover_url={:?}", cover_url);
let res = ctrl.set_metadata(MediaMetadata {
title: title.as_deref(),
artist: artist.as_deref(),
album: album.as_deref(),
cover_url: cover_url.as_deref(),
duration,
})
.map_err(|e| format!("MPRIS set_metadata failed: {e:?}"))
});
match &res {
Ok(()) => crate::app_eprintln!("[mpris-cover] set_metadata OK"),
Err(e) => crate::app_eprintln!("[mpris-cover] set_metadata ERR: {e:?}"),
}
res.map_err(|e| format!("MPRIS set_metadata failed: {e:?}"))
}
#[tauri::command]
+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.0",
"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,