feat(queue): preserve Play Next order toggle (#464)

* feat(queue): add preservePlayNextOrder setting + playNext store action

- New Track.playNextAdded flag (analogous to autoAdded / radioAdded).
  Stale flags behind queueIndex are harmless — only forward streak scan.
- New playerStore action playNext(tracks): tags incoming tracks and
  delegates to enqueueAt for unified undo + server sync.
- New authStore boolean preservePlayNextOrder (default false). When on,
  playNext appends behind the existing Play-Next streak (Spotify-style)
  instead of inserting directly after the current track.

* refactor(context-menu): centralise Play Next; add Settings toggle + i18n

- Replace 3 inline splice/enqueueAt call sites in ContextMenu with the
  new playNext action. Side-benefit: the single-song path now goes
  through enqueueAt and gets undo + queue sync (previously missing).
- Settings → Audio → Playback: new toggle below Gapless.
- 8 locales: preservePlayNextOrder + preservePlayNextOrderDesc.

* docs(contributors): credit + changelog entry for #464
This commit is contained in:
Frank Stellmacher
2026-05-05 22:33:15 +02:00
committed by GitHub
parent e1f2cb4c37
commit 0fab2849e5
13 changed files with 76 additions and 27 deletions
+4
View File
@@ -130,6 +130,7 @@ interface AuthState {
preloadMode: 'off' | 'balanced' | 'early' | 'custom';
preloadCustomSeconds: number;
infiniteQueueEnabled: boolean;
preservePlayNextOrder: boolean;
showArtistImages: boolean;
showTrayIcon: boolean;
minimizeToTray: boolean;
@@ -307,6 +308,7 @@ interface AuthState {
setPreloadMode: (v: 'off' | 'balanced' | 'early' | 'custom') => void;
setPreloadCustomSeconds: (v: number) => void;
setInfiniteQueueEnabled: (v: boolean) => void;
setPreservePlayNextOrder: (v: boolean) => void;
setShowArtistImages: (v: boolean) => void;
setShowTrayIcon: (v: boolean) => void;
setMinimizeToTray: (v: boolean) => void;
@@ -444,6 +446,7 @@ export const useAuthStore = create<AuthState>()(
preloadMode: 'balanced',
preloadCustomSeconds: 30,
infiniteQueueEnabled: false,
preservePlayNextOrder: false,
showArtistImages: false,
showTrayIcon: true,
minimizeToTray: false,
@@ -607,6 +610,7 @@ export const useAuthStore = create<AuthState>()(
setPreloadMode: (v: 'off' | 'balanced' | 'early' | 'custom') => set({ preloadMode: v }),
setPreloadCustomSeconds: (v: number) => set({ preloadCustomSeconds: v }),
setInfiniteQueueEnabled: (v) => set({ infiniteQueueEnabled: v }),
setPreservePlayNextOrder: (v) => set({ preservePlayNextOrder: v }),
setShowArtistImages: (v) => set({ showArtistImages: v }),
setShowTrayIcon: (v) => set({ showTrayIcon: v }),
setMinimizeToTray: (v) => set({ minimizeToTray: v }),
+27
View File
@@ -73,6 +73,10 @@ export interface Track {
size?: number;
autoAdded?: boolean;
radioAdded?: boolean;
/** Inserted via "Play Next". Used by the preserve-order toggle to find the
* end of the current Play-Next streak. Stale flags behind queueIndex are
* harmless — the streak scan only looks forward from queueIndex+1. */
playNextAdded?: boolean;
}
export function songToTrack(song: SubsonicSong): Track {
@@ -260,6 +264,12 @@ interface PlayerState {
setProgress: (t: number, duration: number) => void;
enqueue: (tracks: Track[], _orbitConfirmed?: boolean) => void;
enqueueAt: (tracks: Track[], insertIndex: number, _orbitConfirmed?: boolean) => void;
/** "Play Next" — inserts after the current track. When
* `preservePlayNextOrder` is on, appends to the existing Play-Next streak
* (Spotify-style); otherwise inserts directly after the current track and
* pushes any earlier Play-Next items down (default). Falls back to
* `playTrack` when nothing is currently playing. */
playNext: (tracks: Track[]) => void;
enqueueRadio: (tracks: Track[], artistId?: string) => void;
setRadioArtistId: (artistId: string) => void;
/** For Lucky Mix: drop upcoming tail; keep the currently playing item only. */
@@ -3247,6 +3257,23 @@ export const usePlayerStore = create<PlayerState>()(
});
},
playNext: (tracks) => {
if (tracks.length === 0) return;
const state = get();
const tagged = tracks.map(t => ({ ...t, playNextAdded: true as const }));
if (!state.currentTrack) {
state.playTrack(tagged[0], tagged);
return;
}
const baseIdx = state.queueIndex + 1;
let insertIdx = baseIdx;
if (useAuthStore.getState().preservePlayNextOrder) {
const q = state.queue;
while (insertIdx < q.length && q[insertIdx].playNextAdded) insertIdx++;
}
get().enqueueAt(tagged, insertIdx);
},
clearQueue: () => {
invoke('audio_stop').catch(console.error);
isAudioPaused = false;