feat(queue): switchable queue display mode (Queue vs Playlist) (#922)

* feat(queue): add queueDisplayMode setting with rehydrate (default queue)

* feat(queue): render upcoming-only or full timeline by mode, with header toggle

* feat(settings): add queue display mode toggle to Personalisation

* i18n: queue display mode strings across all locales

* test(queue): display-mode rendering and absolute index mapping

* docs: changelog + credits for queue display mode (#922)
This commit is contained in:
Frank Stellmacher
2026-05-30 00:26:07 +02:00
committed by GitHub
parent 7b06be5ba2
commit 1de2b0e850
30 changed files with 342 additions and 32 deletions
+63
View File
@@ -74,6 +74,9 @@ describe('QueuePanel — render surface', () => {
.mockImplementation(function (this: HTMLElement) {
return this.classList.contains('queue-list') ? 600 : 52;
});
// These characterize the full-queue rendering (one row per track), which is
// playlist mode. The default mode is 'queue' (upcoming-only), so pin it.
useAuthStore.getState().setQueueDisplayMode('playlist');
});
afterEach(() => offsetSpy.mockRestore());
@@ -106,6 +109,66 @@ describe('QueuePanel — render surface', () => {
});
});
describe('QueuePanel — display mode', () => {
// Same virtualizer layout shim as the render-surface block: jsdom has no
// layout, so give the scroll viewport a height and rows a fixed height.
let offsetSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
offsetSpy = vi
.spyOn(HTMLElement.prototype, 'offsetHeight', 'get')
.mockImplementation(function (this: HTMLElement) {
return this.classList.contains('queue-list') ? 600 : 52;
});
});
afterEach(() => offsetSpy.mockRestore());
it('playlist mode: header reads "Playlist", full queue renders, no "Next Tracks" divider', () => {
const tracks = makeTracks(4);
useAuthStore.getState().setQueueDisplayMode('playlist');
seedQueue(tracks, { index: 1, currentTrack: tracks[1] });
const { container } = renderWithProviders(<QueuePanel />);
expect(container.querySelector('.queue-header h2')?.textContent).toBe('Playlist');
const idxs = [...container.querySelectorAll('[data-queue-idx]')].map(r => r.getAttribute('data-queue-idx'));
expect(idxs).toEqual(['0', '1', '2', '3']);
expect(container.textContent).not.toContain('Next Tracks');
});
it('queue mode: header reads "Queue", only upcoming rows render with absolute indices + titles', () => {
const tracks = makeTracks(5);
useAuthStore.getState().setQueueDisplayMode('queue');
seedQueue(tracks, { index: 1, currentTrack: tracks[1] });
const { container } = renderWithProviders(<QueuePanel />);
expect(container.querySelector('.queue-header h2')?.textContent).toBe('Queue');
const rows = [...container.querySelectorAll<HTMLElement>('[data-queue-idx]')];
// Played (0) + current (1) are gone; only 2,3,4 remain, with absolute idx.
expect(rows.map(r => r.getAttribute('data-queue-idx'))).toEqual(['2', '3', '4']);
// The first displayed row maps to the absolute track at index 2, not 0.
expect(rows[0]?.textContent).toContain(tracks[2].title);
expect(container.textContent).toContain('Next Tracks');
});
it('queue mode with the current track last: shows the "no upcoming" empty state, no rows', () => {
const tracks = makeTracks(3);
useAuthStore.getState().setQueueDisplayMode('queue');
seedQueue(tracks, { index: 2, currentTrack: tracks[2] });
const { container } = renderWithProviders(<QueuePanel />);
expect(container.querySelectorAll('[data-queue-idx]').length).toBe(0);
expect(container.textContent).toContain('No upcoming tracks');
});
it('header mode-toggle button flips queueDisplayMode (default queue → playlist)', () => {
seedQueue(makeTracks(3), { index: 0, currentTrack: makeTrack() });
const { container } = renderWithProviders(<QueuePanel />);
// 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").
const toggle = container.querySelector<HTMLButtonElement>('.queue-header .queue-action-btn');
expect(toggle?.getAttribute('aria-label')).toBe('Playlist');
toggle!.click();
expect(useAuthStore.getState().queueDisplayMode).toBe('playlist');
});
});
describe('QueuePanel — toolbar', () => {
it('exposes Shuffle / Save Playlist / Load Playlist / Share Queue / Clear via aria-label', () => {
const tracks = makeTracks(3);
+45 -2
View File
@@ -1,3 +1,4 @@
import { Play } from 'lucide-react';
import { getPlaylist, updatePlaylist } from '../api/subsonicPlaylists';
import { songToTrack } from '../utils/playback/songToTrack';
import type { Track } from '../store/playerStoreTypes';
@@ -117,6 +118,8 @@ function QueuePanelHostOrSolo() {
const isNowPlayingCollapsed = useAuthStore(s => s.queueNowPlayingCollapsed);
const setIsNowPlayingCollapsed = useAuthStore(s => s.setQueueNowPlayingCollapsed);
const queueDisplayMode = useAuthStore(s => s.queueDisplayMode);
const setQueueDisplayMode = useAuthStore(s => s.setQueueDisplayMode);
const toolbarButtons = useQueueToolbarStore(s => s.buttons);
const durationMode = useAuthStore(s => s.queueDurationDisplayMode);
const setDurationMode = useAuthStore(s => s.setQueueDurationDisplayMode);
@@ -210,6 +213,19 @@ function QueuePanelHostOrSolo() {
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
};
// Queue mode shows upcoming tracks only — the current track lives in the
// header and drops out of the list once played. Playlist mode keeps the full
// timeline. `queueItems` stays the canonical list either way; the slice is a
// view. `displayBaseIndex` maps a displayed row back to its absolute queue
// index for every index-based handler (play / remove / reorder / drag).
const displayBaseIndex = queueDisplayMode === 'queue' ? Math.max(0, queueIndex + 1) : 0;
const displayItems = displayBaseIndex > 0 ? queueItems.slice(displayBaseIndex) : queueItems;
// In queue mode the list can be empty while the queue still holds the
// now-playing (last) track — say "no upcoming" rather than "queue is empty".
const queueEmptyLabel = queueDisplayMode === 'queue' && queueItems.length > 0
? t('queue.noUpcoming')
: t('queue.emptyQueue');
return (
<aside
ref={asideRef}
@@ -253,6 +269,8 @@ function QueuePanelHostOrSolo() {
setIsNowPlayingCollapsed={setIsNowPlayingCollapsed}
durationMode={durationMode}
setDurationMode={setDurationMode}
queueDisplayMode={queueDisplayMode}
setQueueDisplayMode={setQueueDisplayMode}
t={t}
/>
@@ -282,6 +300,25 @@ function QueuePanelHostOrSolo() {
/>
)}
{/* Queue mode hides the current track from the list, so a collapsed
now-playing card would leave nothing showing what's playing. This
slim strip fills that gap; clicking it re-expands the full card. */}
{currentTrack && isNowPlayingCollapsed && queueDisplayMode === 'queue' && (
<button
type="button"
className="queue-now-playing-mini"
onClick={() => setIsNowPlayingCollapsed(false)}
data-tooltip={t('queue.showNowPlaying')}
aria-label={t('queue.showNowPlaying')}
>
<Play size={11} fill="currentColor" style={{ flexShrink: 0 }} />
<span className="truncate" style={{ minWidth: 0 }}>
<span style={{ fontWeight: 600 }}>{currentTrack.title}</span>
<span style={{ color: 'var(--text-muted)' }}> · {currentTrack.artist}</span>
</span>
</button>
)}
{activeTab === 'queue' ? (<>
{!isNowPlayingCollapsed && toolbarButtons.some(b => b.visible && b.id !== 'separator') && (
<QueueToolbar
@@ -306,11 +343,17 @@ function QueuePanelHostOrSolo() {
/>
)}
{currentTrack && queueItems.length > 0 && <div className="queue-divider"><span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.nextTracks')}</span></div>}
{/* "Next Tracks" only labels the upcoming-only list in queue mode. In
playlist mode the list also holds the current + played rows, so the
divider would be misleading — hide it. */}
{queueDisplayMode === 'queue' && displayItems.length > 0 && <div className="queue-divider"><span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.nextTracks')}</span></div>}
<QueueList
queue={queueItems}
queue={displayItems}
queueIndex={queueIndex}
displayBaseIndex={displayBaseIndex}
queueDisplayMode={queueDisplayMode}
emptyLabel={queueEmptyLabel}
contextMenu={contextMenu}
playTrack={playTrack}
activeTab={activeTab}
+24 -3
View File
@@ -1,10 +1,11 @@
import { useDeferredValue, useMemo, useSyncExternalStore } from 'react';
import { ChevronDown, ListMusic } from 'lucide-react';
import { ChevronDown, ListMusic, ListOrdered } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { usePlayerStore } from '../../store/playerStore';
import { useAuthStore } from '../../store/authStore';
import type { QueueItemRef } from '../../store/playerStoreTypes';
import type { QueueDisplayMode } from '../../store/authStoreTypes';
import type { DurationMode } from '../../utils/componentHelpers/queuePanelHelpers';
import { formatLongDuration } from '../../utils/format/formatDuration';
import { formatClockTime } from '../../utils/format/formatClockTime';
@@ -22,12 +23,15 @@ interface Props {
setIsNowPlayingCollapsed: (v: boolean) => void;
durationMode: DurationMode;
setDurationMode: (m: DurationMode) => void;
queueDisplayMode: QueueDisplayMode;
setQueueDisplayMode: (v: QueueDisplayMode) => void;
t: TFunction;
}
export function QueueHeader({
queue, queueIndex, activePlaylist, isNowPlayingCollapsed,
setIsNowPlayingCollapsed, durationMode, setDurationMode, t,
setIsNowPlayingCollapsed, durationMode, setDurationMode,
queueDisplayMode, setQueueDisplayMode, t,
}: Props) {
const currentTime = usePlayerStore((s) => Math.floor(s.currentTime / 30) * 30);
const isPlaying = usePlayerStore((s) => s.isPlaying);
@@ -81,7 +85,24 @@ export function QueueHeader({
<div className="queue-header">
<div style={{ display: "flex", flexDirection: "column", minWidth: 0, flex: 1 }}>
<div style={{ display: "flex", alignItems: "baseline", gap: "8px", minWidth: 0 }}>
<h2 style={{ fontSize: "16px", fontWeight: 700, margin: 0, flexShrink: 0 }}>{t("queue.title")}</h2>
{/* Small icon button to flip the display mode without leaving the
panel. Icon reflects the active mode; the title (next to it) and
the tooltip name the modes. Mirrors the Settings toggle. */}
<button
type="button"
className="queue-action-btn"
onClick={() => setQueueDisplayMode(queueDisplayMode === 'playlist' ? 'queue' : 'playlist')}
data-tooltip={queueDisplayMode === 'playlist' ? t('queue.title') : t('queue.modePlaylist')}
aria-label={queueDisplayMode === 'playlist' ? t('queue.title') : t('queue.modePlaylist')}
style={{ width: 24, height: 24, alignSelf: 'center', flexShrink: 0 }}
>
{queueDisplayMode === 'playlist' ? <ListMusic size={15} /> : <ListOrdered size={15} />}
</button>
{/* Title doubles as the mode indicator so the panel reads "Playlist"
in playlist mode rather than the misleading "Queue". */}
<h2 style={{ fontSize: "16px", fontWeight: 700, margin: 0, flexShrink: 0 }}>
{queueDisplayMode === 'playlist' ? t('queue.modePlaylist') : t('queue.title')}
</h2>
{queue.length > 0 && (
<span style={{ fontSize: "13px", color: "var(--text-muted)", whiteSpace: "nowrap", userSelect: "none" }}>
({queueIndex + 1}/{queue.length})
+62 -26
View File
@@ -6,6 +6,7 @@ import OverlayScrollArea from '../OverlayScrollArea';
import { usePlayerStore } from '../../store/playerStore';
import { useLuckyMixStore } from '../../store/luckyMixStore';
import type { QueueItemRef, PlayerState } from '../../store/playerStoreTypes';
import type { QueueDisplayMode } from '../../store/authStoreTypes';
import { formatTrackTime } from '../../utils/format/formatDuration';
import { resolveQueueTrack } from '../../utils/library/queueTrackView';
import {
@@ -20,8 +21,19 @@ type StartDrag = (
) => void;
interface Props {
/** The rows to render. In queue mode this is the upcoming-only slice of the
* canonical queue; in playlist mode it is the full queue. */
queue: QueueItemRef[];
/** Absolute index of the currently playing track in the canonical queue. */
queueIndex: number;
/** Absolute index of `queue[0]` in the canonical queue (0 in playlist mode,
* `queueIndex + 1` in queue mode). Added to a row's local index to recover
* its absolute index for play / context-menu / drag / reorder. */
displayBaseIndex: number;
queueDisplayMode: QueueDisplayMode;
/** Label for the empty list (differs between "queue is empty" and "no
* upcoming tracks"). */
emptyLabel: string;
contextMenu: PlayerState['contextMenu'];
playTrack: PlayerState['playTrack'];
activeTab: string;
@@ -42,7 +54,8 @@ interface Props {
const INITIAL_RECT = { width: 0, height: 600 };
export function QueueList({
queue, queueIndex, contextMenu, playTrack, activeTab, queueListRef,
queue, queueIndex, displayBaseIndex, queueDisplayMode, emptyLabel,
contextMenu, playTrack, activeTab, queueListRef,
suppressNextAutoScrollRef, isQueueDrag, psyDragFromIdxRef, externalDropTarget,
startDrag, orbitAttributionLabel, luckyRolling, t,
}: Props) {
@@ -69,28 +82,47 @@ export function QueueList({
const virtualItems = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();
// Auto-scroll the upcoming track into view on track change. Replaces the
// scrollIntoView path in useQueueAutoScroll, which relied on every row being
// in the DOM. Honours the suppression flag (row click / undo restore).
// Auto-scroll on track change (and on mode toggle). Honours the suppression
// flag (row click / undo restore). Two-step where it scrolls: the virtualizer
// brings the target row into the rendered range (estimate-based, can land a
// few px off), then scrollIntoView snaps it flush at the top (exact).
useEffect(() => {
if (suppressNextAutoScrollRef.current) {
suppressNextAutoScrollRef.current = false;
return;
}
if (activeTab !== 'queue' || queueIndex < 0 || queue.length === 0) return;
const target = Math.min(queueIndex + 1, queue.length - 1);
// First bring the target row into the rendered range via the virtualizer
// (estimate-based, so it can land a few px off). Then snap to the real DOM
// element so it sits flush at the top — no sliver of the now-playing row,
// which already shows in the header. scrollIntoView is exact (no estimate).
rowVirtualizer.scrollToIndex(target, { align: 'start' });
const id = requestAnimationFrame(() => {
const el = queueListRef.current?.querySelector<HTMLElement>(`[data-queue-idx="${target}"]`);
el?.scrollIntoView({ block: 'start', behavior: 'instant' });
});
return () => cancelAnimationFrame(id);
const pinToTop = (localIndex: number, absIndex: number) => {
rowVirtualizer.scrollToIndex(localIndex, { align: 'start' });
const id = requestAnimationFrame(() => {
const el = queueListRef.current?.querySelector<HTMLElement>(`[data-queue-idx="${absIndex}"]`);
el?.scrollIntoView({ block: 'start', behavior: 'instant' });
});
return () => cancelAnimationFrame(id);
};
if (queueDisplayMode === 'queue') {
// Upcoming-only: the next track is the first row — keep it pinned at the
// top. The played track already dropped out of the slice.
return pinToTop(0, displayBaseIndex);
}
// 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;
if (viewport) {
const rowEl = viewport.querySelector<HTMLElement>(`[data-queue-idx="${queueIndex}"]`);
if (rowEl) {
const rowRect = rowEl.getBoundingClientRect();
const viewRect = viewport.getBoundingClientRect();
const fullyVisible = rowRect.top >= viewRect.top && rowRect.bottom <= viewRect.bottom;
if (fullyVisible) return; // highlight just moved within view — don't yank
}
}
return pinToTop(queueIndex, queueIndex);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [queueIndex, activeTab]);
}, [queueIndex, activeTab, queueDisplayMode]);
return (
<OverlayScrollArea
@@ -103,22 +135,26 @@ export function QueueList({
>
{queue.length === 0 ? (
<div className="queue-empty">
{t('queue.emptyQueue')}
{emptyLabel}
</div>
) : (
<div style={{ height: totalSize, width: '100%', position: 'relative' }}>
{virtualItems.map(vi => {
const idx = vi.index;
// Local index addresses `queue` (the displayed slice); the absolute
// index addresses the canonical queue and drives every handler that
// mutates / selects (play, context menu, drag, reorder, drop target).
const absIdx = displayBaseIndex + idx;
const base = queue[idx];
const track = resolveQueueTrack(base);
const isPlaying = idx === queueIndex;
const isPlaying = absIdx === queueIndex;
const isFirstAutoAdded = base.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded);
const isFirstRadioAdded = base.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded);
let dragStyle: React.CSSProperties = {};
if (isQueueDrag && psyDragFromIdxRef.current === idx) {
if (isQueueDrag && psyDragFromIdxRef.current === absIdx) {
dragStyle = { opacity: 0.4, background: 'var(--bg-hover)' };
} else if (isQueueDrag && externalDropTarget?.idx === idx) {
} else if (isQueueDrag && externalDropTarget?.idx === absIdx) {
if (externalDropTarget.before) {
dragStyle = { borderTop: '2px solid var(--accent)', paddingTop: '6px', marginTop: '-2px' };
} else {
@@ -144,18 +180,18 @@ export function QueueList({
</div>
)}
<div
data-queue-idx={idx}
className={`queue-item ${isPlaying ? 'active' : ''} ${contextMenu.isOpen && contextMenu.type === 'queue-item' && contextMenu.queueIndex === idx ? 'context-active' : ''}`}
data-queue-idx={absIdx}
className={`queue-item ${isPlaying ? 'active' : ''} ${contextMenu.isOpen && contextMenu.type === 'queue-item' && contextMenu.queueIndex === absIdx ? 'context-active' : ''}`}
onClick={() => {
suppressNextAutoScrollRef.current = true;
// Same-queue jump: undefined keeps the canonical refs; the row
// index lands a click on a duplicate track on *this* slot, not
// the first occurrence (issue #500).
playTrack(track, undefined, undefined, undefined, idx);
playTrack(track, undefined, undefined, undefined, absIdx);
}}
onContextMenu={(e) => {
e.preventDefault();
usePlayerStore.getState().openContextMenu(e.clientX, e.clientY, track, 'queue-item', idx);
usePlayerStore.getState().openContextMenu(e.clientX, e.clientY, track, 'queue-item', absIdx);
}}
onMouseDown={(e) => {
if (e.button !== 0) return;
@@ -166,8 +202,8 @@ export function QueueList({
if (Math.abs(me.clientX - startX) > 5 || Math.abs(me.clientY - startY) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
psyDragFromIdxRef.current = idx;
startDrag({ data: JSON.stringify({ type: 'queue_reorder', index: idx }), label: track.title }, me.clientX, me.clientY);
psyDragFromIdxRef.current = absIdx;
startDrag({ data: JSON.stringify({ type: 'queue_reorder', index: absIdx }), label: track.title }, me.clientX, me.clientY);
}
};
const onUp = () => {
+44 -1
View File
@@ -1,6 +1,7 @@
import { useTranslation } from 'react-i18next';
import { Disc3, LayoutGrid, ListMusic, ListTodo, PanelLeft, RotateCcw, Users } from 'lucide-react';
import { Disc3, LayoutGrid, ListMusic, ListOrdered, ListTodo, PanelLeft, RotateCcw, Users } from 'lucide-react';
import { useArtistLayoutStore } from '../../store/artistLayoutStore';
import { useAuthStore } from '../../store/authStore';
import { useHomeStore } from '../../store/homeStore';
import { usePlayerBarLayoutStore } from '../../store/playerBarLayoutStore';
import { usePlaylistLayoutStore } from '../../store/playlistLayoutStore';
@@ -16,6 +17,8 @@ import { SidebarCustomizer } from './SidebarCustomizer';
export function PersonalisationTab() {
const { t } = useTranslation();
const queueDisplayMode = useAuthStore(s => s.queueDisplayMode);
const setQueueDisplayMode = useAuthStore(s => s.setQueueDisplayMode);
return (
<>
<SettingsSubSection
@@ -76,6 +79,46 @@ export function PersonalisationTab() {
<ArtistLayoutCustomizer />
</SettingsSubSection>
<SettingsSubSection
title={t('settings.queueModeTitle')}
icon={<ListOrdered size={16} />}
>
<div className="settings-card">
{/* Two mutually exclusive modes — exactly one is always active, so
turning one on turns the other off; the active one cannot be
switched off directly (ignore the uncheck). */}
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('queue.title')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.queueModeQueueSub')}</div>
</div>
<label className="toggle-switch" aria-label={t('queue.title')}>
<input
type="checkbox"
checked={queueDisplayMode === 'queue'}
onChange={e => { if (e.target.checked) setQueueDisplayMode('queue'); }}
/>
<span className="toggle-track" />
</label>
</div>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('queue.modePlaylist')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.queueModePlaylistSub')}</div>
</div>
<label className="toggle-switch" aria-label={t('queue.modePlaylist')}>
<input
type="checkbox"
checked={queueDisplayMode === 'playlist'}
onChange={e => { if (e.target.checked) setQueueDisplayMode('playlist'); }}
/>
<span className="toggle-track" />
</label>
</div>
</div>
</SettingsSubSection>
<SettingsSubSection
title={t('settings.queueToolbarTitle')}
icon={<ListMusic size={16} />}