From ccf4d5d8cb293b5131bd236c7dc75822d173a4b6 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Fri, 15 May 2026 19:14:06 +0200 Subject: [PATCH] feat(queue): persist queue header duration mode (#724) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(queue): persist header duration mode (#625) The queue header chip cycles total / remaining / ETA, but the choice lived in QueuePanel `useState` and reset to 'total' on every app launch. Move it into `authStore` (persisted under `psysonic-auth`) so the chosen mode survives restarts like other UI preferences. - `DurationMode` consolidated in `authStoreTypes` (re-exported from `queuePanelHelpers` so existing component imports stay valid). - New `queueDurationDisplayMode` field + `setQueueDurationDisplayMode` setter wired through `createUiAppearanceActions`, default `'total'`. - `computeAuthStoreRehydration` validates the persisted value against the three known modes (pattern matches the existing seekbarStyle block) — garbage, null, undefined, or a missing key map back to `'total'` so the chip never receives an unknown mode. - `QueuePanel` reads / writes via `useAuthStore` selectors instead of local `useState`. - Tests: trivial-setter coverage in `authStore.settings.test.ts` and a new `authStoreRehydrate.test.ts` covering corrupt, missing, and valid rehydration cases. Reuses kveld9's design from PR #625; not merged because the 1.46 refactor split locales and reshuffled queue-helper exports. Credited via Co-Authored-By trailer. Co-Authored-By: Kveld. * docs(changelog): queue header duration mode persistence (#724) Adds the CHANGELOG entry + kveld9 credits line referencing the real PR numbers (no #TBD placeholder) — feedback.md §2.7+§2.8 workflow: commit docs as a second push onto the open PR. --------- Co-authored-by: Kveld. --- CHANGELOG.md | 6 +++ src/components/QueuePanel.tsx | 4 +- src/config/settingsCredits.ts | 1 + src/store/authStore.settings.test.ts | 1 + src/store/authStore.ts | 1 + src/store/authStoreRehydrate.test.ts | 42 +++++++++++++++++++ src/store/authStoreRehydrate.ts | 12 ++++++ src/store/authStoreTypes.ts | 5 +++ src/store/authUiAppearanceActions.ts | 2 + .../componentHelpers/queuePanelHelpers.tsx | 2 +- 10 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 src/store/authStoreRehydrate.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6039e9d7..7a8739d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -189,6 +189,12 @@ Foundational work: faster reviews, narrower diffs, and a safety net under the pa * Lives under the **Advanced** group in Personalisation (only visible when the global Advanced Mode toggle is on). * i18n: `settings.playerBar*` across all 9 locales. +### Queue panel — persist header duration mode + +**By [@kveld9](https://github.com/kveld9) + [@Psychotoxical](https://github.com/Psychotoxical), based on [PR #625](https://github.com/Psychotoxical/psysonic/pull/625), PR [#724](https://github.com/Psychotoxical/psysonic/pull/724)** + +* The queue header chip (total duration / remaining time / ETA finish clock) now persists across app restarts via a new `queueDurationDisplayMode` field on `authStore`. Corrupt or missing persisted values fall back to **total** on rehydrate, matching the existing `seekbarStyle` sanitizer pattern. + ## Changed ### Backend — Cargo workspace with 5 domain crates (Rust refactor) diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx index 327d07a8..deef8733 100644 --- a/src/components/QueuePanel.tsx +++ b/src/components/QueuePanel.tsx @@ -22,7 +22,6 @@ import NowPlayingInfo from './NowPlayingInfo'; import { TFunction } from 'i18next'; import { useLuckyMixStore } from '../store/luckyMixStore'; import { useQueueToolbarStore } from '../store/queueToolbarStore'; -import { DurationMode } from '../utils/componentHelpers/queuePanelHelpers'; import { SavePlaylistModal } from './queuePanel/SavePlaylistModal'; import { LoadPlaylistModal } from './queuePanel/LoadPlaylistModal'; import { QueueHeader } from './queuePanel/QueueHeader'; @@ -122,7 +121,8 @@ function QueuePanelHostOrSolo() { const isNowPlayingCollapsed = useAuthStore(s => s.queueNowPlayingCollapsed); const setIsNowPlayingCollapsed = useAuthStore(s => s.setQueueNowPlayingCollapsed); const toolbarButtons = useQueueToolbarStore(s => s.buttons); - const [durationMode, setDurationMode] = useState('total'); + const durationMode = useAuthStore(s => s.queueDurationDisplayMode); + const setDurationMode = useAuthStore(s => s.setQueueDurationDisplayMode); const expandReplayGain = useThemeStore(s => s.expandReplayGain); const setExpandReplayGain = useThemeStore(s => s.setExpandReplayGain); const reanalyzeLoudnessForTrack = usePlayerStore(s => s.reanalyzeLoudnessForTrack); diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 8e0b6508..90411af0 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -170,6 +170,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Customizable queue toolbar — drag-and-drop button reordering, per-button visibility toggles, optional separator, persisted across restarts (PR #534)', 'Playlist page layout — per-element visibility toggles (Add Songs, Import CSV, Download ZIP, Cache Offline, Suggestions) under Settings → Personalisation (PR #556)', 'Player bar layout — per-control visibility toggles (Star rating, Favorite, Last.fm love, Equalizer, Mini player) under Settings → Personalisation (PR #627)', + 'Queue panel — persist header duration mode (total / remaining / ETA) across app restarts (PR #625)', ], }, { diff --git a/src/store/authStore.settings.test.ts b/src/store/authStore.settings.test.ts index 60fd96fe..a542b3c0 100644 --- a/src/store/authStore.settings.test.ts +++ b/src/store/authStore.settings.test.ts @@ -65,6 +65,7 @@ describe('trivial pass-through setters', () => { ['setLyricsStaticOnly', 'lyricsStaticOnly', true], ['setShowChangelogOnUpdate', 'showChangelogOnUpdate', false], ['setQueueNowPlayingCollapsed', 'queueNowPlayingCollapsed', true], + ['setQueueDurationDisplayMode', 'queueDurationDisplayMode', 'eta'], ['setEnableHiRes', 'enableHiRes', true], ['setHotCacheEnabled', 'hotCacheEnabled', true], ['setMixMinRatingFilterEnabled', 'mixMinRatingFilterEnabled', true], diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 906db07c..67f2977d 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -89,6 +89,7 @@ export const useAuthStore = create()( advancedSettingsEnabled: false, seekbarStyle: 'truewave', queueNowPlayingCollapsed: false, + queueDurationDisplayMode: 'total', enableHiRes: false, audioOutputDevice: null, hotCacheEnabled: false, diff --git a/src/store/authStoreRehydrate.test.ts b/src/store/authStoreRehydrate.test.ts new file mode 100644 index 00000000..1ed7a929 --- /dev/null +++ b/src/store/authStoreRehydrate.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { computeAuthStoreRehydration } from './authStoreRehydrate'; +import { useAuthStore } from './authStore'; +import type { AuthState } from './authStoreTypes'; +import { resetAuthStore } from '@/test/helpers/storeReset'; + +describe('computeAuthStoreRehydration — queueDurationDisplayMode', () => { + beforeEach(() => { + resetAuthStore(); + }); + + it.each(['invalid_mode', 123, null, undefined] as const)( + 'maps corrupted value %j back to "total"', + (corrupt) => { + const base = useAuthStore.getState(); + const patch = computeAuthStoreRehydration({ + ...base, + queueDurationDisplayMode: corrupt as never, + }); + expect(patch.queueDurationDisplayMode).toBe('total'); + }, + ); + + it('maps a rehydrated payload without the key back to "total"', () => { + const base = useAuthStore.getState(); + const { queueDurationDisplayMode: _drop, ...without } = base; + const patch = computeAuthStoreRehydration(without as AuthState); + expect(patch.queueDurationDisplayMode).toBe('total'); + }); + + it.each(['total', 'remaining', 'eta'] as const)( + 'does not overwrite a valid mode (%s)', + (mode) => { + const base = useAuthStore.getState(); + const patch = computeAuthStoreRehydration({ + ...base, + queueDurationDisplayMode: mode, + }); + expect(patch.queueDurationDisplayMode).toBeUndefined(); + }, + ); +}); diff --git a/src/store/authStoreRehydrate.ts b/src/store/authStoreRehydrate.ts index 969b0d4c..e85884e5 100644 --- a/src/store/authStoreRehydrate.ts +++ b/src/store/authStoreRehydrate.ts @@ -15,6 +15,7 @@ import { import type { AuthState, DiscordCoverSource, + DurationMode, LyricsSourceConfig, SeekbarStyle, } from './authStoreTypes'; @@ -79,6 +80,16 @@ export function computeAuthStoreRehydration(state: AuthState): Partial(['total', 'remaining', 'eta']); + const queueDurationDisplayModeMigrated = VALID_QUEUE_DURATION_MODES.has( + (state as { queueDurationDisplayMode?: unknown }).queueDurationDisplayMode as string, + ) + ? {} + : { queueDurationDisplayMode: 'total' as DurationMode }; + // The `animationMode` 3-state setting was removed; users on `'reduced'` // or `'static'` collapse onto the former `'full'` path automatically as // soon as the field is gone from the store. Strip the persisted field @@ -130,6 +141,7 @@ export function computeAuthStoreRehydration(state: AuthState): Partial void; setSeekbarStyle: (v: SeekbarStyle) => void; setQueueNowPlayingCollapsed: (v: boolean) => void; + setQueueDurationDisplayMode: (v: DurationMode) => void; setEnableHiRes: (v: boolean) => void; setAudioOutputDevice: (v: string | null) => void; setHotCacheEnabled: (v: boolean) => void; diff --git a/src/store/authUiAppearanceActions.ts b/src/store/authUiAppearanceActions.ts index ad76b1bd..dadf0b29 100644 --- a/src/store/authUiAppearanceActions.ts +++ b/src/store/authUiAppearanceActions.ts @@ -22,6 +22,7 @@ export function createUiAppearanceActions(set: SetState): Pick< | 'setLinuxWebkitKineticScroll' | 'setSeekbarStyle' | 'setQueueNowPlayingCollapsed' + | 'setQueueDurationDisplayMode' | 'setShowFullscreenLyrics' | 'setFsLyricsStyle' | 'setSidebarLyricsStyle' @@ -42,6 +43,7 @@ export function createUiAppearanceActions(set: SetState): Pick< setLinuxWebkitKineticScroll: (v) => set({ linuxWebkitKineticScroll: v }), setSeekbarStyle: (v) => set({ seekbarStyle: v }), setQueueNowPlayingCollapsed: (v) => set({ queueNowPlayingCollapsed: v }), + setQueueDurationDisplayMode: (v) => set({ queueDurationDisplayMode: v }), setShowFullscreenLyrics: (v) => set({ showFullscreenLyrics: v }), setFsLyricsStyle: (v) => set({ fsLyricsStyle: v }), setSidebarLyricsStyle: (v) => set({ sidebarLyricsStyle: v }), diff --git a/src/utils/componentHelpers/queuePanelHelpers.tsx b/src/utils/componentHelpers/queuePanelHelpers.tsx index 3cbd91b9..960c7215 100644 --- a/src/utils/componentHelpers/queuePanelHelpers.tsx +++ b/src/utils/componentHelpers/queuePanelHelpers.tsx @@ -2,7 +2,7 @@ import { Star } from 'lucide-react'; import type { TFunction } from 'i18next'; import type { Track } from '../../store/playerStoreTypes'; -export type DurationMode = 'total' | 'remaining' | 'eta'; +export type { DurationMode } from '../../store/authStoreTypes'; export function formatQueueReplayGainParts(track: Track, t: TFunction): string[] { const parts: string[] = [];