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
+9
View File
@@ -170,6 +170,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Queue — choose between Queue and Playlist view
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#922](https://github.com/Psychotoxical/psysonic/pull/922)**
* **Settings → Personalisation → Queue Display Mode:** *Queue* shows only upcoming tracks — the current one stays in the header and leaves the list once played; *Playlist* keeps the full queue with the current track highlighted at the top. A small icon in the queue header flips the mode, and the title follows it.
* New default is *Queue* — switch to *Playlist* in settings if you prefer the full list with the playing track shown in place.
## Changed
### CI — hot-path coverage gates block merges
+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} />}
+1
View File
@@ -342,6 +342,7 @@ const CONTRIBUTOR_ENTRIES = [
'Library browse: lazy 200-row local catalog chunks, unified in-page scroll, cover priority during scroll (PR #890)',
'Performance Probe: Monitor/Toggles redesign, live CPU/RSS/thread metrics, overlay pins and sparklines, macOS snapshots (PR #890)',
'Performance Probe: opt-in thread-group CPU poll toggle and includeThreadGroups IPC fix (PR #891)',
'Queue: switchable display mode — Queue (upcoming only) vs Playlist (full list), with header toggle and settings entry (PR #922)',
],
},
{
+2
View File
@@ -1,5 +1,7 @@
export const queue = {
title: 'Warteschlange',
modePlaylist: 'Playlist',
noUpcoming: 'Keine weiteren Titel.',
savePlaylist: 'Playlist speichern',
updatePlaylist: 'Playlist aktualisieren',
filterPlaylists: 'Playlists filtern…',
+3
View File
@@ -327,6 +327,9 @@ export const settings = {
integrationsPrivacyTitle: 'Hinweis zum Datenschutz',
integrationsPrivacyBody: 'Alle Integrationen auf diesem Tab sind <strong>Opt-in</strong> und senden, sobald aktiviert, Daten an externe Dienste oder an deinen Navidrome-Server. Last.fm erhält deinen Wiedergabeverlauf, Discord zeigt den aktuell laufenden Track in deinem Profil an, Bandsintown wird pro Künstler für Tour-Daten abgefragt, und der „Jetzt läuft"-Hinweis veröffentlicht deinen aktuellen Titel für andere Benutzer deines Navidrome-Servers. Wer das nicht möchte, lässt den jeweiligen Abschnitt einfach deaktiviert.',
homeCustomizerTitle: 'Startseite',
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.',
queueToolbarTitle: 'Warteschlangen-Toolbar',
queueToolbarReset: 'Zurücksetzen',
queueToolbarSeparator: 'Trennlinie',
+2
View File
@@ -1,5 +1,7 @@
export const queue = {
title: 'Queue',
modePlaylist: 'Playlist',
noUpcoming: 'No upcoming tracks.',
savePlaylist: 'Save Playlist',
updatePlaylist: 'Update Playlist',
filterPlaylists: 'Filter playlists…',
+3
View File
@@ -391,6 +391,9 @@ export const settings = {
integrationsPrivacyTitle: 'Privacy notice',
integrationsPrivacyBody: 'All integrations on this tab are <strong>opt-in</strong> and send data to external services or to your Navidrome server when enabled. Last.fm receives your listening history, Discord shows the currently playing track in your profile, Bandsintown is queried per artist to fetch tour dates, and the Now-Playing share publishes your current track to other users of your Navidrome server. If you don\'t want any of this, simply leave the corresponding section disabled.',
homeCustomizerTitle: 'Home Page',
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.',
queueToolbarTitle: 'Queue Toolbar',
queueToolbarReset: 'Reset to default',
queueToolbarSeparator: 'Separator',
+2
View File
@@ -1,5 +1,7 @@
export const queue = {
title: 'Cola',
modePlaylist: 'Lista',
noUpcoming: 'No hay próximas pistas.',
savePlaylist: 'Guardar Lista',
updatePlaylist: 'Actualizar Lista',
filterPlaylists: 'Filtrar listas…',
+3
View File
@@ -326,6 +326,9 @@ export const settings = {
integrationsPrivacyTitle: 'Aviso de privacidad',
integrationsPrivacyBody: 'Todas las integraciones de esta pestaña son <strong>opt-in</strong> y, una vez activadas, envían datos a servicios externos o a tu servidor Navidrome. Last.fm recibe tu historial de escucha, Discord muestra la pista actual en tu perfil, Bandsintown se consulta por artista para obtener fechas de gira, y la compartición «En reproducción» publica tu pista actual para otros usuarios de tu servidor Navidrome. Si no quieres nada de esto, simplemente deja desactivada la sección correspondiente.',
homeCustomizerTitle: 'Página de Inicio',
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.',
queueToolbarTitle: 'Barra de herramientas de cola',
queueToolbarReset: 'Restablecer a predeterminado',
queueToolbarSeparator: 'Separador',
+2
View File
@@ -1,5 +1,7 @@
export const queue = {
title: 'File d\'attente',
modePlaylist: 'Liste de lecture',
noUpcoming: 'Aucune piste à venir.',
savePlaylist: 'Enregistrer la liste',
updatePlaylist: 'Mettre à jour la liste',
filterPlaylists: 'Filtrer les listes…',
+3
View File
@@ -324,6 +324,9 @@ export const settings = {
integrationsPrivacyTitle: 'Avis de confidentialité',
integrationsPrivacyBody: 'Toutes les intégrations de cet onglet sont <strong>facultatives</strong> et, une fois activées, envoient des données à des services externes ou à votre serveur Navidrome. Last.fm reçoit votre historique d\'écoute, Discord affiche le morceau en cours dans votre profil, Bandsintown est interrogé par artiste pour récupérer les dates de tournée, et le partage « En cours de lecture » publie votre morceau actuel auprès des autres utilisateurs de votre serveur Navidrome. Si vous ne voulez rien de tout cela, laissez simplement la section correspondante désactivée.',
homeCustomizerTitle: 'Page d\'accueil',
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.',
queueToolbarTitle: 'Barre d\'outils de file d\'attente',
queueToolbarReset: 'Réinitialiser',
queueToolbarSeparator: 'Séparateur',
+2
View File
@@ -1,5 +1,7 @@
export const queue = {
title: 'Kø',
modePlaylist: 'Spilleliste',
noUpcoming: 'Ingen kommende spor.',
savePlaylist: 'Lagre spilleliste',
updatePlaylist: 'Oppdater spilleliste',
filterPlaylists: 'Filtrer spillelister…',
+3
View File
@@ -323,6 +323,9 @@ export const settings = {
integrationsPrivacyTitle: 'Personvern-merknad',
integrationsPrivacyBody: 'Alle integrasjoner på denne fanen er <strong>frivillige</strong> og sender, når aktivert, data til eksterne tjenester eller til Navidrome-serveren din. Last.fm mottar lyttehistorikken din, Discord viser gjeldende spor i profilen din, Bandsintown spørres per artist for turnédatoer, og "Spilles nå"-delingen publiserer gjeldende spor til andre brukere av Navidrome-serveren din. Hvis du ikke ønsker noe av dette, la den aktuelle seksjonen stå deaktivert.',
homeCustomizerTitle: 'Hjemmeside',
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.',
queueToolbarTitle: 'Kø-verktøylinje',
queueToolbarReset: 'Tilbakestill til standard',
queueToolbarSeparator: 'Skilje',
+2
View File
@@ -1,5 +1,7 @@
export const queue = {
title: 'Wachtrij',
modePlaylist: 'Afspeellijst',
noUpcoming: 'Geen volgende nummers.',
savePlaylist: 'Afspeellijst opslaan',
updatePlaylist: 'Afspeellijst bijwerken',
filterPlaylists: 'Afspeellijsten filteren…',
+3
View File
@@ -324,6 +324,9 @@ export const settings = {
integrationsPrivacyTitle: 'Privacyverklaring',
integrationsPrivacyBody: 'Alle integraties op dit tabblad zijn <strong>opt-in</strong> en sturen, eenmaal ingeschakeld, gegevens naar externe diensten of naar je Navidrome-server. Last.fm ontvangt je luistergeschiedenis, Discord toont het nu spelende nummer in je profiel, Bandsintown wordt per artiest bevraagd voor tourdatums, en de "Nu aan het afspelen"-deling publiceert je huidige nummer bij andere gebruikers van je Navidrome-server. Wil je dit niet, laat dan de bijbehorende sectie gewoon uitgeschakeld.',
homeCustomizerTitle: 'Startpagina',
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.',
queueToolbarTitle: 'Wachtrij-werkbalk',
queueToolbarReset: 'Standaard herstellen',
queueToolbarSeparator: 'Scheiding',
+2
View File
@@ -1,5 +1,7 @@
export const queue = {
title: 'Coadă',
modePlaylist: 'Playlist',
noUpcoming: 'Nicio piesă în continuare.',
savePlaylist: 'Salvează Playlist',
updatePlaylist: 'Actualizează Playlist',
filterPlaylists: 'Filtrează playlisturi…',
+3
View File
@@ -329,6 +329,9 @@ export const settings = {
integrationsPrivacyTitle: 'Notificare de confidențialitate',
integrationsPrivacyBody: 'Toate integrările din acest tab sunt <strong>opționale</strong> și trimit date către servicii externe sau server-ul tău Navidrome când sunt activate. Last.fm primește istoricul ascultărilor tale, Discord arată piesa redată curent în profilul tău, Bandsintown e interogat per artist pentru a aduce date de turnee, și distribuirea Now-Playing publică piesa curentă către alți useri din server-ul tău Navidrome. Dacă nu vrei vreo opțiune, doar lasă secțiunea corespunzătoare dezactivată',
homeCustomizerTitle: 'Pagina Principală',
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.',
queueToolbarTitle: 'Toolbar Coadă',
queueToolbarReset: 'Resetează la implicit',
queueToolbarSeparator: 'Separator',
+2
View File
@@ -1,5 +1,7 @@
export const queue = {
title: 'Очередь',
modePlaylist: 'Плейлист',
noUpcoming: 'Нет следующих треков.',
savePlaylist: 'Сохранить как плейлист',
updatePlaylist: 'Обновить плейлист',
filterPlaylists: 'Фильтр плейлистов…',
+3
View File
@@ -405,6 +405,9 @@ export const settings = {
integrationsPrivacyTitle: 'Уведомление о конфиденциальности',
integrationsPrivacyBody: 'Все интеграции на этой вкладке включаются <strong>по желанию</strong> и при активации отправляют данные во внешние сервисы или на ваш сервер Navidrome. Last.fm получает вашу историю прослушивания, Discord показывает текущую композицию в вашем профиле, Bandsintown опрашивается по артисту для получения дат гастролей, а «Сейчас играет» делится текущей композицией с другими пользователями вашего сервера Navidrome. Если вы не хотите ничего из этого, просто оставьте соответствующий раздел отключённым.',
homeCustomizerTitle: 'Главная',
queueModeTitle: 'Режим отображения очереди',
queueModeQueueSub: 'Показывает только предстоящие треки. Текущий трек остаётся в заголовке и исчезает из списка после воспроизведения.',
queueModePlaylistSub: 'Сохраняет всю очередь в списке, текущий трек выделен вверху; воспроизведённые треки остаются.',
queueToolbarTitle: 'Панель инструментов очереди',
queueToolbarReset: 'Сбросить',
queueToolbarSeparator: 'Разделитель',
+2
View File
@@ -1,5 +1,7 @@
export const queue = {
title: '队列',
modePlaylist: '播放列表',
noUpcoming: '没有后续曲目。',
savePlaylist: '保存为播放列表',
updatePlaylist: '更新播放列表',
filterPlaylists: '筛选播放列表…',
+3
View File
@@ -323,6 +323,9 @@ export const settings = {
integrationsPrivacyTitle: '隐私说明',
integrationsPrivacyBody: '此标签页中的所有集成均为 <strong>选择加入</strong>,启用后会向外部服务或您的 Navidrome 服务器发送数据。Last.fm 接收您的收听历史,Discord 在您的个人资料中显示正在播放的曲目,Bandsintown 会按艺人查询以获取巡演日期,"正在播放" 分享会向您 Navidrome 服务器的其他用户公开您当前的曲目。如果您不需要这些功能,只需将相应部分保持停用即可。',
homeCustomizerTitle: '首页',
queueModeTitle: '队列显示模式',
queueModeQueueSub: '仅显示即将播放的曲目。当前曲目保留在标题栏中,播放完毕后从列表移除。',
queueModePlaylistSub: '在列表中保留整个队列,当前曲目在顶部高亮显示;已播放的曲目保留。',
queueToolbarTitle: '队列工具栏',
queueToolbarReset: '重置为默认',
queueToolbarSeparator: '分隔符',
+1
View File
@@ -99,6 +99,7 @@ export const useAuthStore = create<AuthState>()(
seekbarStyle: 'truewave',
queueNowPlayingCollapsed: false,
queueDurationDisplayMode: 'total',
queueDisplayMode: 'queue',
enableHiRes: false,
audioOutputDevice: null,
hotCacheEnabled: false,
+11
View File
@@ -17,6 +17,7 @@ import type {
DiscordCoverSource,
DurationMode,
LyricsSourceConfig,
QueueDisplayMode,
SeekbarStyle,
} from './authStoreTypes';
@@ -103,6 +104,15 @@ export function computeAuthStoreRehydration(state: AuthState): Partial<AuthState
? {}
: { queueDurationDisplayMode: 'total' as DurationMode };
// Missing key (pre-feature persist) / garbage maps to 'queue' — the default
// mode, which lists only upcoming tracks.
const VALID_QUEUE_DISPLAY_MODES = new Set<string>(['playlist', 'queue']);
const queueDisplayModeMigrated = VALID_QUEUE_DISPLAY_MODES.has(
(state as { queueDisplayMode?: unknown }).queueDisplayMode as string,
)
? {}
: { queueDisplayMode: 'queue' as QueueDisplayMode };
const VALID_WAYLAND_TEXT_PROFILE = new Set<string>(['balanced', 'sharp', 'gpu', 'minimal']);
const rawWaylandProfile = (state as { linuxWaylandTextRenderProfile?: unknown }).linuxWaylandTextRenderProfile;
const linuxWaylandTextRenderProfileMigrated = VALID_WAYLAND_TEXT_PROFILE.has(rawWaylandProfile as string)
@@ -162,6 +172,7 @@ export function computeAuthStoreRehydration(state: AuthState): Partial<AuthState
...wheelSmoothOneTime,
...seekbarStyleMigrated,
...queueDurationDisplayModeMigrated,
...queueDisplayModeMigrated,
...linuxWaylandTextRenderProfileMigrated,
...discordCoverSourceMigrated,
};
+13
View File
@@ -33,6 +33,16 @@ export interface ServerProfile {
export type SeekbarStyle = 'truewave' | 'pseudowave' | 'linedot' | 'bar' | 'thick' | 'segmented' | 'neon' | 'pulsewave' | 'particletrail' | 'liquidfill' | 'retrotape';
/** Queue header duration chip: total duration / time left / ETA finish clock. */
export type DurationMode = 'total' | 'remaining' | 'eta';
/**
* Queue panel render mode.
* - `playlist`: the full queue stays visible; the now-playing row sits at the
* top of the list, the highlight wanders down as tracks play, and the list
* only re-pins the current track once it scrolls out of view.
* - `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 LoggingMode = 'off' | 'normal' | 'debug';
/**
* Wall-clock format for ETA / sleep-timer labels. `'auto'` follows the user's
@@ -181,6 +191,8 @@ 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`). */
queueDisplayMode: QueueDisplayMode;
/** Alpha: native hi-res sample rate output (disabled = safe 44.1 kHz mode) */
enableHiRes: boolean;
@@ -339,6 +351,7 @@ export interface AuthState {
setSeekbarStyle: (v: SeekbarStyle) => void;
setQueueNowPlayingCollapsed: (v: boolean) => void;
setQueueDurationDisplayMode: (v: DurationMode) => void;
setQueueDisplayMode: (v: QueueDisplayMode) => void;
setEnableHiRes: (v: boolean) => void;
setAudioOutputDevice: (v: string | null) => void;
setHotCacheEnabled: (v: boolean) => void;
+2
View File
@@ -26,6 +26,7 @@ export function createUiAppearanceActions(set: SetState): Pick<
| 'setSeekbarStyle'
| 'setQueueNowPlayingCollapsed'
| 'setQueueDurationDisplayMode'
| 'setQueueDisplayMode'
| 'setShowFullscreenLyrics'
| 'setFsLyricsStyle'
| 'setSidebarLyricsStyle'
@@ -50,6 +51,7 @@ export function createUiAppearanceActions(set: SetState): Pick<
setSeekbarStyle: (v) => set({ seekbarStyle: v }),
setQueueNowPlayingCollapsed: (v) => set({ queueNowPlayingCollapsed: v }),
setQueueDurationDisplayMode: (v) => set({ queueDurationDisplayMode: v }),
setQueueDisplayMode: (v) => set({ queueDisplayMode: v }),
setShowFullscreenLyrics: (v) => set({ showFullscreenLyrics: v }),
setFsLyricsStyle: (v) => set({ fsLyricsStyle: v }),
setSidebarLyricsStyle: (v) => set({ sidebarLyricsStyle: v }),
+22
View File
@@ -27,6 +27,28 @@
flex-shrink: 0;
}
/* Slim now-playing indicator shown only in queue mode when the now-playing
card is collapsed keeps the current track visible (it is not in the list
in queue mode). Click re-expands the full card. */
.queue-now-playing-mini {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 6px var(--space-4);
background: transparent;
border: none;
border-bottom: 1px solid var(--border-subtle);
color: var(--accent);
cursor: pointer;
text-align: left;
font-size: 13px;
}
.queue-now-playing-mini:hover {
background: var(--bg-hover);
}
.queue-action-btn {
display: flex;
align-items: center;