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
+7
View File
@@ -26,6 +26,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* New cover-source picker under Discord Rich Presence settings: **None** (app icon only), **Server**, or **Apple Music**. Mutually exclusive. * New cover-source picker under Discord Rich Presence settings: **None** (app icon only), **Server**, or **Apple Music**. Mutually exclusive.
* Fresh installs default to **Server** for opt-in-friendly cover art with no third-party data leak. Existing users keep their previous Apple-covers preference via migration. * Fresh installs default to **Server** for opt-in-friendly cover art with no third-party data leak. Existing users keep their previous Apple-covers preference via migration.
### Queue — preserve "Play Next" insertion order (toggle)
**By [@Psychotoxical](https://github.com/Psychotoxical), suggested by [@Sayykii](https://github.com/Sayykii), PR [#464](https://github.com/Psychotoxical/psysonic/pull/464)**
* New optional toggle in Settings → Audio → Playback ("Preserve Play Next order"). When on, multiple "Play Next" insertions **queue up behind each other** instead of the latest one bumping earlier picks down. Default off — existing behaviour unchanged.
* Side-benefit: single-song "Play Next" now goes through the unified `enqueueAt` path and gets undo + server-sync support that the album path already had.
## Changed ## Changed
### Dependencies — npm / Cargo refresh and rodio 0.22 ### Dependencies — npm / Cargo refresh and rodio 0.22
+5 -27
View File
@@ -1019,12 +1019,13 @@ function MultiPlaylistToPlaylistSubmenu({ playlists, onDone, triggerId }: { play
export default function ContextMenu() { export default function ContextMenu() {
const { t } = useTranslation(); const { t } = useTranslation();
const orbitRole = useOrbitStore(s => s.role); const orbitRole = useOrbitStore(s => s.role);
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore( const { contextMenu, closeContextMenu, playTrack, enqueue, playNext, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore(
useShallow(s => ({ useShallow(s => ({
contextMenu: s.contextMenu, contextMenu: s.contextMenu,
closeContextMenu: s.closeContextMenu, closeContextMenu: s.closeContextMenu,
playTrack: s.playTrack, playTrack: s.playTrack,
enqueue: s.enqueue, enqueue: s.enqueue,
playNext: s.playNext,
queue: s.queue, queue: s.queue,
currentTrack: s.currentTrack, currentTrack: s.currentTrack,
removeTrack: s.removeTrack, removeTrack: s.removeTrack,
@@ -1528,16 +1529,7 @@ export default function ContextMenu() {
<div className="context-menu-item" onClick={() => handleAction(() => playTrack(song, [song]))}> <div className="context-menu-item" onClick={() => handleAction(() => playTrack(song, [song]))}>
<Play size={14} /> {t('contextMenu.playNow')} <Play size={14} /> {t('contextMenu.playNow')}
</div> </div>
<div className="context-menu-item" onClick={() => handleAction(() => { <div className="context-menu-item" onClick={() => handleAction(() => playNext([song]))}>
if (!currentTrack) {
playTrack(song, [song]);
return;
}
const currentIdx = usePlayerStore.getState().queueIndex;
const newQueue = [...queue];
newQueue.splice(currentIdx + 1, 0, song);
usePlayerStore.setState({ queue: newQueue });
})}>
<ChevronsRight size={14} /> {t('contextMenu.playNext')} <ChevronsRight size={14} /> {t('contextMenu.playNext')}
</div> </div>
<div className="context-menu-item" onClick={() => handleAction(() => enqueue([song]))}> <div className="context-menu-item" onClick={() => handleAction(() => enqueue([song]))}>
@@ -1691,16 +1683,7 @@ export default function ContextMenu() {
<div className="context-menu-item" onClick={() => handleAction(() => playTrack(song, [song]))}> <div className="context-menu-item" onClick={() => handleAction(() => playTrack(song, [song]))}>
<Play size={14} /> {t('contextMenu.playNow')} <Play size={14} /> {t('contextMenu.playNow')}
</div> </div>
<div className="context-menu-item" onClick={() => handleAction(() => { <div className="context-menu-item" onClick={() => handleAction(() => playNext([song]))}>
if (!currentTrack) {
playTrack(song, [song]);
return;
}
const currentIdx = usePlayerStore.getState().queueIndex;
const newQueue = [...queue];
newQueue.splice(currentIdx + 1, 0, song);
usePlayerStore.setState({ queue: newQueue });
})}>
<ChevronsRight size={14} /> {t('contextMenu.playNext')} <ChevronsRight size={14} /> {t('contextMenu.playNext')}
</div> </div>
<div className="context-menu-item" onClick={() => handleAction(() => enqueue([song]))}> <div className="context-menu-item" onClick={() => handleAction(() => enqueue([song]))}>
@@ -1830,12 +1813,7 @@ export default function ContextMenu() {
const albumData = await getAlbum(album.id); const albumData = await getAlbum(album.id);
const tracks = albumData.songs.map(songToTrack); const tracks = albumData.songs.map(songToTrack);
if (tracks.length === 0) return; if (tracks.length === 0) return;
if (!currentTrack) { playNext(tracks);
playTrack(tracks[0], tracks);
return;
}
const currentIdx = usePlayerStore.getState().queueIndex;
usePlayerStore.getState().enqueueAt(tracks, currentIdx + 1);
})}> })}>
<ChevronsRight size={14} /> {t('contextMenu.playNext')} <ChevronsRight size={14} /> {t('contextMenu.playNext')}
</div> </div>
+2
View File
@@ -891,6 +891,8 @@ export const deTranslation = {
notWithCrossfade: 'Nicht verfügbar wenn Crossfade aktiv ist', notWithCrossfade: 'Nicht verfügbar wenn Crossfade aktiv ist',
gapless: 'Nahtlose Wiedergabe', gapless: 'Nahtlose Wiedergabe',
gaplessDesc: 'Nächsten Track vorpuffern um Lücken zwischen Songs zu vermeiden', gaplessDesc: 'Nächsten Track vorpuffern um Lücken zwischen Songs zu vermeiden',
preservePlayNextOrder: '„Als Nächstes"-Reihenfolge bewahren',
preservePlayNextOrderDesc: 'Neu hinzugefügte „Als Nächstes"-Titel reihen sich hinten an statt sich vorn einzuschieben.',
trackPreviewsTitle: 'Track-Vorschau', trackPreviewsTitle: 'Track-Vorschau',
trackPreviewsToggle: 'Track-Vorschau aktivieren', trackPreviewsToggle: 'Track-Vorschau aktivieren',
trackPreviewsDesc: 'Inline Play- und Vorschau-Buttons in den Tracklisten anzeigen für eine kurze Hörprobe mitten im Song.', trackPreviewsDesc: 'Inline Play- und Vorschau-Buttons in den Tracklisten anzeigen für eine kurze Hörprobe mitten im Song.',
+2
View File
@@ -897,6 +897,8 @@ export const enTranslation = {
notWithCrossfade: 'Not available while Crossfade is active', notWithCrossfade: 'Not available while Crossfade is active',
gapless: 'Gapless Playback', gapless: 'Gapless Playback',
gaplessDesc: 'Pre-buffer next track to eliminate gaps between songs', gaplessDesc: 'Pre-buffer next track to eliminate gaps between songs',
preservePlayNextOrder: 'Preserve "Play Next" order',
preservePlayNextOrderDesc: 'Newly added Play Next items queue up behind earlier ones instead of jumping in front.',
trackPreviewsTitle: 'Track Previews', trackPreviewsTitle: 'Track Previews',
trackPreviewsToggle: 'Enable track previews', trackPreviewsToggle: 'Enable track previews',
trackPreviewsDesc: 'Show inline Play and Preview buttons in tracklists for a quick mid-song sample.', trackPreviewsDesc: 'Show inline Play and Preview buttons in tracklists for a quick mid-song sample.',
+2
View File
@@ -884,6 +884,8 @@ export const esTranslation = {
notWithCrossfade: 'No disponible mientras Crossfade está activo', notWithCrossfade: 'No disponible mientras Crossfade está activo',
gapless: 'Reproducción Gapless', gapless: 'Reproducción Gapless',
gaplessDesc: 'Precarga siguiente pista para eliminar silencios entre canciones', gaplessDesc: 'Precarga siguiente pista para eliminar silencios entre canciones',
preservePlayNextOrder: 'Mantener el orden de "Reproducir Siguiente"',
preservePlayNextOrderDesc: 'Los elementos añadidos a "Reproducir Siguiente" se encolan detrás de los anteriores en vez de saltar al principio.',
trackPreviewsTitle: 'Previsualizaciones de pistas', trackPreviewsTitle: 'Previsualizaciones de pistas',
trackPreviewsToggle: 'Activar previsualizaciones', trackPreviewsToggle: 'Activar previsualizaciones',
trackPreviewsDesc: 'Mostrar botones de Reproducir y Previsualización en las listas de pistas para una muestra rápida a mitad de canción.', trackPreviewsDesc: 'Mostrar botones de Reproducir y Previsualización en las listas de pistas para una muestra rápida a mitad de canción.',
+2
View File
@@ -879,6 +879,8 @@ export const frTranslation = {
notWithCrossfade: 'Non disponible quand le fondu enchaîné est actif', notWithCrossfade: 'Non disponible quand le fondu enchaîné est actif',
gapless: 'Lecture sans blanc', gapless: 'Lecture sans blanc',
gaplessDesc: 'Préparer la piste suivante pour éliminer les silences entre les morceaux', gaplessDesc: 'Préparer la piste suivante pour éliminer les silences entre les morceaux',
preservePlayNextOrder: "Préserver l'ordre « Lire ensuite »",
preservePlayNextOrderDesc: 'Les nouveaux éléments « Lire ensuite » s\'ajoutent à la fin de la file existante au lieu de la doubler.',
trackPreviewsTitle: 'Aperçus de pistes', trackPreviewsTitle: 'Aperçus de pistes',
trackPreviewsToggle: 'Activer les aperçus de pistes', trackPreviewsToggle: 'Activer les aperçus de pistes',
trackPreviewsDesc: 'Affiche les boutons Lecture et Aperçu dans les listes pour un court extrait au milieu du morceau.', trackPreviewsDesc: 'Affiche les boutons Lecture et Aperçu dans les listes pour un court extrait au milieu du morceau.',
+2
View File
@@ -878,6 +878,8 @@ export const nbTranslation = {
notWithCrossfade: 'Ikke tilgjengelig mens Crossfade er aktiv', notWithCrossfade: 'Ikke tilgjengelig mens Crossfade er aktiv',
gapless: 'Gapless avspilling', gapless: 'Gapless avspilling',
gaplessDesc: 'Forhåndsbuffer neste spor for å eliminere mellomrom mellom sanger', gaplessDesc: 'Forhåndsbuffer neste spor for å eliminere mellomrom mellom sanger',
preservePlayNextOrder: 'Behold "Spill neste"-rekkefølge',
preservePlayNextOrderDesc: 'Nye "Spill neste"-elementer havner bak de eksisterende i stedet for å snike seg foran.',
trackPreviewsTitle: 'Sporforhåndsvisning', trackPreviewsTitle: 'Sporforhåndsvisning',
trackPreviewsToggle: 'Aktiver sporforhåndsvisning', trackPreviewsToggle: 'Aktiver sporforhåndsvisning',
trackPreviewsDesc: 'Vis innebygde Spill- og Forhåndsvisning-knapper i sporlister for en kort smakebit fra midten av sangen.', trackPreviewsDesc: 'Vis innebygde Spill- og Forhåndsvisning-knapper i sporlister for en kort smakebit fra midten av sangen.',
+2
View File
@@ -878,6 +878,8 @@ export const nlTranslation = {
notWithCrossfade: 'Niet beschikbaar als overgang actief is', notWithCrossfade: 'Niet beschikbaar als overgang actief is',
gapless: 'Naadloos afspelen', gapless: 'Naadloos afspelen',
gaplessDesc: 'Volgend nummer vooraf bufferen om stiltes tussen nummers te elimineren', gaplessDesc: 'Volgend nummer vooraf bufferen om stiltes tussen nummers te elimineren',
preservePlayNextOrder: '"Volgende afspelen"-volgorde behouden',
preservePlayNextOrderDesc: 'Nieuwe "Volgende afspelen"-items komen achter bestaande te staan in plaats van ervoor.',
trackPreviewsTitle: 'Track-voorvertoning', trackPreviewsTitle: 'Track-voorvertoning',
trackPreviewsToggle: 'Track-voorvertoning inschakelen', trackPreviewsToggle: 'Track-voorvertoning inschakelen',
trackPreviewsDesc: 'Inline Play- en Voorvertoning-knoppen in trackslijsten tonen voor een korte sample midden in het nummer.', trackPreviewsDesc: 'Inline Play- en Voorvertoning-knoppen in trackslijsten tonen voor een korte sample midden in het nummer.',
+2
View File
@@ -930,6 +930,8 @@ export const ruTranslation = {
notWithCrossfade: 'Недоступно при включённом кроссфейде', notWithCrossfade: 'Недоступно при включённом кроссфейде',
gapless: 'Без пауз между треками', gapless: 'Без пауз между треками',
gaplessDesc: 'Заранее подгружать следующий трек, чтобы не было тишины', gaplessDesc: 'Заранее подгружать следующий трек, чтобы не было тишины',
preservePlayNextOrder: 'Сохранять порядок «Играть следующим»',
preservePlayNextOrderDesc: 'Новые элементы «Играть следующим» становятся в конец очереди, а не лезут вперёд.',
trackPreviewsTitle: 'Превью треков', trackPreviewsTitle: 'Превью треков',
trackPreviewsToggle: 'Включить превью треков', trackPreviewsToggle: 'Включить превью треков',
trackPreviewsDesc: 'Показывать встроенные кнопки воспроизведения и превью в списках треков для быстрого прослушивания фрагмента.', trackPreviewsDesc: 'Показывать встроенные кнопки воспроизведения и превью в списках треков для быстрого прослушивания фрагмента.',
+2
View File
@@ -873,6 +873,8 @@ export const zhTranslation = {
notWithCrossfade: '交叉淡入淡出开启时不可用', notWithCrossfade: '交叉淡入淡出开启时不可用',
gapless: '无缝播放', gapless: '无缝播放',
gaplessDesc: '预缓冲下一首曲目以消除歌曲间的间隙', gaplessDesc: '预缓冲下一首曲目以消除歌曲间的间隙',
preservePlayNextOrder: '保留"下一首播放"顺序',
preservePlayNextOrderDesc: '新添加的"下一首播放"项目排在现有项目之后,而不是插到前面。',
trackPreviewsTitle: '曲目预览', trackPreviewsTitle: '曲目预览',
trackPreviewsToggle: '启用曲目预览', trackPreviewsToggle: '启用曲目预览',
trackPreviewsDesc: '在曲目列表中显示内联播放与预览按钮,可快速试听歌曲中段。', trackPreviewsDesc: '在曲目列表中显示内联播放与预览按钮,可快速试听歌曲中段。',
+17
View File
@@ -360,6 +360,7 @@ const CONTRIBUTORS = [
'Settings: 3-state animation mode (Full / Reduced / Static) — replaces boolean reduce-animations toggle (PR #441)', 'Settings: 3-state animation mode (Full / Reduced / Static) — replaces boolean reduce-animations toggle (PR #441)',
'Tracks: Highly Rated rail and per-card star display, with cache layer for ndListSongs (PR #443)', 'Tracks: Highly Rated rail and per-card star display, with cache layer for ndListSongs (PR #443)',
'Random Mix: playlist-size picker (50/75/100/125/150) and filter-panel layout cleanup (PR #445)', 'Random Mix: playlist-size picker (50/75/100/125/150) and filter-panel layout cleanup (PR #445)',
'Queue: optional "Preserve Play Next order" toggle — multiple Play Next inserts queue up behind each other instead of latest-on-top (PR #464)',
], ],
}, },
] as const; ] as const;
@@ -2552,6 +2553,22 @@ export default function Settings() {
<span className="toggle-track" /> <span className="toggle-track" />
</label> </label>
</div> </div>
<div className="settings-toggle-row" style={{ marginTop: '0.75rem' }}>
<div>
<div style={{ fontWeight: 500 }}>
{t('settings.preservePlayNextOrder')}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.preservePlayNextOrderDesc')}
</div>
</div>
<label className="toggle-switch" aria-label={t('settings.preservePlayNextOrder')}>
<input type="checkbox" checked={auth.preservePlayNextOrder}
onChange={e => auth.setPreservePlayNextOrder(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
</div> </div>
</SettingsSubSection> </SettingsSubSection>
+4
View File
@@ -130,6 +130,7 @@ interface AuthState {
preloadMode: 'off' | 'balanced' | 'early' | 'custom'; preloadMode: 'off' | 'balanced' | 'early' | 'custom';
preloadCustomSeconds: number; preloadCustomSeconds: number;
infiniteQueueEnabled: boolean; infiniteQueueEnabled: boolean;
preservePlayNextOrder: boolean;
showArtistImages: boolean; showArtistImages: boolean;
showTrayIcon: boolean; showTrayIcon: boolean;
minimizeToTray: boolean; minimizeToTray: boolean;
@@ -307,6 +308,7 @@ interface AuthState {
setPreloadMode: (v: 'off' | 'balanced' | 'early' | 'custom') => void; setPreloadMode: (v: 'off' | 'balanced' | 'early' | 'custom') => void;
setPreloadCustomSeconds: (v: number) => void; setPreloadCustomSeconds: (v: number) => void;
setInfiniteQueueEnabled: (v: boolean) => void; setInfiniteQueueEnabled: (v: boolean) => void;
setPreservePlayNextOrder: (v: boolean) => void;
setShowArtistImages: (v: boolean) => void; setShowArtistImages: (v: boolean) => void;
setShowTrayIcon: (v: boolean) => void; setShowTrayIcon: (v: boolean) => void;
setMinimizeToTray: (v: boolean) => void; setMinimizeToTray: (v: boolean) => void;
@@ -444,6 +446,7 @@ export const useAuthStore = create<AuthState>()(
preloadMode: 'balanced', preloadMode: 'balanced',
preloadCustomSeconds: 30, preloadCustomSeconds: 30,
infiniteQueueEnabled: false, infiniteQueueEnabled: false,
preservePlayNextOrder: false,
showArtistImages: false, showArtistImages: false,
showTrayIcon: true, showTrayIcon: true,
minimizeToTray: false, minimizeToTray: false,
@@ -607,6 +610,7 @@ export const useAuthStore = create<AuthState>()(
setPreloadMode: (v: 'off' | 'balanced' | 'early' | 'custom') => set({ preloadMode: v }), setPreloadMode: (v: 'off' | 'balanced' | 'early' | 'custom') => set({ preloadMode: v }),
setPreloadCustomSeconds: (v: number) => set({ preloadCustomSeconds: v }), setPreloadCustomSeconds: (v: number) => set({ preloadCustomSeconds: v }),
setInfiniteQueueEnabled: (v) => set({ infiniteQueueEnabled: v }), setInfiniteQueueEnabled: (v) => set({ infiniteQueueEnabled: v }),
setPreservePlayNextOrder: (v) => set({ preservePlayNextOrder: v }),
setShowArtistImages: (v) => set({ showArtistImages: v }), setShowArtistImages: (v) => set({ showArtistImages: v }),
setShowTrayIcon: (v) => set({ showTrayIcon: v }), setShowTrayIcon: (v) => set({ showTrayIcon: v }),
setMinimizeToTray: (v) => set({ minimizeToTray: v }), setMinimizeToTray: (v) => set({ minimizeToTray: v }),
+27
View File
@@ -73,6 +73,10 @@ export interface Track {
size?: number; size?: number;
autoAdded?: boolean; autoAdded?: boolean;
radioAdded?: 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 { export function songToTrack(song: SubsonicSong): Track {
@@ -260,6 +264,12 @@ interface PlayerState {
setProgress: (t: number, duration: number) => void; setProgress: (t: number, duration: number) => void;
enqueue: (tracks: Track[], _orbitConfirmed?: boolean) => void; enqueue: (tracks: Track[], _orbitConfirmed?: boolean) => void;
enqueueAt: (tracks: Track[], insertIndex: number, _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; enqueueRadio: (tracks: Track[], artistId?: string) => void;
setRadioArtistId: (artistId: string) => void; setRadioArtistId: (artistId: string) => void;
/** For Lucky Mix: drop upcoming tail; keep the currently playing item only. */ /** 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: () => { clearQueue: () => {
invoke('audio_stop').catch(console.error); invoke('audio_stop').catch(console.error);
isAudioPaused = false; isAudioPaused = false;