feat(settings): global Advanced Mode toggle + playlist page layout (#556) (#720)

Adds a per-element visibility toggle for the playlist detail page (Add
Songs, Import CSV, Download ZIP, Cache Offline, Suggestions) and reworks
the way uncommon options are surfaced: instead of a per-tab collapsible
group, a global "Advanced" toggle in the Settings header reveals all
`advanced` sub-sections across every tab and marks each one with a small
badge. Sets the pattern up so any future advanced option lives in its
natural tab, gated by the same switch.

- New `advancedSettingsEnabled` boolean on `authStore`
  (UiAppearance slice, persisted with the rest of the store).
- `SettingsSubSection` gains an `advanced?: boolean` prop. Hidden when
  the toggle is off; renders an "Advanced" pill in the header when on.
- Settings header gets a Toggle-Switch next to the search lupe.
- `PersonalisationTab` flattens — Sidebar + Home stay always visible;
  Artist sections, Queue Toolbar, and the new Playlist layout get
  `advanced` and disappear by default. `PersonalisationAdvancedGroup`
  component + CSS removed.
- New `playlistLayoutStore` (Zustand + persist, items[{id,visible}] +
  rehydrate sanitize) following the queueToolbarStore pattern.
- `PlaylistHero` and `PlaylistSuggestions` gate the four toolbar buttons
  and the suggestions rail on the store directly.
- One-time migration in MainApp on mount: if the user had opened the
  old per-tab Advanced group (`psysonic_personalisation_advanced_open
  === 'true'`) OR already customised any of the three sub-sections,
  Advanced Mode auto-enables on first launch. Idempotent via a
  localStorage flag; legacy key removed afterwards.
- New i18n keys `settings.advancedMode`, `settings.advancedModeTooltip`,
  `settings.advancedBadge`, `settings.playlistLayout*` in all 9 locales.

Reuses kveld9's design from PR #556; not merged because the locale split
landed afterwards. Credited under the existing kveld9 entry in
settingsCredits.ts.

Co-authored-by: Kveld. <kveld912@proton.me>
This commit is contained in:
Frank Stellmacher
2026-05-15 15:55:11 +02:00
committed by GitHub
parent e1283f4068
commit 651a3f276a
24 changed files with 404 additions and 39 deletions
+9
View File
@@ -165,6 +165,15 @@ Foundational work: faster reviews, narrower diffs, and a safety net under the pa
* Shared tracks and queues resolve against the matching saved server via explicit credentials; `orbitBulkGuard` applies before bulk enqueue. * Shared tracks and queues resolve against the matching saved server via explicit credentials; `orbitBulkGuard` applies before bulk enqueue.
* i18n: `search.share*` keys across all 9 locales. * i18n: `search.share*` keys across all 9 locales.
### Settings — Advanced Mode toggle + playlist page layout
**By [@kveld9](https://github.com/kveld9) + [@Psychotoxical](https://github.com/Psychotoxical), PR [#556](https://github.com/Psychotoxical/psysonic/pull/556)**
* **Advanced Mode.** A new toggle in the Settings header reveals advanced sub-sections across all tabs. Sub-sections marked advanced are hidden by default and labelled with a small badge when shown — community-contributed options that do not necessarily reflect the design philosophy of the Psysonic maintainers, kept available but out of the way. The three current advanced sub-sections live under **Personalisation**: Artist page sections, Queue Toolbar, and the new Playlist page layout.
* **Playlist page layout.** New sub-section that hides individual elements on the playlist page: **Add Songs**, **Import CSV**, **Download ZIP**, **Cache Offline**, and the **Suggestions** rail at the bottom. Persisted via a new `playlistLayoutStore` (Zustand + localStorage); all toggles on by default so existing playlists look unchanged.
* **One-time migration:** if you had previously opened the per-tab Advanced group or customised any of the three sub-sections, Advanced Mode is auto-enabled on first launch — your existing tweaks stay visible.
* i18n: `settings.advancedMode`, `settings.advancedModeTooltip`, `settings.advancedBadge`, and `settings.playlistLayout*` across all 9 locales.
## Changed ## Changed
### Backend — Cargo workspace with 5 domain crates (Rust refactor) ### Backend — Cargo workspace with 5 domain crates (Rust refactor)
+5
View File
@@ -13,6 +13,7 @@ import { useAuthStore } from '../store/authStore';
import { useGlobalShortcutsStore } from '../store/globalShortcutsStore'; import { useGlobalShortcutsStore } from '../store/globalShortcutsStore';
import { initHotCachePrefetch } from '../hotCachePrefetch'; import { initHotCachePrefetch } from '../hotCachePrefetch';
import { initMiniPlayerBridgeOnMain } from '../utils/miniPlayerBridge'; import { initMiniPlayerBridgeOnMain } from '../utils/miniPlayerBridge';
import { runAdvancedModeMigration } from '../utils/migrations/advancedModeMigration';
import { IS_WINDOWS } from '../utils/platform'; import { IS_WINDOWS } from '../utils/platform';
import TauriEventBridge from './TauriEventBridge'; import TauriEventBridge from './TauriEventBridge';
import AppShell from './AppShell'; import AppShell from './AppShell';
@@ -29,6 +30,10 @@ const Login = lazy(() => import('../pages/Login'));
export default function MainApp() { export default function MainApp() {
const [exportPickerOpen, setExportPickerOpen] = useState(false); const [exportPickerOpen, setExportPickerOpen] = useState(false);
// One-time bridge from the per-tab Advanced group (v1.46) to the global
// Advanced Mode toggle. Idempotent — flagged in localStorage.
useEffect(() => { runAdvancedModeMigration(); }, []);
// Push playback state to mini window + handle control events. // Push playback state to mini window + handle control events.
useEffect(() => { useEffect(() => {
return initMiniPlayerBridgeOnMain(); return initMiniPlayerBridgeOnMain();
+16
View File
@@ -1,5 +1,7 @@
import React, { useId } from 'react'; import React, { useId } from 'react';
import { ChevronDown } from 'lucide-react'; import { ChevronDown } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
interface SettingsSubSectionProps { interface SettingsSubSectionProps {
title: string; title: string;
@@ -10,6 +12,8 @@ interface SettingsSubSectionProps {
// Rechts im Summary neben dem Chevron (z.B. Reset-Button). Clicks werden // Rechts im Summary neben dem Chevron (z.B. Reset-Button). Clicks werden
// gestoppt, damit sie das Accordion nicht togglen. // gestoppt, damit sie das Accordion nicht togglen.
action?: React.ReactNode; action?: React.ReactNode;
/** Hidden unless the global Advanced Mode toggle is on; renders a small badge when visible. */
advanced?: boolean;
children: React.ReactNode; children: React.ReactNode;
} }
@@ -23,13 +27,20 @@ export default function SettingsSubSection({
description, description,
searchText, searchText,
action, action,
advanced = false,
children, children,
}: SettingsSubSectionProps) { }: SettingsSubSectionProps) {
const { t } = useTranslation();
const advancedSettingsEnabled = useAuthStore(s => s.advancedSettingsEnabled);
const headingId = useId(); const headingId = useId();
if (advanced && !advancedSettingsEnabled) return null;
return ( return (
<details <details
className="settings-sub-section" className="settings-sub-section"
data-settings-search={searchText ?? title} data-settings-search={searchText ?? title}
data-advanced={advanced ? 'true' : undefined}
open={defaultOpen} open={defaultOpen}
> >
<summary <summary
@@ -38,6 +49,11 @@ export default function SettingsSubSection({
> >
{icon && <span className="settings-sub-section-icon">{icon}</span>} {icon && <span className="settings-sub-section-icon">{icon}</span>}
<span id={headingId} className="settings-sub-section-title">{title}</span> <span id={headingId} className="settings-sub-section-title">{title}</span>
{advanced && (
<span className="settings-sub-section-advanced-badge" aria-hidden="true">
{t('settings.advancedBadge')}
</span>
)}
{action && ( {action && (
<span <span
className="settings-sub-section-action" className="settings-sub-section-action"
+25 -18
View File
@@ -8,6 +8,7 @@ import {
import type { SubsonicPlaylist, SubsonicSong } from '../../api/subsonicTypes'; import type { SubsonicPlaylist, SubsonicSong } from '../../api/subsonicTypes';
import type { ZipDownload } from '../../store/zipDownloadStore'; import type { ZipDownload } from '../../store/zipDownloadStore';
import { useThemeStore } from '../../store/themeStore'; import { useThemeStore } from '../../store/themeStore';
import { usePlaylistLayoutStore, type PlaylistLayoutItemId } from '../../store/playlistLayoutStore';
import { import {
displayPlaylistName, formatSize, isSmartPlaylistName, totalDurationLabel, displayPlaylistName, formatSize, isSmartPlaylistName, totalDurationLabel,
} from '../../utils/componentHelpers/playlistDetailHelpers'; } from '../../utils/componentHelpers/playlistDetailHelpers';
@@ -59,6 +60,9 @@ export default function PlaylistHero({
const navigate = useNavigate(); const navigate = useNavigate();
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground); const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
const enablePlaylistCoverPhoto = useThemeStore(s => s.enablePlaylistCoverPhoto); const enablePlaylistCoverPhoto = useThemeStore(s => s.enablePlaylistCoverPhoto);
const layoutItems = usePlaylistLayoutStore(s => s.items);
const isLayoutVisible = (id: PlaylistLayoutItemId) =>
layoutItems.find(i => i.id === id)?.visible !== false;
return ( return (
<div className="album-detail-header"> <div className="album-detail-header">
@@ -158,23 +162,26 @@ export default function PlaylistHero({
<ListPlus size={16} /> <ListPlus size={16} />
</button> </button>
</div> </div>
<button {isLayoutVisible('addSongs') && (
className={`btn btn-ghost ${searchOpen ? 'active' : ''}`} <button
onClick={() => { setSearchOpen(v => !v); setSearchQuery(''); setSearchResults([]); setSelectedSearchIds(new Set()); setSearchPlPickerOpen(false); }} className={`btn btn-ghost ${searchOpen ? 'active' : ''}`}
> onClick={() => { setSearchOpen(v => !v); setSearchQuery(''); setSearchResults([]); setSelectedSearchIds(new Set()); setSearchPlPickerOpen(false); }}
<Search size={16} /> {t('playlists.addSongs')} >
</button> <Search size={16} /> {t('playlists.addSongs')}
<button </button>
className="btn btn-ghost" )}
onClick={handleImportCsv} {isLayoutVisible('importCsv') && (
disabled={csvImporting} <button
data-tooltip={t('playlists.importCSVTooltip')} className="btn btn-ghost"
> onClick={handleImportCsv}
{csvImporting ? <Loader2 size={16} className="spin-slow" /> : <FileUp size={16} />} disabled={csvImporting}
{t('playlists.importCSV')} data-tooltip={t('playlists.importCSVTooltip')}
</button> >
{/* search close resets selection */} {csvImporting ? <Loader2 size={16} className="spin-slow" /> : <FileUp size={16} />}
{songs.length > 0 && ( {t('playlists.importCSV')}
</button>
)}
{isLayoutVisible('downloadZip') && songs.length > 0 && (
activeZip && !activeZip.done && !activeZip.error ? ( activeZip && !activeZip.done && !activeZip.error ? (
<div className="download-progress-wrap"> <div className="download-progress-wrap">
<Download size={14} /> <Download size={14} />
@@ -189,7 +196,7 @@ export default function PlaylistHero({
</button> </button>
) )
)} )}
{songs.length > 0 && id && ( {isLayoutVisible('offlineCache') && songs.length > 0 && id && (
<button <button
className={`btn btn-ghost${isCached ? ' btn-danger' : ''}`} className={`btn btn-ghost${isCached ? ' btn-danger' : ''}`}
disabled={isDownloading} disabled={isDownloading}
@@ -7,6 +7,7 @@ import type { SubsonicSong } from '../../api/subsonicTypes';
import { usePlayerStore } from '../../store/playerStore'; import { usePlayerStore } from '../../store/playerStore';
import { usePreviewStore } from '../../store/previewStore'; import { usePreviewStore } from '../../store/previewStore';
import { useThemeStore } from '../../store/themeStore'; import { useThemeStore } from '../../store/themeStore';
import { usePlaylistLayoutStore } from '../../store/playlistLayoutStore';
import { songToTrack } from '../../utils/playback/songToTrack'; import { songToTrack } from '../../utils/playback/songToTrack';
import { codecLabel } from '../../utils/componentHelpers/playlistDetailHelpers'; import { codecLabel } from '../../utils/componentHelpers/playlistDetailHelpers';
import { formatTrackTime } from '../../utils/format/formatDuration'; import { formatTrackTime } from '../../utils/format/formatDuration';
@@ -43,6 +44,10 @@ export default function PlaylistSuggestions({
const previewingId = usePreviewStore(s => s.previewingId); const previewingId = usePreviewStore(s => s.previewingId);
const previewAudioStarted = usePreviewStore(s => s.audioStarted); const previewAudioStarted = usePreviewStore(s => s.audioStarted);
const showBitrate = useThemeStore(s => s.showBitrate); const showBitrate = useThemeStore(s => s.showBitrate);
const suggestionsVisible = usePlaylistLayoutStore(s =>
s.items.find(i => i.id === 'suggestions')?.visible !== false);
if (!suggestionsVisible) return null;
const filteredSuggestions = suggestions.filter(s => !existingIds.has(s.id)); const filteredSuggestions = suggestions.filter(s => !existingIds.has(s.id));
+44 -20
View File
@@ -1,12 +1,14 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { LayoutGrid, ListMusic, PanelLeft, RotateCcw, Users } from 'lucide-react'; import { LayoutGrid, ListMusic, ListTodo, PanelLeft, RotateCcw, Users } from 'lucide-react';
import { useArtistLayoutStore } from '../../store/artistLayoutStore'; import { useArtistLayoutStore } from '../../store/artistLayoutStore';
import { useHomeStore } from '../../store/homeStore'; import { useHomeStore } from '../../store/homeStore';
import { usePlaylistLayoutStore } from '../../store/playlistLayoutStore';
import { useQueueToolbarStore } from '../../store/queueToolbarStore'; import { useQueueToolbarStore } from '../../store/queueToolbarStore';
import { useSidebarStore } from '../../store/sidebarStore'; import { useSidebarStore } from '../../store/sidebarStore';
import SettingsSubSection from '../SettingsSubSection'; import SettingsSubSection from '../SettingsSubSection';
import { ArtistLayoutCustomizer } from './ArtistLayoutCustomizer'; import { ArtistLayoutCustomizer } from './ArtistLayoutCustomizer';
import { HomeCustomizer } from './HomeCustomizer'; import { HomeCustomizer } from './HomeCustomizer';
import { PlaylistLayoutCustomizer } from './PlaylistLayoutCustomizer';
import { QueueToolbarCustomizer } from './QueueToolbarCustomizer'; import { QueueToolbarCustomizer } from './QueueToolbarCustomizer';
import { SidebarCustomizer } from './SidebarCustomizer'; import { SidebarCustomizer } from './SidebarCustomizer';
@@ -33,25 +35,6 @@ export function PersonalisationTab() {
<SidebarCustomizer /> <SidebarCustomizer />
</SettingsSubSection> </SettingsSubSection>
<SettingsSubSection
title={t('settings.artistLayoutTitle')}
icon={<Users size={16} />}
action={
<button
type="button"
className="btn btn-ghost"
style={{ fontSize: 12, color: 'var(--text-muted)', padding: '2px 6px' }}
onClick={() => useArtistLayoutStore.getState().reset()}
data-tooltip={t('settings.artistLayoutReset')}
aria-label={t('settings.artistLayoutReset')}
>
<RotateCcw size={14} />
</button>
}
>
<ArtistLayoutCustomizer />
</SettingsSubSection>
<SettingsSubSection <SettingsSubSection
title={t('settings.homeCustomizerTitle')} title={t('settings.homeCustomizerTitle')}
icon={<LayoutGrid size={16} />} icon={<LayoutGrid size={16} />}
@@ -71,9 +54,30 @@ export function PersonalisationTab() {
<HomeCustomizer /> <HomeCustomizer />
</SettingsSubSection> </SettingsSubSection>
<SettingsSubSection
title={t('settings.artistLayoutTitle')}
icon={<Users size={16} />}
advanced
action={
<button
type="button"
className="btn btn-ghost"
style={{ fontSize: 12, color: 'var(--text-muted)', padding: '2px 6px' }}
onClick={() => useArtistLayoutStore.getState().reset()}
data-tooltip={t('settings.artistLayoutReset')}
aria-label={t('settings.artistLayoutReset')}
>
<RotateCcw size={14} />
</button>
}
>
<ArtistLayoutCustomizer />
</SettingsSubSection>
<SettingsSubSection <SettingsSubSection
title={t('settings.queueToolbarTitle')} title={t('settings.queueToolbarTitle')}
icon={<ListMusic size={16} />} icon={<ListMusic size={16} />}
advanced
action={ action={
<button <button
type="button" type="button"
@@ -89,6 +93,26 @@ export function PersonalisationTab() {
> >
<QueueToolbarCustomizer /> <QueueToolbarCustomizer />
</SettingsSubSection> </SettingsSubSection>
<SettingsSubSection
title={t('settings.playlistLayoutTitle')}
icon={<ListTodo size={16} />}
advanced
action={
<button
type="button"
className="btn btn-ghost"
style={{ fontSize: 12, color: 'var(--text-muted)', padding: '2px 6px' }}
onClick={() => usePlaylistLayoutStore.getState().reset()}
data-tooltip={t('settings.playlistLayoutReset')}
aria-label={t('settings.playlistLayoutReset')}
>
<RotateCcw size={14} />
</button>
}
>
<PlaylistLayoutCustomizer />
</SettingsSubSection>
</> </>
); );
} }
@@ -0,0 +1,44 @@
import { useTranslation } from 'react-i18next';
import { Download, FileUp, HardDrive, Lightbulb, Search } from 'lucide-react';
import { usePlaylistLayoutStore, type PlaylistLayoutItemId } from '../../store/playlistLayoutStore';
const PLAYLIST_LAYOUT_ICONS: Record<PlaylistLayoutItemId, typeof Search> = {
addSongs: Search,
importCsv: FileUp,
downloadZip: Download,
offlineCache: HardDrive,
suggestions: Lightbulb,
};
const PLAYLIST_LAYOUT_LABEL_KEYS: Record<PlaylistLayoutItemId, string> = {
addSongs: 'playlists.addSongs',
importCsv: 'playlists.importCSV',
downloadZip: 'playlists.downloadZip',
offlineCache: 'playlists.cacheOffline',
suggestions: 'playlists.suggestions',
};
export function PlaylistLayoutCustomizer() {
const { t } = useTranslation();
const items = usePlaylistLayoutStore(s => s.items);
const toggleItem = usePlaylistLayoutStore(s => s.toggleItem);
return (
<div className="settings-card" style={{ padding: '4px 0' }}>
{items.map((it) => {
const Icon = PLAYLIST_LAYOUT_ICONS[it.id];
const label = t(PLAYLIST_LAYOUT_LABEL_KEYS[it.id]);
return (
<div key={it.id} className="sidebar-customizer-row">
<Icon size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 14 }}>{label}</span>
<label className="toggle-switch" aria-label={label}>
<input type="checkbox" checked={it.visible} onChange={() => toggleItem(it.id)} />
<span className="toggle-track" />
</label>
</div>
);
})}
</div>
);
}
+1
View File
@@ -168,6 +168,7 @@ const CONTRIBUTOR_ENTRIES = [
'Queue Panel — position counter, tri-state duration toggle (total/remaining/ETA), persistent Now Playing collapse, animated EQ indicator (PR #419)', '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)', '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)', 'Customizable queue toolbar — drag-and-drop button reordering, per-button visibility toggles, optional separator, persisted across restarts (PR #534)',
'Playlist page layout — per-element visibility toggles (Add Songs, Import CSV, Download ZIP, Cache Offline, Suggestions) under Settings → Personalisation (PR #556)',
], ],
}, },
{ {
+6
View File
@@ -270,6 +270,12 @@ export const settings = {
artistLayoutSimilar: 'Ähnliche Künstler*innen', artistLayoutSimilar: 'Ähnliche Künstler*innen',
artistLayoutAlbums: 'Alben', artistLayoutAlbums: 'Alben',
artistLayoutFeatured: 'Auch enthalten auf', artistLayoutFeatured: 'Auch enthalten auf',
playlistLayoutTitle: 'Playlist-Seitenlayout',
playlistLayoutDesc: 'Einzelne Elemente auf der Playlist-Seite ein- oder ausblenden.',
playlistLayoutReset: 'Zurücksetzen',
advancedMode: 'Erweitert',
advancedModeTooltip: 'Erweiterte Optionen in allen Settings-Tabs anzeigen. Community-Beiträge, die nicht zwingend der Design-Philosophie der Maintainer von Psysonic entsprechen.',
advancedBadge: 'Erweitert',
sidebarDrag: 'Ziehen zum Umsortieren', sidebarDrag: 'Ziehen zum Umsortieren',
sidebarFixed: 'Immer sichtbar', sidebarFixed: 'Immer sichtbar',
randomNavSplitTitle: 'Mix-Navigation aufteilen', randomNavSplitTitle: 'Mix-Navigation aufteilen',
+6
View File
@@ -273,6 +273,12 @@ export const settings = {
artistLayoutSimilar: 'Similar artists', artistLayoutSimilar: 'Similar artists',
artistLayoutAlbums: 'Albums', artistLayoutAlbums: 'Albums',
artistLayoutFeatured: 'Also featured on', artistLayoutFeatured: 'Also featured on',
playlistLayoutTitle: 'Playlist page layout',
playlistLayoutDesc: 'Show or hide individual elements on the playlist page.',
playlistLayoutReset: 'Reset to default',
advancedMode: 'Advanced',
advancedModeTooltip: 'Show advanced options across all Settings tabs. Community contributions that do not necessarily reflect the design philosophy of the Psysonic maintainers.',
advancedBadge: 'Advanced',
sidebarDrag: 'Drag to reorder', sidebarDrag: 'Drag to reorder',
sidebarFixed: 'Always visible', sidebarFixed: 'Always visible',
randomNavSplitTitle: 'Split Mix navigation', randomNavSplitTitle: 'Split Mix navigation',
+6
View File
@@ -270,6 +270,12 @@ export const settings = {
artistLayoutSimilar: 'Artistas similares', artistLayoutSimilar: 'Artistas similares',
artistLayoutAlbums: 'Álbumes', artistLayoutAlbums: 'Álbumes',
artistLayoutFeatured: 'También presente en', artistLayoutFeatured: 'También presente en',
playlistLayoutTitle: 'Diseño de la página de playlists',
playlistLayoutDesc: 'Mostrar u ocultar elementos individuales en la página de playlists.',
playlistLayoutReset: 'Restablecer a predeterminado',
advancedMode: 'Avanzado',
advancedModeTooltip: 'Mostrar opciones avanzadas en todas las pestañas de ajustes. Aportes de la comunidad que no reflejan necesariamente la filosofía de diseño de los mantenedores de Psysonic.',
advancedBadge: 'Avanzado',
sidebarDrag: 'Arrastra para reordenar', sidebarDrag: 'Arrastra para reordenar',
sidebarFixed: 'Siempre visible', sidebarFixed: 'Siempre visible',
randomNavSplitTitle: 'Dividir navegación Mix', randomNavSplitTitle: 'Dividir navegación Mix',
+6
View File
@@ -268,6 +268,12 @@ export const settings = {
artistLayoutSimilar: 'Artistes similaires', artistLayoutSimilar: 'Artistes similaires',
artistLayoutAlbums: 'Albums', artistLayoutAlbums: 'Albums',
artistLayoutFeatured: 'Apparaît aussi sur', artistLayoutFeatured: 'Apparaît aussi sur',
playlistLayoutTitle: 'Mise en page de la playlist',
playlistLayoutDesc: 'Afficher ou masquer des éléments individuels sur la page de la playlist.',
playlistLayoutReset: 'Réinitialiser',
advancedMode: 'Avancé',
advancedModeTooltip: 'Afficher les options avancées dans tous les onglets des paramètres. Contributions de la communauté qui ne reflètent pas nécessairement la philosophie de conception des mainteneurs de Psysonic.',
advancedBadge: 'Avancé',
sidebarDrag: 'Glisser pour réorganiser', sidebarDrag: 'Glisser pour réorganiser',
sidebarFixed: 'Toujours visible', sidebarFixed: 'Toujours visible',
randomNavSplitTitle: 'Diviser la navigation Mix', randomNavSplitTitle: 'Diviser la navigation Mix',
+6
View File
@@ -267,6 +267,12 @@ export const settings = {
artistLayoutSimilar: 'Lignende artister', artistLayoutSimilar: 'Lignende artister',
artistLayoutAlbums: 'Album', artistLayoutAlbums: 'Album',
artistLayoutFeatured: 'Også med på', artistLayoutFeatured: 'Også med på',
playlistLayoutTitle: 'Sideoppsett for spilleliste',
playlistLayoutDesc: 'Vis eller skjul individuelle elementer på spillelistesiden.',
playlistLayoutReset: 'Tilbakestill til standard',
advancedMode: 'Avansert',
advancedModeTooltip: 'Vis avanserte alternativer i alle innstillinger-faner. Bidrag fra fellesskapet som ikke nødvendigvis gjenspeiler design-filosofien til Psysonic-vedlikeholderne.',
advancedBadge: 'Avansert',
sidebarDrag: 'Dra for å endre rekkefølge', sidebarDrag: 'Dra for å endre rekkefølge',
sidebarFixed: 'Alltid synlig', sidebarFixed: 'Alltid synlig',
randomNavSplitTitle: 'Del Mix-navigasjon', randomNavSplitTitle: 'Del Mix-navigasjon',
+6
View File
@@ -268,6 +268,12 @@ export const settings = {
artistLayoutSimilar: 'Soortgelijke artiesten', artistLayoutSimilar: 'Soortgelijke artiesten',
artistLayoutAlbums: 'Albums', artistLayoutAlbums: 'Albums',
artistLayoutFeatured: 'Ook te horen op', artistLayoutFeatured: 'Ook te horen op',
playlistLayoutTitle: 'Afspeellijst pagina-indeling',
playlistLayoutDesc: 'Individuele elementen op de afspeellijstpagina tonen of verbergen.',
playlistLayoutReset: 'Standaard herstellen',
advancedMode: 'Geavanceerd',
advancedModeTooltip: 'Toon geavanceerde opties in alle instellingstabbladen. Bijdragen van de community die niet noodzakelijk de ontwerpfilosofie van de Psysonic-onderhouders weerspiegelen.',
advancedBadge: 'Geavanceerd',
sidebarDrag: 'Slepen om te herordenen', sidebarDrag: 'Slepen om te herordenen',
sidebarFixed: 'Altijd zichtbaar', sidebarFixed: 'Altijd zichtbaar',
randomNavSplitTitle: 'Mix-navigatie splitsen', randomNavSplitTitle: 'Mix-navigatie splitsen',
+6
View File
@@ -273,6 +273,12 @@ export const settings = {
artistLayoutSimilar: 'Artiști similari', artistLayoutSimilar: 'Artiști similari',
artistLayoutAlbums: 'Albume', artistLayoutAlbums: 'Albume',
artistLayoutFeatured: 'Prezentat și pe', artistLayoutFeatured: 'Prezentat și pe',
playlistLayoutTitle: 'Aspect pagină playlist',
playlistLayoutDesc: 'Afișează sau ascunde elemente individuale pe pagina playlistului.',
playlistLayoutReset: 'Resetare la implicit',
advancedMode: 'Avansat',
advancedModeTooltip: 'Afișează opțiuni avansate în toate filele de setări. Contribuții ale comunității care nu reflectă neapărat filozofia de design a mentenanților Psysonic.',
advancedBadge: 'Avansat',
sidebarDrag: 'Trage pentru a reordona', sidebarDrag: 'Trage pentru a reordona',
sidebarFixed: 'Mereu vizibil', sidebarFixed: 'Mereu vizibil',
randomNavSplitTitle: 'Navigare Mix împărțită', randomNavSplitTitle: 'Navigare Mix împărțită',
+6
View File
@@ -284,6 +284,12 @@ export const settings = {
artistLayoutSimilar: 'Похожие исполнители', artistLayoutSimilar: 'Похожие исполнители',
artistLayoutAlbums: 'Альбомы', artistLayoutAlbums: 'Альбомы',
artistLayoutFeatured: 'Также участвует в', artistLayoutFeatured: 'Также участвует в',
playlistLayoutTitle: 'Макет страницы плейлиста',
playlistLayoutDesc: 'Показать или скрыть отдельные элементы на странице плейлиста.',
playlistLayoutReset: 'Сбросить',
advancedMode: 'Дополнительно',
advancedModeTooltip: 'Показывать расширенные параметры во всех вкладках настроек. Вклады сообщества, которые не обязательно соответствуют дизайн-философии мейнтейнеров Psysonic.',
advancedBadge: 'Дополнительно',
sidebarDrag: 'Перетащите для порядка', sidebarDrag: 'Перетащите для порядка',
sidebarFixed: 'Всегда показывать', sidebarFixed: 'Всегда показывать',
randomNavSplitTitle: 'Разделить навигацию микса', randomNavSplitTitle: 'Разделить навигацию микса',
+6
View File
@@ -268,6 +268,12 @@ export const settings = {
artistLayoutSimilar: '相似艺术家', artistLayoutSimilar: '相似艺术家',
artistLayoutAlbums: '专辑', artistLayoutAlbums: '专辑',
artistLayoutFeatured: '也参与了', artistLayoutFeatured: '也参与了',
playlistLayoutTitle: '播放列表页面布局',
playlistLayoutDesc: '在播放列表页面上显示或隐藏各个元素。',
playlistLayoutReset: '重置为默认',
advancedMode: '高级',
advancedModeTooltip: '在所有设置选项卡中显示高级选项。由社区贡献,未必体现 Psysonic 维护者的设计理念。',
advancedBadge: '高级',
sidebarDrag: '拖动以重新排序', sidebarDrag: '拖动以重新排序',
sidebarFixed: '始终显示', sidebarFixed: '始终显示',
randomNavSplitTitle: '拆分混音导航', randomNavSplitTitle: '拆分混音导航',
+19 -1
View File
@@ -178,7 +178,7 @@ export default function Settings() {
onClick={() => setSearchOpen(true)} onClick={() => setSearchOpen(true)}
aria-label={t('settings.searchPlaceholder')} aria-label={t('settings.searchPlaceholder')}
data-tooltip={t('settings.searchPlaceholder')} data-tooltip={t('settings.searchPlaceholder')}
data-tooltip-pos="left" data-tooltip-pos="bottom"
> >
<Search size={16} /> <Search size={16} />
</button> </button>
@@ -229,6 +229,24 @@ export default function Settings() {
</div> </div>
)} )}
</div> </div>
<label
className="settings-advanced-toggle"
data-tooltip={t('settings.advancedModeTooltip')}
data-tooltip-pos="left"
>
<span className="settings-advanced-toggle-label">
{t('settings.advancedMode')}
</span>
<span className="toggle-switch">
<input
type="checkbox"
checked={auth.advancedSettingsEnabled}
onChange={e => auth.setAdvancedSettingsEnabled(e.target.checked)}
aria-label={t('settings.advancedMode')}
/>
<span className="toggle-track" />
</span>
</label>
</div> </div>
{/* Tab navigation */} {/* Tab navigation */}
+1
View File
@@ -86,6 +86,7 @@ export const useAuthStore = create<AuthState>()(
fsPortraitDim: 28, fsPortraitDim: 28,
showChangelogOnUpdate: true, showChangelogOnUpdate: true,
lastSeenChangelogVersion: '', lastSeenChangelogVersion: '',
advancedSettingsEnabled: false,
seekbarStyle: 'truewave', seekbarStyle: 'truewave',
queueNowPlayingCollapsed: false, queueNowPlayingCollapsed: false,
enableHiRes: false, enableHiRes: false,
+3
View File
@@ -130,6 +130,8 @@ export interface AuthState {
fsPortraitDim: number; fsPortraitDim: number;
showChangelogOnUpdate: boolean; showChangelogOnUpdate: boolean;
lastSeenChangelogVersion: string; lastSeenChangelogVersion: string;
/** Reveals sub-sections marked `advanced` across all Settings tabs. */
advancedSettingsEnabled: boolean;
seekbarStyle: SeekbarStyle; seekbarStyle: SeekbarStyle;
/** Persisted UI toggle: is the Now Playing section in queue panel collapsed */ /** Persisted UI toggle: is the Now Playing section in queue panel collapsed */
@@ -284,6 +286,7 @@ export interface AuthState {
setFsPortraitDim: (v: number) => void; setFsPortraitDim: (v: number) => void;
setShowChangelogOnUpdate: (v: boolean) => void; setShowChangelogOnUpdate: (v: boolean) => void;
setLastSeenChangelogVersion: (v: string) => void; setLastSeenChangelogVersion: (v: string) => void;
setAdvancedSettingsEnabled: (v: boolean) => void;
setSeekbarStyle: (v: SeekbarStyle) => void; setSeekbarStyle: (v: SeekbarStyle) => void;
setQueueNowPlayingCollapsed: (v: boolean) => void; setQueueNowPlayingCollapsed: (v: boolean) => void;
setEnableHiRes: (v: boolean) => void; setEnableHiRes: (v: boolean) => void;
+2
View File
@@ -29,6 +29,7 @@ export function createUiAppearanceActions(set: SetState): Pick<
| 'setFsPortraitDim' | 'setFsPortraitDim'
| 'setShowChangelogOnUpdate' | 'setShowChangelogOnUpdate'
| 'setLastSeenChangelogVersion' | 'setLastSeenChangelogVersion'
| 'setAdvancedSettingsEnabled'
> { > {
return { return {
setShowArtistImages: (v) => set({ showArtistImages: v }), setShowArtistImages: (v) => set({ showArtistImages: v }),
@@ -48,5 +49,6 @@ export function createUiAppearanceActions(set: SetState): Pick<
setFsPortraitDim: (v) => set({ fsPortraitDim: v }), setFsPortraitDim: (v) => set({ fsPortraitDim: v }),
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }), setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }), setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
setAdvancedSettingsEnabled: (v) => set({ advancedSettingsEnabled: v }),
}; };
} }
+68
View File
@@ -0,0 +1,68 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type PlaylistLayoutItemId =
| 'addSongs'
| 'importCsv'
| 'downloadZip'
| 'offlineCache'
| 'suggestions';
export interface PlaylistLayoutItemConfig {
id: PlaylistLayoutItemId;
visible: boolean;
}
export const DEFAULT_PLAYLIST_LAYOUT_ITEMS: PlaylistLayoutItemConfig[] = [
{ id: 'addSongs', visible: true },
{ id: 'importCsv', visible: true },
{ id: 'downloadZip', visible: true },
{ id: 'offlineCache', visible: true },
{ id: 'suggestions', visible: true },
];
interface PlaylistLayoutStore {
items: PlaylistLayoutItemConfig[];
setItems: (items: PlaylistLayoutItemConfig[]) => void;
toggleItem: (id: PlaylistLayoutItemId) => void;
reset: () => void;
}
export const usePlaylistLayoutStore = create<PlaylistLayoutStore>()(
persist(
(set) => ({
items: DEFAULT_PLAYLIST_LAYOUT_ITEMS,
setItems: (items) => set({ items }),
toggleItem: (id) => set((s) => ({
items: s.items.map(it => it.id === id ? { ...it, visible: !it.visible } : it),
})),
reset: () => set({ items: DEFAULT_PLAYLIST_LAYOUT_ITEMS }),
}),
{
name: 'psysonic_playlist_layout',
onRehydrateStorage: () => (state) => {
if (!state) return;
const knownIds = new Set(DEFAULT_PLAYLIST_LAYOUT_ITEMS.map(i => i.id));
const safe = (state.items ?? [])
.filter((i): i is PlaylistLayoutItemConfig =>
i != null && typeof i.id === 'string' && knownIds.has(i.id as PlaylistLayoutItemId));
const seen = new Set(safe.map(i => i.id));
const missing = DEFAULT_PLAYLIST_LAYOUT_ITEMS.filter(i => !seen.has(i.id));
state.items = missing.length > 0 ? [...safe, ...missing] : safe;
},
}
)
);
export function isPlaylistLayoutCustomized(items: PlaylistLayoutItemConfig[]): boolean {
if (items.length !== DEFAULT_PLAYLIST_LAYOUT_ITEMS.length) return true;
for (let i = 0; i < items.length; i++) {
const cur = items[i];
const def = DEFAULT_PLAYLIST_LAYOUT_ITEMS[i];
if (cur.id !== def.id || cur.visible !== def.visible) return true;
}
return false;
}
+51
View File
@@ -145,6 +145,51 @@
display: none; display: none;
} }
/* Advanced Mode: Header-Toggle + Sub-Section Badge
Globaler Schalter im Settings-Header schaltet das Sichtbar-Werden aller
<SettingsSubSection advanced> Bloecke um. Der Badge markiert die jeweilige
Section nochmal optisch sobald sie eingeblendet ist. */
.settings-advanced-toggle {
display: inline-flex;
align-items: center;
gap: var(--space-2);
cursor: pointer;
user-select: none;
flex-shrink: 0;
padding: 4px 8px;
border-radius: var(--radius-md);
transition: background 120ms ease;
/* Lupe sitzt neben dem Titel; der Toggle wird durch auto-margin ans rechte
Ende der Header-Zeile geschoben. */
margin-left: auto;
}
.settings-advanced-toggle:hover {
background: color-mix(in srgb, var(--accent) 6%, transparent);
}
.settings-advanced-toggle-label {
font-size: 13px;
color: var(--text-secondary);
font-weight: 500;
}
.settings-sub-section-advanced-badge {
display: inline-flex;
align-items: center;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 2px 6px;
border-radius: 4px;
color: var(--text-muted);
background: color-mix(in srgb, var(--accent) 14%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 24%, transparent);
flex-shrink: 0;
margin-right: var(--space-2);
}
/* ── In-Page-Suche fuer Settings ─────────────────────────────────────────── */ /* ── In-Page-Suche fuer Settings ─────────────────────────────────────────── */
.settings-header { .settings-header {
display: flex; display: flex;
@@ -156,12 +201,18 @@
.settings-header .page-title { .settings-header .page-title {
margin-bottom: 0; margin-bottom: 0;
line-height: 1;
} }
.settings-search { .settings-search {
display: flex; display: flex;
align-items: center; align-items: center;
flex-shrink: 0; flex-shrink: 0;
/* h1.page-title hat im Display-Font einen visuellen Glyph-Mittelpunkt
unterhalb der Box-Mitte. align-items: center wirkt deshalb so, als hänge
die Lupe zu hoch. Wir verschieben sie auf die Glyph-Baseline runter. */
align-self: end;
padding-bottom: 4px;
} }
.settings-search-wrap { .settings-search-wrap {
@@ -0,0 +1,57 @@
import { useAuthStore } from '../../store/authStore';
import { DEFAULT_ARTIST_SECTIONS, useArtistLayoutStore } from '../../store/artistLayoutStore';
import { DEFAULT_QUEUE_TOOLBAR_BUTTONS, useQueueToolbarStore } from '../../store/queueToolbarStore';
import { DEFAULT_PLAYLIST_LAYOUT_ITEMS, usePlaylistLayoutStore } from '../../store/playlistLayoutStore';
const MIGRATION_FLAG = 'psysonic_advanced_mode_migrated';
const LEGACY_OPEN_KEY = 'psysonic_personalisation_advanced_open';
function arraysEqual<T extends { id: string; visible: boolean }>(a: T[], b: T[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i].id !== b[i].id || a[i].visible !== b[i].visible) return false;
}
return true;
}
/**
* One-time bridge from the v1.46 "Personalisation → Advanced group" (per-tab
* collapsible) to the global Advanced Mode toggle. Auto-enables the new toggle
* for users who had previously opened the collapsible OR already customised any
* of the three sub-sections that now sit behind it. Idempotent via a localStorage
* flag runs at most once per install.
*/
export function runAdvancedModeMigration(): void {
try {
if (localStorage.getItem(MIGRATION_FLAG) === '1') return;
} catch {
return;
}
const legacyOpen = (() => {
try { return localStorage.getItem(LEGACY_OPEN_KEY) === 'true'; } catch { return false; }
})();
const artistCustomised = !arraysEqual(
useArtistLayoutStore.getState().sections,
DEFAULT_ARTIST_SECTIONS,
);
const queueCustomised = !arraysEqual(
useQueueToolbarStore.getState().buttons,
DEFAULT_QUEUE_TOOLBAR_BUTTONS,
);
const playlistCustomised = !arraysEqual(
usePlaylistLayoutStore.getState().items,
DEFAULT_PLAYLIST_LAYOUT_ITEMS,
);
if (legacyOpen || artistCustomised || queueCustomised || playlistCustomised) {
useAuthStore.getState().setAdvancedSettingsEnabled(true);
}
try {
localStorage.removeItem(LEGACY_OPEN_KEY);
localStorage.setItem(MIGRATION_FLAG, '1');
} catch {
// best effort — next boot retries
}
}