diff --git a/CHANGELOG.md b/CHANGELOG.md index 986a03ac..320b73d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. * 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 ### Dependencies — npm / Cargo refresh and rodio 0.22 diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 32bb6d3d..a5729575 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -1019,12 +1019,13 @@ function MultiPlaylistToPlaylistSubmenu({ playlists, onDone, triggerId }: { play export default function ContextMenu() { const { t } = useTranslation(); 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 => ({ contextMenu: s.contextMenu, closeContextMenu: s.closeContextMenu, playTrack: s.playTrack, enqueue: s.enqueue, + playNext: s.playNext, queue: s.queue, currentTrack: s.currentTrack, removeTrack: s.removeTrack, @@ -1528,16 +1529,7 @@ export default function ContextMenu() {
handleAction(() => playTrack(song, [song]))}> {t('contextMenu.playNow')}
-
handleAction(() => { - if (!currentTrack) { - playTrack(song, [song]); - return; - } - const currentIdx = usePlayerStore.getState().queueIndex; - const newQueue = [...queue]; - newQueue.splice(currentIdx + 1, 0, song); - usePlayerStore.setState({ queue: newQueue }); - })}> +
handleAction(() => playNext([song]))}> {t('contextMenu.playNext')}
handleAction(() => enqueue([song]))}> @@ -1691,16 +1683,7 @@ export default function ContextMenu() {
handleAction(() => playTrack(song, [song]))}> {t('contextMenu.playNow')}
-
handleAction(() => { - if (!currentTrack) { - playTrack(song, [song]); - return; - } - const currentIdx = usePlayerStore.getState().queueIndex; - const newQueue = [...queue]; - newQueue.splice(currentIdx + 1, 0, song); - usePlayerStore.setState({ queue: newQueue }); - })}> +
handleAction(() => playNext([song]))}> {t('contextMenu.playNext')}
handleAction(() => enqueue([song]))}> @@ -1830,12 +1813,7 @@ export default function ContextMenu() { const albumData = await getAlbum(album.id); const tracks = albumData.songs.map(songToTrack); if (tracks.length === 0) return; - if (!currentTrack) { - playTrack(tracks[0], tracks); - return; - } - const currentIdx = usePlayerStore.getState().queueIndex; - usePlayerStore.getState().enqueueAt(tracks, currentIdx + 1); + playNext(tracks); })}> {t('contextMenu.playNext')}
diff --git a/src/locales/de.ts b/src/locales/de.ts index 918100db..899f2253 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -891,6 +891,8 @@ export const deTranslation = { notWithCrossfade: 'Nicht verfügbar wenn Crossfade aktiv ist', gapless: 'Nahtlose Wiedergabe', 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', trackPreviewsToggle: 'Track-Vorschau aktivieren', trackPreviewsDesc: 'Inline Play- und Vorschau-Buttons in den Tracklisten anzeigen für eine kurze Hörprobe mitten im Song.', diff --git a/src/locales/en.ts b/src/locales/en.ts index 5815d342..e470a649 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -897,6 +897,8 @@ export const enTranslation = { notWithCrossfade: 'Not available while Crossfade is active', gapless: 'Gapless Playback', 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', trackPreviewsToggle: 'Enable track previews', trackPreviewsDesc: 'Show inline Play and Preview buttons in tracklists for a quick mid-song sample.', diff --git a/src/locales/es.ts b/src/locales/es.ts index d72c11a8..d0c201e0 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -884,6 +884,8 @@ export const esTranslation = { notWithCrossfade: 'No disponible mientras Crossfade está activo', gapless: 'Reproducción Gapless', 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', 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.', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 9b8b1a31..f74da8c9 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -879,6 +879,8 @@ export const frTranslation = { notWithCrossfade: 'Non disponible quand le fondu enchaîné est actif', gapless: 'Lecture sans blanc', 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', 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.', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index f140cbcc..a5844b42 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -878,6 +878,8 @@ export const nbTranslation = { notWithCrossfade: 'Ikke tilgjengelig mens Crossfade er aktiv', gapless: 'Gapless avspilling', 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', trackPreviewsToggle: 'Aktiver sporforhåndsvisning', trackPreviewsDesc: 'Vis innebygde Spill- og Forhåndsvisning-knapper i sporlister for en kort smakebit fra midten av sangen.', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 873d5875..5169a723 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -878,6 +878,8 @@ export const nlTranslation = { notWithCrossfade: 'Niet beschikbaar als overgang actief is', gapless: 'Naadloos afspelen', 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', trackPreviewsToggle: 'Track-voorvertoning inschakelen', trackPreviewsDesc: 'Inline Play- en Voorvertoning-knoppen in trackslijsten tonen voor een korte sample midden in het nummer.', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 2fda4c18..fc0f9a7f 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -930,6 +930,8 @@ export const ruTranslation = { notWithCrossfade: 'Недоступно при включённом кроссфейде', gapless: 'Без пауз между треками', gaplessDesc: 'Заранее подгружать следующий трек, чтобы не было тишины', + preservePlayNextOrder: 'Сохранять порядок «Играть следующим»', + preservePlayNextOrderDesc: 'Новые элементы «Играть следующим» становятся в конец очереди, а не лезут вперёд.', trackPreviewsTitle: 'Превью треков', trackPreviewsToggle: 'Включить превью треков', trackPreviewsDesc: 'Показывать встроенные кнопки воспроизведения и превью в списках треков для быстрого прослушивания фрагмента.', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 10f0083d..7cd915fd 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -873,6 +873,8 @@ export const zhTranslation = { notWithCrossfade: '交叉淡入淡出开启时不可用', gapless: '无缝播放', gaplessDesc: '预缓冲下一首曲目以消除歌曲间的间隙', + preservePlayNextOrder: '保留"下一首播放"顺序', + preservePlayNextOrderDesc: '新添加的"下一首播放"项目排在现有项目之后,而不是插到前面。', trackPreviewsTitle: '曲目预览', trackPreviewsToggle: '启用曲目预览', trackPreviewsDesc: '在曲目列表中显示内联播放与预览按钮,可快速试听歌曲中段。', diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 5ac28c26..4c505987 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -360,6 +360,7 @@ const CONTRIBUTORS = [ '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)', '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; @@ -2552,6 +2553,22 @@ export default function Settings() {
+ +
+
+
+ {t('settings.preservePlayNextOrder')} +
+
+ {t('settings.preservePlayNextOrderDesc')} +
+
+ +
diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 1df00177..679e29ac 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -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()( preloadMode: 'balanced', preloadCustomSeconds: 30, infiniteQueueEnabled: false, + preservePlayNextOrder: false, showArtistImages: false, showTrayIcon: true, minimizeToTray: false, @@ -607,6 +610,7 @@ export const useAuthStore = create()( 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 }), diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index ac554be9..f011646f 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -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()( }); }, + 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;