mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
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:
committed by
GitHub
parent
187170057d
commit
ccf4d5d8cb
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -89,6 +89,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
advancedSettingsEnabled: false,
|
||||
seekbarStyle: 'truewave',
|
||||
queueNowPlayingCollapsed: false,
|
||||
queueDurationDisplayMode: 'total',
|
||||
enableHiRes: false,
|
||||
audioOutputDevice: null,
|
||||
hotCacheEnabled: false,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
Reference in New Issue
Block a user