mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
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:
committed by
GitHub
parent
e1f2cb4c37
commit
0fab2849e5
@@ -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() {
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => playTrack(song, [song]))}>
|
||||
<Play size={14} /> {t('contextMenu.playNow')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => 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 });
|
||||
})}>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => playNext([song]))}>
|
||||
<ChevronsRight size={14} /> {t('contextMenu.playNext')}
|
||||
</div>
|
||||
<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]))}>
|
||||
<Play size={14} /> {t('contextMenu.playNow')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => 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 });
|
||||
})}>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => playNext([song]))}>
|
||||
<ChevronsRight size={14} /> {t('contextMenu.playNext')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => 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);
|
||||
})}>
|
||||
<ChevronsRight size={14} /> {t('contextMenu.playNext')}
|
||||
</div>
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -930,6 +930,8 @@ export const ruTranslation = {
|
||||
notWithCrossfade: 'Недоступно при включённом кроссфейде',
|
||||
gapless: 'Без пауз между треками',
|
||||
gaplessDesc: 'Заранее подгружать следующий трек, чтобы не было тишины',
|
||||
preservePlayNextOrder: 'Сохранять порядок «Играть следующим»',
|
||||
preservePlayNextOrderDesc: 'Новые элементы «Играть следующим» становятся в конец очереди, а не лезут вперёд.',
|
||||
trackPreviewsTitle: 'Превью треков',
|
||||
trackPreviewsToggle: 'Включить превью треков',
|
||||
trackPreviewsDesc: 'Показывать встроенные кнопки воспроизведения и превью в списках треков для быстрого прослушивания фрагмента.',
|
||||
|
||||
@@ -873,6 +873,8 @@ export const zhTranslation = {
|
||||
notWithCrossfade: '交叉淡入淡出开启时不可用',
|
||||
gapless: '无缝播放',
|
||||
gaplessDesc: '预缓冲下一首曲目以消除歌曲间的间隙',
|
||||
preservePlayNextOrder: '保留"下一首播放"顺序',
|
||||
preservePlayNextOrderDesc: '新添加的"下一首播放"项目排在现有项目之后,而不是插到前面。',
|
||||
trackPreviewsTitle: '曲目预览',
|
||||
trackPreviewsToggle: '启用曲目预览',
|
||||
trackPreviewsDesc: '在曲目列表中显示内联播放与预览按钮,可快速试听歌曲中段。',
|
||||
|
||||
@@ -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() {
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</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>
|
||||
</SettingsSubSection>
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user