From 1c233058877cd6959dccad6d6bec44e45c6ffa6e Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:19:20 +0200 Subject: [PATCH] feat(queue): add Timeline display mode (#1004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(queue): add Timeline display mode A third queue display mode alongside Queue and Playlist. Timeline shows the full queue anchored on the current track — played history above (dimmed), upcoming below — with 'History' and 'Up next' dividers and the current row auto-centered. It already follows shuffle order since the queue is stored in play order. The header mode button now cycles queue → timeline → playlist; Settings gains a third option. * i18n(queue): Timeline mode strings (9 locales) * docs(changelog): note Timeline queue mode (#1004) --- CHANGELOG.md | 9 +++++++ src/components/QueuePanel.test.tsx | 10 +++---- src/components/queuePanel/QueueHeader.tsx | 26 +++++++++++++------ src/components/queuePanel/QueueList.tsx | 24 ++++++++++++++++- .../settings/PersonalisationTab.tsx | 19 ++++++++++++-- src/locales/de/queue.ts | 3 +++ src/locales/de/settings.ts | 1 + src/locales/en/queue.ts | 3 +++ src/locales/en/settings.ts | 1 + src/locales/es/queue.ts | 3 +++ src/locales/es/settings.ts | 1 + src/locales/fr/queue.ts | 3 +++ src/locales/fr/settings.ts | 1 + src/locales/nb/queue.ts | 3 +++ src/locales/nb/settings.ts | 1 + src/locales/nl/queue.ts | 3 +++ src/locales/nl/settings.ts | 1 + src/locales/ro/queue.ts | 3 +++ src/locales/ro/settings.ts | 1 + src/locales/ru/queue.ts | 3 +++ src/locales/ru/settings.ts | 1 + src/locales/zh/queue.ts | 3 +++ src/locales/zh/settings.ts | 1 + src/store/authStoreRehydrate.ts | 2 +- src/store/authStoreTypes.ts | 6 +++-- 25 files changed, 113 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d34d48e0..9aa200fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Queue — Timeline display mode + +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1004](https://github.com/Psychotoxical/psysonic/pull/1004), suggested by [@Legislate3030](https://github.com/Legislate3030)** + +* New third queue display mode (cycle the header button, or pick it in **Settings → Personalisation → Queue display**). Timeline keeps the current track centered with played history above and upcoming tracks below — both visible at once — so it's easy to follow playback and jump back to earlier songs. +* The up-next order respects shuffle, and a "History" / "Up next" divider marks the boundary. + + + ## Changed ### Dependencies — npm and Rust refresh diff --git a/src/components/QueuePanel.test.tsx b/src/components/QueuePanel.test.tsx index 706aaa7f..e42a9e18 100644 --- a/src/components/QueuePanel.test.tsx +++ b/src/components/QueuePanel.test.tsx @@ -156,16 +156,16 @@ describe('QueuePanel — display mode', () => { expect(container.textContent).toContain('No upcoming tracks'); }); - it('header mode-toggle button flips queueDisplayMode (default queue → playlist)', () => { + it('header mode-toggle button advances queueDisplayMode (default queue → timeline)', () => { seedQueue(makeTracks(3), { index: 0, currentTrack: makeTrack() }); const { container } = renderWithProviders(); // The mode toggle is the first .queue-action-btn in the header (the - // collapse chevron is the second). The default mode is 'queue', so the - // toggle's label names its target ("Playlist"). + // collapse chevron is the second). The toggle's label names its target; + // from the default 'queue' that is the next mode in the cycle, "Timeline". const toggle = container.querySelector('.queue-header .queue-action-btn'); - expect(toggle?.getAttribute('aria-label')).toBe('Playlist'); + expect(toggle?.getAttribute('aria-label')).toBe('Timeline'); toggle!.click(); - expect(useAuthStore.getState().queueDisplayMode).toBe('playlist'); + expect(useAuthStore.getState().queueDisplayMode).toBe('timeline'); }); }); diff --git a/src/components/queuePanel/QueueHeader.tsx b/src/components/queuePanel/QueueHeader.tsx index 60050cdf..10014966 100644 --- a/src/components/queuePanel/QueueHeader.tsx +++ b/src/components/queuePanel/QueueHeader.tsx @@ -1,5 +1,5 @@ import { useDeferredValue, useMemo, useSyncExternalStore } from 'react'; -import { ChevronDown, ListMusic, ListOrdered } from 'lucide-react'; +import { AlignCenterVertical, ChevronDown, ListMusic, ListOrdered } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; import { usePlayerStore } from '../../store/playerStore'; @@ -81,6 +81,16 @@ export function QueueHeader({ const isEta = durationMode === 'eta'; + // Cycle the panel through the three render modes; icon + title show the active + // one, tooltip names the one a click switches to. + const DISPLAY_MODE_CYCLE: QueueDisplayMode[] = ['queue', 'timeline', 'playlist']; + const nextDisplayMode = + DISPLAY_MODE_CYCLE[(DISPLAY_MODE_CYCLE.indexOf(queueDisplayMode) + 1) % DISPLAY_MODE_CYCLE.length]; + const displayModeLabel = (m: QueueDisplayMode) => + m === 'playlist' ? t('queue.modePlaylist') : m === 'timeline' ? t('queue.modeTimeline') : t('queue.title'); + const displayModeIcon = (m: QueueDisplayMode) => + m === 'playlist' ? : m === 'timeline' ? : ; + return (
@@ -91,17 +101,17 @@ export function QueueHeader({ - {/* Title doubles as the mode indicator so the panel reads "Playlist" - in playlist mode rather than the misleading "Queue". */} + {/* Title doubles as the mode indicator so the panel names the active + mode rather than always reading "Queue". */}

- {queueDisplayMode === 'playlist' ? t('queue.modePlaylist') : t('queue.title')} + {displayModeLabel(queueDisplayMode)}

{queue.length > 0 && ( diff --git a/src/components/queuePanel/QueueList.tsx b/src/components/queuePanel/QueueList.tsx index 0c3503c5..c54799a0 100644 --- a/src/components/queuePanel/QueueList.tsx +++ b/src/components/queuePanel/QueueList.tsx @@ -108,6 +108,16 @@ export function QueueList({ return pinToTop(0, displayBaseIndex); } + if (queueDisplayMode === 'timeline') { + // Anchor the current track in the middle — history above, up-next below. + rowVirtualizer.scrollToIndex(queueIndex, { align: 'center' }); + const id = requestAnimationFrame(() => { + const el = queueListRef.current?.querySelector(`[data-queue-idx="${queueIndex}"]`); + el?.scrollIntoView({ block: 'center', behavior: 'instant' }); + }); + return () => cancelAnimationFrame(id); + } + // Playlist: lazy. Let the highlight wander while the now-playing row stays // visible; only re-pin it to the top once it has scrolled out of view. const viewport = queueListRef.current; @@ -148,6 +158,8 @@ export function QueueList({ const base = queue[idx]; const track = resolveQueueTrack(base); const isPlaying = absIdx === queueIndex; + const isTimeline = queueDisplayMode === 'timeline'; + const isPast = isTimeline && absIdx < queueIndex; const isFirstAutoAdded = base.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded); const isFirstRadioAdded = base.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded); @@ -169,6 +181,16 @@ export function QueueList({ ref={rowVirtualizer.measureElement} style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)` }} > + {isTimeline && idx === 0 && queueIndex > 0 && ( +
+ {t('queue.history')} +
+ )} + {isTimeline && absIdx === queueIndex + 1 && ( +
+ {t('queue.upNext')} +
+ )} {isFirstRadioAdded && (
{t('queue.radioAdded')} @@ -213,7 +235,7 @@ export function QueueList({ document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }} - style={dragStyle} + style={{ ...(isPast && !isPlaying ? { opacity: 0.5 } : null), ...dragStyle }} >
diff --git a/src/components/settings/PersonalisationTab.tsx b/src/components/settings/PersonalisationTab.tsx index bc7dedaf..58c87926 100644 --- a/src/components/settings/PersonalisationTab.tsx +++ b/src/components/settings/PersonalisationTab.tsx @@ -84,8 +84,8 @@ export function PersonalisationTab() { icon={} >
- {/* Two mutually exclusive modes — exactly one is always active, so - turning one on turns the other off; the active one cannot be + {/* Three mutually exclusive modes — exactly one is always active, so + turning one on turns the others off; the active one cannot be switched off directly (ignore the uncheck). */}
@@ -116,6 +116,21 @@ export function PersonalisationTab() {
+
+
+
+
{t('queue.modeTimeline')}
+
{t('settings.queueModeTimelineSub')}
+
+ +
diff --git a/src/locales/de/queue.ts b/src/locales/de/queue.ts index a6183e48..b45b7718 100644 --- a/src/locales/de/queue.ts +++ b/src/locales/de/queue.ts @@ -1,6 +1,9 @@ export const queue = { title: 'Warteschlange', modePlaylist: 'Playlist', + modeTimeline: 'Zeitachse', + upNext: 'Als Nächstes', + history: 'Verlauf', noUpcoming: 'Keine weiteren Titel.', savePlaylist: 'Playlist speichern', updatePlaylist: 'Playlist aktualisieren', diff --git a/src/locales/de/settings.ts b/src/locales/de/settings.ts index d9524654..82210ee1 100644 --- a/src/locales/de/settings.ts +++ b/src/locales/de/settings.ts @@ -330,6 +330,7 @@ export const settings = { queueModeTitle: 'Warteschlangen-Ansicht', queueModeQueueSub: 'Zeigt nur kommende Titel. Der laufende Titel bleibt im Kopf und verschwindet aus der Liste, sobald er gespielt wurde.', queueModePlaylistSub: 'Behält die ganze Warteschlange in der Liste, der laufende Titel oben hervorgehoben; gespielte Titel bleiben stehen.', + queueModeTimelineSub: 'Aktuellen Titel zentrieren — gespielter Verlauf darüber, kommende Titel (in Shuffle-Reihenfolge) darunter.', queueToolbarTitle: 'Warteschlangen-Toolbar', queueToolbarReset: 'Zurücksetzen', queueToolbarSeparator: 'Trennlinie', diff --git a/src/locales/en/queue.ts b/src/locales/en/queue.ts index 2ffdf47d..3e13ae26 100644 --- a/src/locales/en/queue.ts +++ b/src/locales/en/queue.ts @@ -1,6 +1,9 @@ export const queue = { title: 'Queue', modePlaylist: 'Playlist', + modeTimeline: 'Timeline', + upNext: 'Up next', + history: 'History', noUpcoming: 'No upcoming tracks.', savePlaylist: 'Save Playlist', updatePlaylist: 'Update Playlist', diff --git a/src/locales/en/settings.ts b/src/locales/en/settings.ts index 5195f8dd..88f4542d 100644 --- a/src/locales/en/settings.ts +++ b/src/locales/en/settings.ts @@ -394,6 +394,7 @@ export const settings = { queueModeTitle: 'Queue Display Mode', queueModeQueueSub: 'Show only upcoming tracks. The current track stays in the header and leaves the list once it has played.', queueModePlaylistSub: 'Keep the whole queue in the list with the current track highlighted at the top; played tracks stay.', + queueModeTimelineSub: 'Center the current track, with played history above and upcoming tracks (in shuffle order) below.', queueToolbarTitle: 'Queue Toolbar', queueToolbarReset: 'Reset to default', queueToolbarSeparator: 'Separator', diff --git a/src/locales/es/queue.ts b/src/locales/es/queue.ts index 5be8bed9..4f95f29b 100644 --- a/src/locales/es/queue.ts +++ b/src/locales/es/queue.ts @@ -1,6 +1,9 @@ export const queue = { title: 'Cola', modePlaylist: 'Lista', + modeTimeline: 'Cronología', + upNext: 'A continuación', + history: 'Historial', noUpcoming: 'No hay próximas pistas.', savePlaylist: 'Guardar Lista', updatePlaylist: 'Actualizar Lista', diff --git a/src/locales/es/settings.ts b/src/locales/es/settings.ts index 03e16c79..00a9c5ad 100644 --- a/src/locales/es/settings.ts +++ b/src/locales/es/settings.ts @@ -329,6 +329,7 @@ export const settings = { queueModeTitle: 'Modo de visualización de la cola', queueModeQueueSub: 'Muestra solo las próximas pistas. La pista actual permanece en el encabezado y sale de la lista al reproducirse.', queueModePlaylistSub: 'Mantiene toda la cola en la lista, con la pista actual resaltada arriba; las reproducidas permanecen.', + queueModeTimelineSub: 'Centrar la pista actual, con el historial reproducido arriba y las próximas pistas (en orden aleatorio) debajo.', queueToolbarTitle: 'Barra de herramientas de cola', queueToolbarReset: 'Restablecer a predeterminado', queueToolbarSeparator: 'Separador', diff --git a/src/locales/fr/queue.ts b/src/locales/fr/queue.ts index 520508c1..11e40f3b 100644 --- a/src/locales/fr/queue.ts +++ b/src/locales/fr/queue.ts @@ -1,6 +1,9 @@ export const queue = { title: 'File d\'attente', modePlaylist: 'Liste de lecture', + modeTimeline: 'Chronologie', + upNext: 'À suivre', + history: 'Historique', noUpcoming: 'Aucune piste à venir.', savePlaylist: 'Enregistrer la liste', updatePlaylist: 'Mettre à jour la liste', diff --git a/src/locales/fr/settings.ts b/src/locales/fr/settings.ts index c64f1732..463a0269 100644 --- a/src/locales/fr/settings.ts +++ b/src/locales/fr/settings.ts @@ -327,6 +327,7 @@ export const settings = { queueModeTitle: 'Mode d\'affichage de la file', queueModeQueueSub: 'N\'affiche que les pistes à venir. La piste en cours reste dans l\'en-tête et quitte la liste une fois jouée.', queueModePlaylistSub: 'Conserve toute la file dans la liste, la piste en cours surlignée en haut ; les pistes jouées restent.', + queueModeTimelineSub: 'Centrer le morceau en cours, avec l\'historique au-dessus et les morceaux à venir (dans l\'ordre aléatoire) en dessous.', queueToolbarTitle: 'Barre d\'outils de file d\'attente', queueToolbarReset: 'Réinitialiser', queueToolbarSeparator: 'Séparateur', diff --git a/src/locales/nb/queue.ts b/src/locales/nb/queue.ts index b41a9d06..f73c3ed4 100644 --- a/src/locales/nb/queue.ts +++ b/src/locales/nb/queue.ts @@ -1,6 +1,9 @@ export const queue = { title: 'Kø', modePlaylist: 'Spilleliste', + modeTimeline: 'Tidslinje', + upNext: 'Neste', + history: 'Historikk', noUpcoming: 'Ingen kommende spor.', savePlaylist: 'Lagre spilleliste', updatePlaylist: 'Oppdater spilleliste', diff --git a/src/locales/nb/settings.ts b/src/locales/nb/settings.ts index 0d63b32c..1eb07481 100644 --- a/src/locales/nb/settings.ts +++ b/src/locales/nb/settings.ts @@ -326,6 +326,7 @@ export const settings = { queueModeTitle: 'Visningsmodus for kø', queueModeQueueSub: 'Viser bare kommende spor. Det gjeldende sporet blir i toppen og forsvinner fra listen når det er spilt.', queueModePlaylistSub: 'Beholder hele køen i listen med gjeldende spor uthevet øverst; avspilte spor blir værende.', + queueModeTimelineSub: 'Sentrer gjeldende spor, med avspilt historikk over og kommende spor (i shuffle-rekkefølge) under.', queueToolbarTitle: 'Kø-verktøylinje', queueToolbarReset: 'Tilbakestill til standard', queueToolbarSeparator: 'Skilje', diff --git a/src/locales/nl/queue.ts b/src/locales/nl/queue.ts index a5f4fb31..7fde0880 100644 --- a/src/locales/nl/queue.ts +++ b/src/locales/nl/queue.ts @@ -1,6 +1,9 @@ export const queue = { title: 'Wachtrij', modePlaylist: 'Afspeellijst', + modeTimeline: 'Tijdlijn', + upNext: 'Hierna', + history: 'Geschiedenis', noUpcoming: 'Geen volgende nummers.', savePlaylist: 'Afspeellijst opslaan', updatePlaylist: 'Afspeellijst bijwerken', diff --git a/src/locales/nl/settings.ts b/src/locales/nl/settings.ts index 505ab52c..4459ec0b 100644 --- a/src/locales/nl/settings.ts +++ b/src/locales/nl/settings.ts @@ -327,6 +327,7 @@ export const settings = { queueModeTitle: 'Weergavemodus wachtrij', queueModeQueueSub: 'Toont alleen komende nummers. Het huidige nummer blijft in de kop en verdwijnt uit de lijst zodra het is afgespeeld.', queueModePlaylistSub: 'Houdt de hele wachtrij in de lijst met het huidige nummer bovenaan gemarkeerd; afgespeelde nummers blijven staan.', + queueModeTimelineSub: 'Centreer de huidige track, met de afgespeelde geschiedenis erboven en de komende tracks (in shuffle-volgorde) eronder.', queueToolbarTitle: 'Wachtrij-werkbalk', queueToolbarReset: 'Standaard herstellen', queueToolbarSeparator: 'Scheiding', diff --git a/src/locales/ro/queue.ts b/src/locales/ro/queue.ts index b4814208..6b34e00c 100644 --- a/src/locales/ro/queue.ts +++ b/src/locales/ro/queue.ts @@ -1,6 +1,9 @@ export const queue = { title: 'Coadă', modePlaylist: 'Playlist', + modeTimeline: 'Cronologie', + upNext: 'Urmează', + history: 'Istoric', noUpcoming: 'Nicio piesă în continuare.', savePlaylist: 'Salvează Playlist', updatePlaylist: 'Actualizează Playlist', diff --git a/src/locales/ro/settings.ts b/src/locales/ro/settings.ts index fa8069f7..a5083399 100644 --- a/src/locales/ro/settings.ts +++ b/src/locales/ro/settings.ts @@ -332,6 +332,7 @@ export const settings = { queueModeTitle: 'Mod de afișare a cozii', queueModeQueueSub: 'Arată doar piesele următoare. Piesa curentă rămâne în antet și iese din listă după ce a fost redată.', queueModePlaylistSub: 'Păstrează toată coada în listă, cu piesa curentă evidențiată sus; piesele redate rămân.', + queueModeTimelineSub: 'Centrează piesa curentă, cu istoricul redat deasupra și piesele următoare (în ordine aleatorie) dedesubt.', queueToolbarTitle: 'Toolbar Coadă', queueToolbarReset: 'Resetează la implicit', queueToolbarSeparator: 'Separator', diff --git a/src/locales/ru/queue.ts b/src/locales/ru/queue.ts index 4b71128a..8be3c273 100644 --- a/src/locales/ru/queue.ts +++ b/src/locales/ru/queue.ts @@ -1,6 +1,9 @@ export const queue = { title: 'Очередь', modePlaylist: 'Плейлист', + modeTimeline: 'Хронология', + upNext: 'Далее', + history: 'История', noUpcoming: 'Нет следующих треков.', savePlaylist: 'Сохранить как плейлист', updatePlaylist: 'Обновить плейлист', diff --git a/src/locales/ru/settings.ts b/src/locales/ru/settings.ts index 05bfb510..7e942400 100644 --- a/src/locales/ru/settings.ts +++ b/src/locales/ru/settings.ts @@ -408,6 +408,7 @@ export const settings = { queueModeTitle: 'Режим отображения очереди', queueModeQueueSub: 'Показывает только предстоящие треки. Текущий трек остаётся в заголовке и исчезает из списка после воспроизведения.', queueModePlaylistSub: 'Сохраняет всю очередь в списке, текущий трек выделен вверху; воспроизведённые треки остаются.', + queueModeTimelineSub: 'Центрировать текущий трек: история воспроизведения сверху, предстоящие треки (в порядке перемешивания) снизу.', queueToolbarTitle: 'Панель инструментов очереди', queueToolbarReset: 'Сбросить', queueToolbarSeparator: 'Разделитель', diff --git a/src/locales/zh/queue.ts b/src/locales/zh/queue.ts index 45a2826b..a6aea2f5 100644 --- a/src/locales/zh/queue.ts +++ b/src/locales/zh/queue.ts @@ -1,6 +1,9 @@ export const queue = { title: '队列', modePlaylist: '播放列表', + modeTimeline: '时间轴', + upNext: '即将播放', + history: '历史', noUpcoming: '没有后续曲目。', savePlaylist: '保存为播放列表', updatePlaylist: '更新播放列表', diff --git a/src/locales/zh/settings.ts b/src/locales/zh/settings.ts index 30311b30..e9467b8a 100644 --- a/src/locales/zh/settings.ts +++ b/src/locales/zh/settings.ts @@ -326,6 +326,7 @@ export const settings = { queueModeTitle: '队列显示模式', queueModeQueueSub: '仅显示即将播放的曲目。当前曲目保留在标题栏中,播放完毕后从列表移除。', queueModePlaylistSub: '在列表中保留整个队列,当前曲目在顶部高亮显示;已播放的曲目保留。', + queueModeTimelineSub: '将当前曲目居中显示,已播放的历史在上方,即将播放的曲目(按随机顺序)在下方。', queueToolbarTitle: '队列工具栏', queueToolbarReset: '重置为默认', queueToolbarSeparator: '分隔符', diff --git a/src/store/authStoreRehydrate.ts b/src/store/authStoreRehydrate.ts index 1328f569..b802e621 100644 --- a/src/store/authStoreRehydrate.ts +++ b/src/store/authStoreRehydrate.ts @@ -106,7 +106,7 @@ export function computeAuthStoreRehydration(state: AuthState): Partial(['playlist', 'queue']); + const VALID_QUEUE_DISPLAY_MODES = new Set(['playlist', 'queue', 'timeline']); const queueDisplayModeMigrated = VALID_QUEUE_DISPLAY_MODES.has( (state as { queueDisplayMode?: unknown }).queueDisplayMode as string, ) diff --git a/src/store/authStoreTypes.ts b/src/store/authStoreTypes.ts index fda8b0f7..2df1274b 100644 --- a/src/store/authStoreTypes.ts +++ b/src/store/authStoreTypes.ts @@ -42,7 +42,7 @@ export type DurationMode = 'total' | 'remaining' | 'eta'; * - `queue`: the list shows upcoming tracks only — the current track lives in * the header and drops out of the list once it has played. */ -export type QueueDisplayMode = 'playlist' | 'queue'; +export type QueueDisplayMode = 'playlist' | 'queue' | 'timeline'; export type LoggingMode = 'off' | 'normal' | 'debug'; /** * Wall-clock format for ETA / sleep-timer labels. `'auto'` follows the user's @@ -185,7 +185,9 @@ export interface AuthState { queueNowPlayingCollapsed: boolean; /** Queue header duration chip mode (cycle: total → remaining → ETA). */ queueDurationDisplayMode: DurationMode; - /** Queue panel render mode: full timeline (`playlist`) vs. upcoming-only (`queue`). */ + /** Queue panel render mode: full list from top (`playlist`), upcoming-only + * (`queue`), or full list centered on the current track with history above + * and up-next below (`timeline`). */ queueDisplayMode: QueueDisplayMode; /** Alpha: native hi-res sample rate output (disabled = safe 44.1 kHz mode) */