From e563749ace76742eda3d4d891be4ee1e628df00f Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Mon, 15 Jun 2026 22:11:56 +0200 Subject: [PATCH 01/12] fix(queue): stop re-walking the whole queue on every track change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. (cherry picked from commit 7e91a5b2a112e76829a9d483ea08186489f0826c) --- src/components/queuePanel/QueueHeader.tsx | 32 ++++++++++++++--------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/src/components/queuePanel/QueueHeader.tsx b/src/components/queuePanel/QueueHeader.tsx index 10014966..05d0dbd4 100644 --- a/src/components/queuePanel/QueueHeader.tsx +++ b/src/components/queuePanel/QueueHeader.tsx @@ -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); From 4c0dfaaada6708d1cb4434ed28a8dd1508c9542e Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:10:49 +0200 Subject: [PATCH 02/12] 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 (cherry picked from commit f04bfb3d355f95152df4837cf117ebc7668387f3) --- src-tauri/src/lib.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3fcc7b06..16e3a98e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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", ()); } From 997e697a538ac82ee5d2885f2550c6188cccf5a5 Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:43:23 +0200 Subject: [PATCH 03/12] 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. (cherry picked from commit 588dd8c48d02f57a89e2091b79b9498052f4895f) --- .../tauriBridge/useAudioDeviceBridge.test.ts | 16 ++++++++++++++++ src/hooks/tauriBridge/useAudioDeviceBridge.ts | 11 +++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/hooks/tauriBridge/useAudioDeviceBridge.test.ts b/src/hooks/tauriBridge/useAudioDeviceBridge.test.ts index d9e5253c..9ebd1a75 100644 --- a/src/hooks/tauriBridge/useAudioDeviceBridge.test.ts +++ b/src/hooks/tauriBridge/useAudioDeviceBridge.test.ts @@ -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 ───────────────────────────────────────────────────── diff --git a/src/hooks/tauriBridge/useAudioDeviceBridge.ts b/src/hooks/tauriBridge/useAudioDeviceBridge.ts index 4f95a415..ced55f25 100644 --- a/src/hooks/tauriBridge/useAudioDeviceBridge.ts +++ b/src/hooks/tauriBridge/useAudioDeviceBridge.ts @@ -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, From 42dcbb93233c90725e15f0904bb25eb0f3dc43cb Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:49:49 +0200 Subject: [PATCH 04/12] 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. (cherry picked from commit 9034882bf6d305a5cc3c51c21f6effdab15450e8) --- src-tauri/crates/psysonic-audio/src/device_watcher.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src-tauri/crates/psysonic-audio/src/device_watcher.rs b/src-tauri/crates/psysonic-audio/src/device_watcher.rs index 7c55b544..d409adb6 100644 --- a/src-tauri/crates/psysonic-audio/src/device_watcher.rs +++ b/src-tauri/crates/psysonic-audio/src/device_watcher.rs @@ -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(); } From 4fd558fa28f6e18bda5217d624725a284d84e47e Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:08:57 +0200 Subject: [PATCH 05/12] fix(discord): use the public server address for Rich Presence cover art MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. (cherry picked from commit 5f15784b7de63334a2250b0c6fffc1a1b47824d1) --- src/cover/integrations/discord.test.ts | 48 ++++++++++++++++++++++ src/cover/integrations/discord.ts | 57 ++++++++++++++++++++++---- 2 files changed, 97 insertions(+), 8 deletions(-) create mode 100644 src/cover/integrations/discord.test.ts diff --git a/src/cover/integrations/discord.test.ts b/src/cover/integrations/discord.test.ts new file mode 100644 index 00000000..0a70e9e3 --- /dev/null +++ b/src/cover/integrations/discord.test.ts @@ -0,0 +1,48 @@ +/** + * 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'); + }); +}); diff --git a/src/cover/integrations/discord.ts b/src/cover/integrations/discord.ts index 52ef7d59..909a86c1 100644 --- a/src/cover/integrations/discord.ts +++ b/src/cover/integrations/discord.ts @@ -1,13 +1,54 @@ -import { buildCoverArtFetchUrl } from '../fetchUrl'; -import type { CoverArtRef } from '../types'; +import { buildCoverArtUrlForServer } from '../../api/subsonicStreamUrl'; +import { serverShareBaseUrl } from '../../utils/server/serverEndpoint'; +import { getPlaybackServerId } from '../../utils/playback/playbackServer'; +import { useAuthStore } from '../../store/authStore'; +import type { CoverArtRef, CoverServerScope } from '../types'; + +/** The saved profile id that a cover scope resolves to (active/playback/server). */ +function serverIdForScope(scope: CoverServerScope): string | null { + if (scope.kind === 'server') return scope.serverId; + if (scope.kind === 'playback') { + return getPlaybackServerId() ?? useAuthStore.getState().activeServerId ?? null; + } + return useAuthStore.getState().activeServerId ?? null; +} /** - * 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). */ export async function coverArtUrlForDiscord(ref: CoverArtRef): Promise { - return buildCoverArtFetchUrl(ref, 800) || null; + const { serverScope, fetchCoverArtId } = ref; + const serverId = serverIdForScope(serverScope); + const profile = serverId + ? useAuthStore.getState().servers.find(s => s.id === serverId) + : undefined; + + 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; } From 961dba996c9de81d43e4331656f251694c041baa Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:45:38 +0200 Subject: [PATCH 06/12] fix(discord): resolve cover profile from the store, not getPlaybackServerId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. (cherry picked from commit 8f93f30e6fd28e9ac64e958cf80250c32bd5e896) --- src/cover/integrations/discord.test.ts | 21 +++++++++++++++++++ src/cover/integrations/discord.ts | 28 ++++++++++++-------------- 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/src/cover/integrations/discord.test.ts b/src/cover/integrations/discord.test.ts index 0a70e9e3..8771ad4e 100644 --- a/src/cover/integrations/discord.test.ts +++ b/src/cover/integrations/discord.test.ts @@ -45,4 +45,25 @@ describe('coverArtUrlForDiscord', () => { 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'); + }); }); diff --git a/src/cover/integrations/discord.ts b/src/cover/integrations/discord.ts index 909a86c1..d52bd24c 100644 --- a/src/cover/integrations/discord.ts +++ b/src/cover/integrations/discord.ts @@ -1,17 +1,7 @@ import { buildCoverArtUrlForServer } from '../../api/subsonicStreamUrl'; import { serverShareBaseUrl } from '../../utils/server/serverEndpoint'; -import { getPlaybackServerId } from '../../utils/playback/playbackServer'; import { useAuthStore } from '../../store/authStore'; -import type { CoverArtRef, CoverServerScope } from '../types'; - -/** The saved profile id that a cover scope resolves to (active/playback/server). */ -function serverIdForScope(scope: CoverServerScope): string | null { - if (scope.kind === 'server') return scope.serverId; - if (scope.kind === 'playback') { - return getPlaybackServerId() ?? useAuthStore.getState().activeServerId ?? null; - } - return useAuthStore.getState().activeServerId ?? null; -} +import type { CoverArtRef } from '../types'; /** * Discord large image — an https:// URL Discord's own servers can reach. @@ -21,13 +11,21 @@ function serverIdForScope(scope: CoverServerScope): string | null { * 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 { const { serverScope, fetchCoverArtId } = ref; - const serverId = serverIdForScope(serverScope); - const profile = serverId - ? useAuthStore.getState().servers.find(s => s.id === serverId) - : undefined; + 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( From 16e562b42d9f05fece088c7da5ae0d7071fcbed5 Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:00:18 +0200 Subject: [PATCH 07/12] fix(window): honour minimize-to-tray on the macOS close button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (cherry picked from commit acd6f12abaa2fe77211b72be92ba24f219cf2ac2) --- src-tauri/src/lib.rs | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 16e3a98e..94866281 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -608,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. From 067ed00ae2b3c8fb715222f06fb8a991b5e20ac6 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:26:37 +0300 Subject: [PATCH 08/12] fix(audio): fix Opus/Ogg seek crash (symphonia do_seek panic) (#1100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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). (cherry picked from commit 8bfde08199396924dc9602c58449b073ae7483a0) --- src-tauri/crates/psysonic-audio/src/decode.rs | 76 ++++++++++++++----- .../psysonic-audio/src/device_resume.rs | 1 + .../crates/psysonic-audio/src/play_input.rs | 5 ++ .../crates/psysonic-audio/src/preview.rs | 2 +- .../psysonic-audio/src/radio_commands.rs | 2 +- .../crates/psysonic-audio/src/source_build.rs | 10 ++- .../crates/psysonic-audio/src/stream/mod.rs | 17 +++++ 7 files changed, 90 insertions(+), 23 deletions(-) diff --git a/src-tauri/crates/psysonic-audio/src/decode.rs b/src-tauri/crates/psysonic-audio/src/decode.rs index c0c9950b..fe4165fd 100644 --- a/src-tauri/crates/psysonic-audio/src/decode.rs +++ b/src-tauri/crates/psysonic-audio/src/decode.rs @@ -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 = 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, format_hint: Option<&str>, source_tag: &str, + source_random_access: bool, ) -> Result { // 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 = 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()); } diff --git a/src-tauri/crates/psysonic-audio/src/device_resume.rs b/src-tauri/crates/psysonic-audio/src/device_resume.rs index 1dd8e7b0..c4fd7833 100644 --- a/src-tauri/crates/psysonic-audio/src/device_resume.rs +++ b/src-tauri/crates/psysonic-audio/src/device_resume.rs @@ -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, } } diff --git a/src-tauri/crates/psysonic-audio/src/play_input.rs b/src-tauri/crates/psysonic-audio/src/play_input.rs index 4ed0382d..03f7f3f5 100644 --- a/src-tauri/crates/psysonic-audio/src/play_input.rs +++ b/src-tauri/crates/psysonic-audio/src/play_input.rs @@ -40,6 +40,9 @@ pub(crate) enum PlayInput { reader: Box, format_hint: Option, 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, }, @@ -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, })); } diff --git a/src-tauri/crates/psysonic-audio/src/preview.rs b/src-tauri/crates/psysonic-audio/src/preview.rs index 610c1ffd..aad71ab1 100644 --- a/src-tauri/crates/psysonic-audio/src/preview.rs +++ b/src-tauri/crates/psysonic-audio/src/preview.rs @@ -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}"))??; diff --git a/src-tauri/crates/psysonic-audio/src/radio_commands.rs b/src-tauri/crates/psysonic-audio/src/radio_commands.rs index d84cf0fd..6fb4c3df 100644 --- a/src-tauri/crates/psysonic-audio/src/radio_commands.rs +++ b/src-tauri/crates/psysonic-audio/src/radio_commands.rs @@ -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())??; diff --git a/src-tauri/crates/psysonic-audio/src/source_build.rs b/src-tauri/crates/psysonic-audio/src/source_build.rs index 0f6d7f0a..c5a89ed2 100644 --- a/src-tauri/crates/psysonic-audio/src/source_build.rs +++ b/src-tauri/crates/psysonic-audio/src/source_build.rs @@ -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())??; diff --git a/src-tauri/crates/psysonic-audio/src/stream/mod.rs b/src-tauri/crates/psysonic-audio/src/stream/mod.rs index 412aee5c..8085c29b 100644 --- a/src-tauri/crates/psysonic-audio/src/stream/mod.rs +++ b/src-tauri/crates/psysonic-audio/src/stream/mod.rs @@ -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}; From 6168e81195fb5046994ff4fda7f796df991f2042 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:41:46 +0300 Subject: [PATCH 09/12] =?UTF-8?q?fix(library):=20use=20partial=20indexes?= =?UTF-8?q?=20for=20=C2=A76.9=20remap=20lookup=20(#1105)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) (cherry picked from commit bca0acbaff065afbeeb0f0b36db9fcfe4d4f45dc) --- .../psysonic-library/src/repos/track.rs | 128 +++++++++++++++--- 1 file changed, 106 insertions(+), 22 deletions(-) diff --git a/src-tauri/crates/psysonic-library/src/repos/track.rs b/src-tauri/crates/psysonic-library/src/repos/track.rs index 924750cd..db70d774 100644 --- a/src-tauri/crates/psysonic-library/src/repos/track.rs +++ b/src-tauri/crates/psysonic-library/src/repos/track.rs @@ -448,7 +448,10 @@ impl<'a> TrackRepository<'a> { let mut remapped: Vec = 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 = if let Some(ref mut lookup) = remap_lookup { - detect_remap_target_cached(lookup, r)? - } else { - None - }; + let detected_old: Option = + 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> { // 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> = 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 From 6d63365c2a7b8c24ccaf6f21e2b4aee42c3d57c0 Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:44:11 +0200 Subject: [PATCH 10/12] 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. (cherry picked from commit 4fd85f2dd415cc3e2ba9dc61839fb9994918f047) --- src-tauri/src/lib.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 94866281..7f3153e8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -64,7 +64,28 @@ fn on_second_instance( } } +/// 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")] { From a6122f9db4f0b97d3361f3daa97cdd9e874ae3ca Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:51:19 +0200 Subject: [PATCH 11/12] 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. (cherry picked from commit 76d028127d3a79627057e15e3838b2b186807e88) --- .../src/lib_commands/app_api/integration.rs | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src-tauri/src/lib_commands/app_api/integration.rs b/src-tauri/src/lib_commands/app_api/integration.rs index 3009ff05..0d105af4 100644 --- a/src-tauri/src/lib_commands/app_api/integration.rs +++ b/src-tauri/src/lib_commands/app_api/integration.rs @@ -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) -> Option { + #[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 { + 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, From 82967caa9c6a930823187a8aec51312a2a36083f Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Wed, 17 Jun 2026 01:32:40 +0300 Subject: [PATCH 12/12] docs: add 1.48.1 release section to CHANGELOG and WHATS_NEW MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of the 1.48.1 hotfix notes onto main (which is on 1.49.0-dev). Insert the released [1.48.1] section between [1.49.0] and [1.48.0] in CHANGELOG.md (all eight Fixed entries with their attribution, identical to the fix/1.48.1 branch) and the matching [1.48.1] What's New section. Application version is intentionally left at 1.49.0-dev — only the notes are carried over. --- CHANGELOG.md | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++ WHATS_NEW.md | 26 ++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58604bbe..5ff83f85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * New store filter to show only animated themes or only static ones, next to the existing mode and sort controls. +## [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] diff --git a/WHATS_NEW.md b/WHATS_NEW.md index 3d5d5acf..7abf71d5 100644 --- a/WHATS_NEW.md +++ b/WHATS_NEW.md @@ -7,6 +7,32 @@ 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