mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +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:
@@ -18,6 +18,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## Added
|
||||
|
||||
### Queue Toolbar — customizable button order + per-button visibility
|
||||
|
||||
**By [@kveld9](https://github.com/kveld9), PR [#534](https://github.com/Psychotoxical/psysonic/pull/534)**
|
||||
|
||||
* **Settings → Personalisation** grows a new **Queue Toolbar** section. Drag-and-drop reorders the toolbar buttons; a per-button toggle hides individual entries; a **Separator** item can be placed anywhere to break the row into visual groups. A **Reset** button restores the default layout.
|
||||
* Persistence via a new `queueToolbarStore` (Zustand + localStorage), so the layout survives restarts.
|
||||
* Behaviour-preserving default: `[Shuffle] [Save] [Load] [Share] [Clear] | [Gapless] [Crossfade] [Infinite]` — same buttons in the same order as before.
|
||||
* Auto-hides the toolbar when no real button is visible (a lone Separator no longer takes up space on its own).
|
||||
* i18n coverage across all 8 locales.
|
||||
|
||||
### Orbit — in-app diagnostics popover with copyable event log
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), prompted by reports from nzxl + RavingGrob, PR [#524](https://github.com/Psychotoxical/psysonic/pull/524)**
|
||||
|
||||
+127
-88
@@ -29,6 +29,7 @@ import NowPlayingInfo from './NowPlayingInfo';
|
||||
import { TFunction } from 'i18next';
|
||||
import OverlayScrollArea from './OverlayScrollArea';
|
||||
import { useLuckyMixStore } from '../store/luckyMixStore';
|
||||
import { useQueueToolbarStore, QueueToolbarButtonId } from '../store/queueToolbarStore';
|
||||
import { loudnessGainPlaceholderUntilCacheDb } from '../utils/loudnessPlaceholder';
|
||||
import { effectiveLoudnessPreAnalysisAttenuationDb } from '../utils/loudnessPreAnalysisSlider';
|
||||
|
||||
@@ -384,6 +385,7 @@ function QueuePanelHostOrSolo() {
|
||||
|
||||
const isNowPlayingCollapsed = useAuthStore(s => s.queueNowPlayingCollapsed);
|
||||
const setIsNowPlayingCollapsed = useAuthStore(s => s.setQueueNowPlayingCollapsed);
|
||||
const toolbarButtons = useQueueToolbarStore(s => s.buttons);
|
||||
const [durationMode, setDurationMode] = useState<DurationMode>('total');
|
||||
const [showCrossfadePopover, setShowCrossfadePopover] = useState(false);
|
||||
const [lufsTgtOpen, setLufsTgtOpen] = useState(false);
|
||||
@@ -922,95 +924,132 @@ function QueuePanelHostOrSolo() {
|
||||
)}
|
||||
|
||||
{activeTab === 'queue' ? (<>
|
||||
{!isNowPlayingCollapsed && (
|
||||
{!isNowPlayingCollapsed && toolbarButtons.some(b => b.visible && b.id !== 'separator') && (
|
||||
<div className="queue-toolbar">
|
||||
<button className="queue-round-btn" onClick={() => shuffleQueue()} disabled={queue.length < 2} data-tooltip={t('queue.shuffle')} aria-label={t('queue.shuffle')}>
|
||||
<Shuffle size={13} />
|
||||
</button>
|
||||
<button
|
||||
className={`queue-round-btn${saveState === 'saved' ? ' active' : ''}`}
|
||||
onClick={handleSave}
|
||||
disabled={saveState === 'saving'}
|
||||
data-tooltip={activePlaylist ? `${t('queue.updatePlaylist')}: ${activePlaylist.name}` : t('queue.savePlaylist')}
|
||||
aria-label={t('queue.savePlaylist')}
|
||||
>
|
||||
{saveState === 'saved' ? <Check size={13} /> : <Save size={13} />}
|
||||
</button>
|
||||
<button className="queue-round-btn" onClick={handleLoad} data-tooltip={t('queue.loadPlaylist')} aria-label={t('queue.loadPlaylist')}>
|
||||
<FolderOpen size={13} />
|
||||
</button>
|
||||
<button
|
||||
className="queue-round-btn"
|
||||
onClick={() => void handleCopyQueueShare()}
|
||||
data-tooltip={t('queue.shareQueue')}
|
||||
aria-label={t('queue.shareQueue')}
|
||||
>
|
||||
<Share2 size={13} />
|
||||
</button>
|
||||
<button className="queue-round-btn" onClick={handleClear} data-tooltip={t('queue.clear')} aria-label={t('queue.clear')}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
<div className="queue-toolbar-sep" />
|
||||
<button
|
||||
className={`queue-round-btn${gaplessEnabled ? ' active' : ''}`}
|
||||
onClick={() => { setCrossfadeEnabled(false); setShowCrossfadePopover(false); setGaplessEnabled(!gaplessEnabled); }}
|
||||
data-tooltip={t('queue.gapless')}
|
||||
aria-label={t('queue.gapless')}
|
||||
>
|
||||
<MoveRight size={13} />
|
||||
</button>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
ref={crossfadeBtnRef}
|
||||
className={`queue-round-btn${crossfadeEnabled || showCrossfadePopover ? ' active' : ''}`}
|
||||
onClick={() => {
|
||||
if (crossfadeEnabled) {
|
||||
setCrossfadeEnabled(false);
|
||||
setShowCrossfadePopover(false);
|
||||
} else {
|
||||
setGaplessEnabled(false);
|
||||
setCrossfadeEnabled(true);
|
||||
setShowCrossfadePopover(true);
|
||||
}
|
||||
}}
|
||||
data-tooltip={showCrossfadePopover ? undefined : t('queue.crossfade')}
|
||||
aria-label={t('queue.crossfade')}
|
||||
>
|
||||
<Waves size={13} />
|
||||
</button>
|
||||
{showCrossfadePopover && (
|
||||
<div className="crossfade-popover" ref={crossfadePopoverRef}>
|
||||
<div className="crossfade-popover-label">
|
||||
<Waves size={11} />
|
||||
{t('queue.crossfade')}
|
||||
<span className="crossfade-popover-value">{crossfadeSecs.toFixed(1)} s</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0.1}
|
||||
max={10}
|
||||
step={0.1}
|
||||
value={crossfadeSecs}
|
||||
onChange={e => {
|
||||
setCrossfadeSecs(parseFloat(e.target.value));
|
||||
setCrossfadeEnabled(true);
|
||||
}}
|
||||
className="crossfade-popover-slider"
|
||||
/>
|
||||
<div className="crossfade-popover-range">
|
||||
<span>0.1s</span><span>10s</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className={`queue-round-btn${infiniteQueueEnabled ? ' active' : ''}`}
|
||||
onClick={() => setInfiniteQueueEnabled(!infiniteQueueEnabled)}
|
||||
data-tooltip={t('queue.infiniteQueue')}
|
||||
aria-label={t('queue.infiniteQueue')}
|
||||
>
|
||||
<Infinity size={13} />
|
||||
</button>
|
||||
{toolbarButtons.map((btn, idx) => {
|
||||
if (!btn.visible) return null;
|
||||
|
||||
switch (btn.id as QueueToolbarButtonId) {
|
||||
case 'shuffle':
|
||||
return (
|
||||
<button key={btn.id} className="queue-round-btn" onClick={() => shuffleQueue()} disabled={queue.length < 2} data-tooltip={t('queue.shuffle')} aria-label={t('queue.shuffle')}>
|
||||
<Shuffle size={13} />
|
||||
</button>
|
||||
);
|
||||
case 'save':
|
||||
return (
|
||||
<button
|
||||
key={btn.id}
|
||||
className={`queue-round-btn${saveState === 'saved' ? ' active' : ''}`}
|
||||
onClick={handleSave}
|
||||
disabled={saveState === 'saving'}
|
||||
data-tooltip={activePlaylist ? `${t('queue.updatePlaylist')}: ${activePlaylist.name}` : t('queue.savePlaylist')}
|
||||
aria-label={t('queue.savePlaylist')}
|
||||
>
|
||||
{saveState === 'saved' ? <Check size={13} /> : <Save size={13} />}
|
||||
</button>
|
||||
);
|
||||
case 'load':
|
||||
return (
|
||||
<button key={btn.id} className="queue-round-btn" onClick={handleLoad} data-tooltip={t('queue.loadPlaylist')} aria-label={t('queue.loadPlaylist')}>
|
||||
<FolderOpen size={13} />
|
||||
</button>
|
||||
);
|
||||
case 'share':
|
||||
return (
|
||||
<button
|
||||
key={btn.id}
|
||||
className="queue-round-btn"
|
||||
onClick={() => void handleCopyQueueShare()}
|
||||
data-tooltip={t('queue.shareQueue')}
|
||||
aria-label={t('queue.shareQueue')}
|
||||
>
|
||||
<Share2 size={13} />
|
||||
</button>
|
||||
);
|
||||
case 'clear':
|
||||
return (
|
||||
<button key={btn.id} className="queue-round-btn" onClick={handleClear} data-tooltip={t('queue.clear')} aria-label={t('queue.clear')}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
);
|
||||
case 'separator':
|
||||
return <div key={btn.id} className="queue-toolbar-sep" />;
|
||||
case 'gapless':
|
||||
return (
|
||||
<button
|
||||
key={btn.id}
|
||||
className={`queue-round-btn${gaplessEnabled ? ' active' : ''}`}
|
||||
onClick={() => { setCrossfadeEnabled(false); setShowCrossfadePopover(false); setGaplessEnabled(!gaplessEnabled); }}
|
||||
data-tooltip={t('queue.gapless')}
|
||||
aria-label={t('queue.gapless')}
|
||||
>
|
||||
<MoveRight size={13} />
|
||||
</button>
|
||||
);
|
||||
case 'crossfade':
|
||||
return (
|
||||
<div key={btn.id} style={{ position: 'relative' }}>
|
||||
<button
|
||||
ref={crossfadeBtnRef}
|
||||
className={`queue-round-btn${crossfadeEnabled || showCrossfadePopover ? ' active' : ''}`}
|
||||
onClick={() => {
|
||||
if (crossfadeEnabled) {
|
||||
setCrossfadeEnabled(false);
|
||||
setShowCrossfadePopover(false);
|
||||
} else {
|
||||
setGaplessEnabled(false);
|
||||
setCrossfadeEnabled(true);
|
||||
setShowCrossfadePopover(true);
|
||||
}
|
||||
}}
|
||||
data-tooltip={showCrossfadePopover ? undefined : t('queue.crossfade')}
|
||||
aria-label={t('queue.crossfade')}
|
||||
>
|
||||
<Waves size={13} />
|
||||
</button>
|
||||
{showCrossfadePopover && (
|
||||
<div className="crossfade-popover" ref={crossfadePopoverRef}>
|
||||
<div className="crossfade-popover-label">
|
||||
<Waves size={11} />
|
||||
{t('queue.crossfade')}
|
||||
<span className="crossfade-popover-value">{crossfadeSecs.toFixed(1)} s</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0.1}
|
||||
max={10}
|
||||
step={0.1}
|
||||
value={crossfadeSecs}
|
||||
onChange={e => {
|
||||
setCrossfadeSecs(parseFloat(e.target.value));
|
||||
setCrossfadeEnabled(true);
|
||||
}}
|
||||
className="crossfade-popover-slider"
|
||||
/>
|
||||
<div className="crossfade-popover-range">
|
||||
<span>0.1s</span><span>10s</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case 'infinite':
|
||||
return (
|
||||
<button
|
||||
key={btn.id}
|
||||
className={`queue-round-btn${infiniteQueueEnabled ? ' active' : ''}`}
|
||||
onClick={() => setInfiniteQueueEnabled(!infiniteQueueEnabled)}
|
||||
data-tooltip={t('queue.infiniteQueue')}
|
||||
aria-label={t('queue.infiniteQueue')}
|
||||
>
|
||||
<Infinity size={13} />
|
||||
</button>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -830,6 +830,9 @@ export const deTranslation = {
|
||||
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',
|
||||
queueToolbarTitle: 'Warteschlangen-Toolbar',
|
||||
queueToolbarReset: 'Zurücksetzen',
|
||||
queueToolbarSeparator: 'Trennlinie',
|
||||
sidebarTitle: 'Seitenleiste',
|
||||
sidebarReset: 'Zurücksetzen',
|
||||
artistLayoutTitle: 'Künstlerseiten-Abschnitte',
|
||||
|
||||
@@ -835,6 +835,9 @@ export const enTranslation = {
|
||||
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',
|
||||
queueToolbarTitle: 'Queue Toolbar',
|
||||
queueToolbarReset: 'Reset to default',
|
||||
queueToolbarSeparator: 'Separator',
|
||||
sidebarTitle: 'Sidebar',
|
||||
sidebarReset: 'Reset to default',
|
||||
artistLayoutTitle: 'Artist page sections',
|
||||
|
||||
@@ -822,6 +822,9 @@ export const esTranslation = {
|
||||
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',
|
||||
queueToolbarTitle: 'Barra de herramientas de cola',
|
||||
queueToolbarReset: 'Restablecer a predeterminado',
|
||||
queueToolbarSeparator: 'Separador',
|
||||
sidebarTitle: 'Barra Lateral',
|
||||
sidebarReset: 'Restablecer a predeterminado',
|
||||
artistLayoutTitle: 'Secciones de la página del artista',
|
||||
|
||||
@@ -818,6 +818,9 @@ export const frTranslation = {
|
||||
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',
|
||||
queueToolbarTitle: 'Barre d\'outils de file d\'attente',
|
||||
queueToolbarReset: 'Réinitialiser',
|
||||
queueToolbarSeparator: 'Séparateur',
|
||||
sidebarTitle: 'Barre latérale',
|
||||
sidebarReset: 'Réinitialiser',
|
||||
artistLayoutTitle: 'Sections de la page artiste',
|
||||
|
||||
@@ -817,6 +817,9 @@ export const nbTranslation = {
|
||||
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',
|
||||
queueToolbarTitle: 'Kø-verktøylinje',
|
||||
queueToolbarReset: 'Tilbakestill til standard',
|
||||
queueToolbarSeparator: 'Skilje',
|
||||
sidebarTitle: 'Sidefelt',
|
||||
sidebarReset: 'Tilbakestill til standard',
|
||||
artistLayoutTitle: 'Artistsidens seksjoner',
|
||||
|
||||
@@ -817,6 +817,9 @@ export const nlTranslation = {
|
||||
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',
|
||||
queueToolbarTitle: 'Wachtrij-werkbalk',
|
||||
queueToolbarReset: 'Standaard herstellen',
|
||||
queueToolbarSeparator: 'Scheiding',
|
||||
sidebarTitle: 'Zijbalk',
|
||||
sidebarReset: 'Standaard herstellen',
|
||||
artistLayoutTitle: 'Secties artiestenpagina',
|
||||
|
||||
@@ -867,6 +867,9 @@ export const ruTranslation = {
|
||||
integrationsPrivacyTitle: 'Уведомление о конфиденциальности',
|
||||
integrationsPrivacyBody: 'Все интеграции на этой вкладке включаются <strong>по желанию</strong> и при активации отправляют данные во внешние сервисы или на ваш сервер Navidrome. Last.fm получает вашу историю прослушивания, Discord показывает текущую композицию в вашем профиле, Bandsintown опрашивается по артисту для получения дат гастролей, а «Сейчас играет» делится текущей композицией с другими пользователями вашего сервера Navidrome. Если вы не хотите ничего из этого, просто оставьте соответствующий раздел отключённым.',
|
||||
homeCustomizerTitle: 'Главная',
|
||||
queueToolbarTitle: 'Панель инструментов очереди',
|
||||
queueToolbarReset: 'Сбросить',
|
||||
queueToolbarSeparator: 'Разделитель',
|
||||
sidebarTitle: 'Боковая панель',
|
||||
sidebarReset: 'Сбросить порядок',
|
||||
artistLayoutTitle: 'Разделы страницы исполнителя',
|
||||
|
||||
@@ -812,6 +812,9 @@ export const zhTranslation = {
|
||||
integrationsPrivacyTitle: '隐私说明',
|
||||
integrationsPrivacyBody: '此标签页中的所有集成均为 <strong>选择加入</strong>,启用后会向外部服务或您的 Navidrome 服务器发送数据。Last.fm 接收您的收听历史,Discord 在您的个人资料中显示正在播放的曲目,Bandsintown 会按艺人查询以获取巡演日期,"正在播放" 分享会向您 Navidrome 服务器的其他用户公开您当前的曲目。如果您不需要这些功能,只需将相应部分保持停用即可。',
|
||||
homeCustomizerTitle: '首页',
|
||||
queueToolbarTitle: '队列工具栏',
|
||||
queueToolbarReset: '重置为默认',
|
||||
queueToolbarSeparator: '分隔符',
|
||||
sidebarTitle: '侧边栏',
|
||||
sidebarReset: '重置为默认',
|
||||
artistLayoutTitle: '艺术家页面板块',
|
||||
|
||||
+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(() => ({
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export type QueueToolbarButtonId =
|
||||
| 'shuffle'
|
||||
| 'save'
|
||||
| 'load'
|
||||
| 'share'
|
||||
| 'clear'
|
||||
| 'separator'
|
||||
| 'gapless'
|
||||
| 'crossfade'
|
||||
| 'infinite';
|
||||
|
||||
export interface QueueToolbarButtonConfig {
|
||||
id: QueueToolbarButtonId;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default order and visibility for queue toolbar buttons.
|
||||
* Matches the historical layout in QueuePanel.tsx.
|
||||
*/
|
||||
export const DEFAULT_QUEUE_TOOLBAR_BUTTONS: QueueToolbarButtonConfig[] = [
|
||||
{ id: 'shuffle', visible: true },
|
||||
{ id: 'save', visible: true },
|
||||
{ id: 'load', visible: true },
|
||||
{ id: 'share', visible: true },
|
||||
{ id: 'clear', visible: true },
|
||||
{ id: 'separator', visible: true },
|
||||
{ id: 'gapless', visible: true },
|
||||
{ id: 'crossfade', visible: true },
|
||||
{ id: 'infinite', visible: true },
|
||||
];
|
||||
|
||||
interface QueueToolbarStore {
|
||||
buttons: QueueToolbarButtonConfig[];
|
||||
setButtons: (buttons: QueueToolbarButtonConfig[]) => void;
|
||||
toggleButton: (id: QueueToolbarButtonId) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useQueueToolbarStore = create<QueueToolbarStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
buttons: DEFAULT_QUEUE_TOOLBAR_BUTTONS,
|
||||
|
||||
setButtons: (buttons) => set({ buttons }),
|
||||
|
||||
toggleButton: (id) => set((s) => ({
|
||||
buttons: s.buttons.map(btn => btn.id === id ? { ...btn, visible: !btn.visible } : btn),
|
||||
})),
|
||||
|
||||
reset: () => set({ buttons: DEFAULT_QUEUE_TOOLBAR_BUTTONS }),
|
||||
}),
|
||||
{
|
||||
name: 'psysonic_queue_toolbar',
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (!state) return;
|
||||
// Sanitize: remove null/corrupt entries
|
||||
const knownIds = new Set(DEFAULT_QUEUE_TOOLBAR_BUTTONS.map(b => b.id));
|
||||
const safe = (state.buttons ?? [])
|
||||
.filter((b): b is QueueToolbarButtonConfig => b != null && typeof b.id === 'string' && knownIds.has(b.id as QueueToolbarButtonId));
|
||||
const seen = new Set(safe.map(b => b.id));
|
||||
const missing = DEFAULT_QUEUE_TOOLBAR_BUTTONS.filter(b => !seen.has(b.id));
|
||||
state.buttons = missing.length > 0 ? [...safe, ...missing] : safe;
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
Reference in New Issue
Block a user