mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
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:
committed by
GitHub
parent
7b06be5ba2
commit
1de2b0e850
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
Reference in New Issue
Block a user