feat: customizable queue toolbar with drag-and-drop reordering and visibility toggles (#534)

* Add drag-and-drop reordering and visibility toggles for queue toolbar

* docs(changelog): credit PR #534 (queue toolbar customization)

Adds the v1.46.0 CHANGELOG entry and a new bullet on kveld9's
contributors block in Settings → System.

---------

Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
This commit is contained in:
Kveld.
2026-05-11 07:35:38 -03:00
committed by GitHub
parent 02d533e949
commit 64b33e6941
12 changed files with 390 additions and 89 deletions
+159 -1
View File
@@ -6,7 +6,7 @@ import {
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown,
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves, Star, Clock, ZoomIn, Sparkles, AlertTriangle, Maximize2, AudioLines, User, Lock,
Users, UserPlus, Shield, Wand2, Search, Scale
Users, UserPlus, Shield, Wand2, Search, Scale, ListMusic, Save, Infinity, Share2, MoveRight
} from 'lucide-react';
import i18n from '../i18n';
import { exportBackup, importBackup } from '../utils/backup';
@@ -48,6 +48,7 @@ import { useKeybindingsStore, KeyAction, formatBinding, buildInAppBinding } from
import { useGlobalShortcutsStore, GlobalAction, buildGlobalShortcut, formatGlobalShortcut } from '../store/globalShortcutsStore';
import { IN_APP_SHORTCUT_ACTIONS, GLOBAL_SHORTCUT_ACTIONS } from '../config/shortcutActions';
import { useSidebarStore, DEFAULT_SIDEBAR_ITEMS, SidebarItemConfig } from '../store/sidebarStore';
import { useQueueToolbarStore, QueueToolbarButtonId, QueueToolbarButtonConfig } from '../store/queueToolbarStore';
import {
effectiveLoudnessPreAnalysisAttenuationDb,
} from '../utils/loudnessPreAnalysisSlider';
@@ -255,6 +256,7 @@ const CONTRIBUTORS = [
'Floating player bar: scroll-padding fix (PR #221)',
'Queue Panel — position counter, tri-state duration toggle (total/remaining/ETA), persistent Now Playing collapse, animated EQ indicator (PR #419)',
'Community themes — redesign pass: removed 5 overlapping / eye-straining palettes, added 8 new dark themes (Obsidian Black, Carbon Grey, Volcanic Dark, Forest Green, Violet Haze, Copper Oxide, Sakura Night, Obsidian Gold) (PR #490)',
'Customizable queue toolbar — drag-and-drop button reordering, per-button visibility toggles, optional separator, persisted across restarts (PR #534)',
],
},
{
@@ -429,6 +431,7 @@ const SETTINGS_INDEX: SearchIndexEntry[] = [
{ tab: 'personalisation',titleKey: 'settings.sidebarTitle', keywords: 'sidebar nav navigation items reorder customize' },
{ tab: 'personalisation',titleKey: 'settings.artistLayoutTitle', keywords: 'artist page layout sections order' },
{ tab: 'personalisation',titleKey: 'settings.homeCustomizerTitle', keywords: 'home page customize sections' },
{ tab: 'personalisation',titleKey: 'settings.queueToolbarTitle', keywords: 'queue toolbar buttons reorder customize shuffle save load' },
{ tab: 'library', titleKey: 'settings.randomMixTitle', keywords: 'random mix blacklist genre keywords filter audiobook' },
{ tab: 'library', titleKey: 'settings.ratingsSectionTitle', keywords: 'ratings stars skip threshold manual' },
{ tab: 'storage', titleKey: 'settings.offlineDirTitle', keywords: 'offline library download directory folder cache' },
@@ -3007,6 +3010,25 @@ export default function Settings() {
>
<HomeCustomizer />
</SettingsSubSection>
<SettingsSubSection
title={t('settings.queueToolbarTitle')}
icon={<ListMusic size={16} />}
action={
<button
type="button"
className="btn btn-ghost"
style={{ fontSize: 12, color: 'var(--text-muted)', padding: '2px 6px' }}
onClick={() => useQueueToolbarStore.getState().reset()}
data-tooltip={t('settings.queueToolbarReset')}
aria-label={t('settings.queueToolbarReset')}
>
<RotateCcw size={14} />
</button>
}
>
<QueueToolbarCustomizer />
</SettingsSubSection>
</>
)}
@@ -4583,6 +4605,142 @@ function HomeCustomizer() {
);
}
// ── Queue Toolbar Customizer ───────────────────────────────────────────────
type QueueToolbarDropTarget = { idx: number; before: boolean } | null;
const QUEUE_TOOLBAR_BUTTON_ICONS: Record<QueueToolbarButtonId, typeof Shuffle | null> = {
shuffle: Shuffle,
save: Save,
load: FolderOpen,
share: Share2,
clear: Trash2,
separator: null, // No icon for separator
gapless: MoveRight,
crossfade: Waves,
infinite: Infinity,
};
const QUEUE_TOOLBAR_LABEL_KEYS: Record<QueueToolbarButtonId, string> = {
shuffle: 'queue.shuffle',
save: 'queue.savePlaylist',
load: 'queue.loadPlaylist',
share: 'queue.shareQueue',
clear: 'queue.clear',
separator: 'settings.queueToolbarSeparator',
gapless: 'queue.gapless',
crossfade: 'queue.crossfade',
infinite: 'queue.infiniteQueue',
};
function QueueToolbarGripHandle({ idx, label }: { idx: number; label: string }) {
const { t } = useTranslation();
const { onMouseDown } = useDragSource(() => ({
data: JSON.stringify({ type: 'queue_toolbar_reorder', index: idx }),
label,
}));
return (
<span
className="sidebar-customizer-grip"
data-tooltip={t('settings.sidebarDrag')}
data-tooltip-pos="right"
onMouseDown={onMouseDown}
>
<GripVertical size={16} />
</span>
);
}
function QueueToolbarCustomizer() {
const { t } = useTranslation();
const { buttons, setButtons, toggleButton } = useQueueToolbarStore();
const { isDragging: isPsyDragging } = useDragDrop();
const containerRef = useRef<HTMLDivElement>(null);
const [dropTarget, setDropTarget] = useState<QueueToolbarDropTarget>(null);
const dropTargetRef = useRef<QueueToolbarDropTarget>(null);
const buttonsRef = useRef(buttons);
buttonsRef.current = buttons;
useEffect(() => {
if (!isPsyDragging) { dropTargetRef.current = null; setDropTarget(null); }
}, [isPsyDragging]);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const onPsyDrop = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (!detail?.data) return;
let parsed: { type?: string; index?: number };
try { parsed = JSON.parse(detail.data as string); } catch { return; }
if (parsed.type !== 'queue_toolbar_reorder' || parsed.index == null) return;
const fromIdx = parsed.index;
const target = dropTargetRef.current;
dropTargetRef.current = null; setDropTarget(null);
if (!target) return;
const insertBefore = target.before ? target.idx : target.idx + 1;
if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return;
const next = [...buttonsRef.current];
const [moved] = next.splice(fromIdx, 1);
next.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved);
setButtons(next);
};
el.addEventListener('psy-drop', onPsyDrop);
return () => el.removeEventListener('psy-drop', onPsyDrop);
}, [setButtons]);
const handleMouseMove = (e: React.MouseEvent) => {
if (!isPsyDragging || !containerRef.current) return;
const rows = containerRef.current.querySelectorAll<HTMLElement>('[data-queue-toolbar-idx]');
let target: QueueToolbarDropTarget = null;
for (const row of rows) {
const rect = row.getBoundingClientRect();
const idx = Number(row.dataset.queueToolbarIdx);
if (e.clientY < rect.top + rect.height / 2) { target = { idx, before: true }; break; }
target = { idx, before: false };
}
dropTargetRef.current = target;
setDropTarget(target);
};
return (
<div ref={containerRef} onMouseMove={handleMouseMove} className="settings-card" style={{ padding: '4px 0' }}>
{buttons.map((btn, idx) => {
const Icon = QUEUE_TOOLBAR_BUTTON_ICONS[btn.id];
const label = t(QUEUE_TOOLBAR_LABEL_KEYS[btn.id]);
const isBefore = isPsyDragging && dropTarget?.idx === idx && dropTarget.before;
const isAfter = isPsyDragging && dropTarget?.idx === idx && !dropTarget.before;
return (
<div
key={btn.id}
data-queue-toolbar-idx={idx}
className="sidebar-customizer-row"
style={{
borderTop: isBefore ? '2px solid var(--accent)' : undefined,
borderBottom: isAfter ? '2px solid var(--accent)' : undefined,
}}
>
<QueueToolbarGripHandle idx={idx} label={label} />
{Icon ? (
<Icon size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
) : (
<div style={{ width: 1, height: 16, background: 'var(--border-subtle)', flexShrink: 0 }} />
)}
<span style={{ flex: 1, fontSize: 14 }}>{label}</span>
<label className="toggle-switch" aria-label={label}>
<input type="checkbox" checked={btn.visible} onChange={() => toggleButton(btn.id)} />
<span className="toggle-track" />
</label>
</div>
);
})}
</div>
);
}
function SidebarGripHandle({ idx, section, label }: { idx: number; section: 'library' | 'system'; label: string }) {
const { t } = useTranslation();
const { onMouseDown } = useDragSource(() => ({