mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
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:
+159
-1
@@ -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(() => ({
|
||||
|
||||
Reference in New Issue
Block a user