feat(queue): persist queue header duration mode (#724)

* 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. <kveld912@proton.me>

* 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. <kveld912@proton.me>
This commit is contained in:
Frank Stellmacher
2026-05-15 19:14:06 +02:00
committed by GitHub
parent 187170057d
commit ccf4d5d8cb
10 changed files with 73 additions and 3 deletions
+6
View File
@@ -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)
+2 -2
View File
@@ -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<DurationMode>('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);
+1
View File
@@ -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)',
],
},
{
+1
View File
@@ -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],
+1
View File
@@ -89,6 +89,7 @@ export const useAuthStore = create<AuthState>()(
advancedSettingsEnabled: false,
seekbarStyle: 'truewave',
queueNowPlayingCollapsed: false,
queueDurationDisplayMode: 'total',
enableHiRes: false,
audioOutputDevice: null,
hotCacheEnabled: false,
+42
View File
@@ -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();
},
);
});
+12
View File
@@ -15,6 +15,7 @@ import {
import type {
AuthState,
DiscordCoverSource,
DurationMode,
LyricsSourceConfig,
SeekbarStyle,
} from './authStoreTypes';
@@ -79,6 +80,16 @@ export function computeAuthStoreRehydration(state: AuthState): Partial<AuthState
? {}
: { seekbarStyle: 'truewave' as SeekbarStyle };
// Garbage / null / undefined / missing key from a legacy or tampered persist
// payload maps back to 'total' so the duration chip never receives an
// unknown mode (would render an empty label).
const VALID_QUEUE_DURATION_MODES = new Set<string>(['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<AuthState
...lyricsSourcesMigrated,
...wheelSmoothOneTime,
...seekbarStyleMigrated,
...queueDurationDisplayModeMigrated,
...discordCoverSourceMigrated,
};
}
+5
View File
@@ -13,6 +13,8 @@ export interface ServerProfile {
}
export type SeekbarStyle = 'truewave' | 'pseudowave' | 'linedot' | 'bar' | 'thick' | 'segmented' | 'neon' | 'pulsewave' | 'particletrail' | 'liquidfill' | 'retrotape';
/** Queue header duration chip: total duration / time left / ETA finish clock. */
export type DurationMode = 'total' | 'remaining' | 'eta';
export type LoggingMode = 'off' | 'normal' | 'debug';
export type NormalizationEngine = 'off' | 'replaygain' | 'loudness';
export type DiscordCoverSource = 'none' | 'apple' | 'server';
@@ -136,6 +138,8 @@ export interface AuthState {
seekbarStyle: SeekbarStyle;
/** Persisted UI toggle: is the Now Playing section in queue panel collapsed */
queueNowPlayingCollapsed: boolean;
/** Queue header duration chip mode (cycle: total → remaining → ETA). */
queueDurationDisplayMode: DurationMode;
/** Alpha: native hi-res sample rate output (disabled = safe 44.1 kHz mode) */
enableHiRes: boolean;
@@ -289,6 +293,7 @@ export interface AuthState {
setAdvancedSettingsEnabled: (v: boolean) => 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;
+2
View File
@@ -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 }),
@@ -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[] = [];