mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
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:
committed by
GitHub
parent
e1283f4068
commit
651a3f276a
@@ -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.
|
||||
* 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
|
||||
|
||||
### Backend — Cargo workspace with 5 domain crates (Rust refactor)
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useAuthStore } from '../store/authStore';
|
||||
import { useGlobalShortcutsStore } from '../store/globalShortcutsStore';
|
||||
import { initHotCachePrefetch } from '../hotCachePrefetch';
|
||||
import { initMiniPlayerBridgeOnMain } from '../utils/miniPlayerBridge';
|
||||
import { runAdvancedModeMigration } from '../utils/migrations/advancedModeMigration';
|
||||
import { IS_WINDOWS } from '../utils/platform';
|
||||
import TauriEventBridge from './TauriEventBridge';
|
||||
import AppShell from './AppShell';
|
||||
@@ -29,6 +30,10 @@ const Login = lazy(() => import('../pages/Login'));
|
||||
export default function MainApp() {
|
||||
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.
|
||||
useEffect(() => {
|
||||
return initMiniPlayerBridgeOnMain();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { useId } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
interface SettingsSubSectionProps {
|
||||
title: string;
|
||||
@@ -10,6 +12,8 @@ interface SettingsSubSectionProps {
|
||||
// Rechts im Summary neben dem Chevron (z.B. Reset-Button). Clicks werden
|
||||
// gestoppt, damit sie das Accordion nicht togglen.
|
||||
action?: React.ReactNode;
|
||||
/** Hidden unless the global Advanced Mode toggle is on; renders a small badge when visible. */
|
||||
advanced?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -23,13 +27,20 @@ export default function SettingsSubSection({
|
||||
description,
|
||||
searchText,
|
||||
action,
|
||||
advanced = false,
|
||||
children,
|
||||
}: SettingsSubSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const advancedSettingsEnabled = useAuthStore(s => s.advancedSettingsEnabled);
|
||||
const headingId = useId();
|
||||
|
||||
if (advanced && !advancedSettingsEnabled) return null;
|
||||
|
||||
return (
|
||||
<details
|
||||
className="settings-sub-section"
|
||||
data-settings-search={searchText ?? title}
|
||||
data-advanced={advanced ? 'true' : undefined}
|
||||
open={defaultOpen}
|
||||
>
|
||||
<summary
|
||||
@@ -38,6 +49,11 @@ export default function SettingsSubSection({
|
||||
>
|
||||
{icon && <span className="settings-sub-section-icon">{icon}</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 && (
|
||||
<span
|
||||
className="settings-sub-section-action"
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import type { SubsonicPlaylist, SubsonicSong } from '../../api/subsonicTypes';
|
||||
import type { ZipDownload } from '../../store/zipDownloadStore';
|
||||
import { useThemeStore } from '../../store/themeStore';
|
||||
import { usePlaylistLayoutStore, type PlaylistLayoutItemId } from '../../store/playlistLayoutStore';
|
||||
import {
|
||||
displayPlaylistName, formatSize, isSmartPlaylistName, totalDurationLabel,
|
||||
} from '../../utils/componentHelpers/playlistDetailHelpers';
|
||||
@@ -59,6 +60,9 @@ export default function PlaylistHero({
|
||||
const navigate = useNavigate();
|
||||
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
|
||||
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 (
|
||||
<div className="album-detail-header">
|
||||
@@ -158,23 +162,26 @@ export default function PlaylistHero({
|
||||
<ListPlus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className={`btn btn-ghost ${searchOpen ? 'active' : ''}`}
|
||||
onClick={() => { setSearchOpen(v => !v); setSearchQuery(''); setSearchResults([]); setSelectedSearchIds(new Set()); setSearchPlPickerOpen(false); }}
|
||||
>
|
||||
<Search size={16} /> {t('playlists.addSongs')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={handleImportCsv}
|
||||
disabled={csvImporting}
|
||||
data-tooltip={t('playlists.importCSVTooltip')}
|
||||
>
|
||||
{csvImporting ? <Loader2 size={16} className="spin-slow" /> : <FileUp size={16} />}
|
||||
{t('playlists.importCSV')}
|
||||
</button>
|
||||
{/* search close resets selection */}
|
||||
{songs.length > 0 && (
|
||||
{isLayoutVisible('addSongs') && (
|
||||
<button
|
||||
className={`btn btn-ghost ${searchOpen ? 'active' : ''}`}
|
||||
onClick={() => { setSearchOpen(v => !v); setSearchQuery(''); setSearchResults([]); setSelectedSearchIds(new Set()); setSearchPlPickerOpen(false); }}
|
||||
>
|
||||
<Search size={16} /> {t('playlists.addSongs')}
|
||||
</button>
|
||||
)}
|
||||
{isLayoutVisible('importCsv') && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={handleImportCsv}
|
||||
disabled={csvImporting}
|
||||
data-tooltip={t('playlists.importCSVTooltip')}
|
||||
>
|
||||
{csvImporting ? <Loader2 size={16} className="spin-slow" /> : <FileUp size={16} />}
|
||||
{t('playlists.importCSV')}
|
||||
</button>
|
||||
)}
|
||||
{isLayoutVisible('downloadZip') && songs.length > 0 && (
|
||||
activeZip && !activeZip.done && !activeZip.error ? (
|
||||
<div className="download-progress-wrap">
|
||||
<Download size={14} />
|
||||
@@ -189,7 +196,7 @@ export default function PlaylistHero({
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
{songs.length > 0 && id && (
|
||||
{isLayoutVisible('offlineCache') && songs.length > 0 && id && (
|
||||
<button
|
||||
className={`btn btn-ghost${isCached ? ' btn-danger' : ''}`}
|
||||
disabled={isDownloading}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { SubsonicSong } from '../../api/subsonicTypes';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { usePreviewStore } from '../../store/previewStore';
|
||||
import { useThemeStore } from '../../store/themeStore';
|
||||
import { usePlaylistLayoutStore } from '../../store/playlistLayoutStore';
|
||||
import { songToTrack } from '../../utils/playback/songToTrack';
|
||||
import { codecLabel } from '../../utils/componentHelpers/playlistDetailHelpers';
|
||||
import { formatTrackTime } from '../../utils/format/formatDuration';
|
||||
@@ -43,6 +44,10 @@ export default function PlaylistSuggestions({
|
||||
const previewingId = usePreviewStore(s => s.previewingId);
|
||||
const previewAudioStarted = usePreviewStore(s => s.audioStarted);
|
||||
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));
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
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 { useHomeStore } from '../../store/homeStore';
|
||||
import { usePlaylistLayoutStore } from '../../store/playlistLayoutStore';
|
||||
import { useQueueToolbarStore } from '../../store/queueToolbarStore';
|
||||
import { useSidebarStore } from '../../store/sidebarStore';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import { ArtistLayoutCustomizer } from './ArtistLayoutCustomizer';
|
||||
import { HomeCustomizer } from './HomeCustomizer';
|
||||
import { PlaylistLayoutCustomizer } from './PlaylistLayoutCustomizer';
|
||||
import { QueueToolbarCustomizer } from './QueueToolbarCustomizer';
|
||||
import { SidebarCustomizer } from './SidebarCustomizer';
|
||||
|
||||
@@ -33,25 +35,6 @@ export function PersonalisationTab() {
|
||||
<SidebarCustomizer />
|
||||
</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
|
||||
title={t('settings.homeCustomizerTitle')}
|
||||
icon={<LayoutGrid size={16} />}
|
||||
@@ -71,9 +54,30 @@ export function PersonalisationTab() {
|
||||
<HomeCustomizer />
|
||||
</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
|
||||
title={t('settings.queueToolbarTitle')}
|
||||
icon={<ListMusic size={16} />}
|
||||
advanced
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
@@ -89,6 +93,26 @@ export function PersonalisationTab() {
|
||||
>
|
||||
<QueueToolbarCustomizer />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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)',
|
||||
'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)',
|
||||
'Playlist page layout — per-element visibility toggles (Add Songs, Import CSV, Download ZIP, Cache Offline, Suggestions) under Settings → Personalisation (PR #556)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -270,6 +270,12 @@ export const settings = {
|
||||
artistLayoutSimilar: 'Ähnliche Künstler*innen',
|
||||
artistLayoutAlbums: 'Alben',
|
||||
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',
|
||||
sidebarFixed: 'Immer sichtbar',
|
||||
randomNavSplitTitle: 'Mix-Navigation aufteilen',
|
||||
|
||||
@@ -273,6 +273,12 @@ export const settings = {
|
||||
artistLayoutSimilar: 'Similar artists',
|
||||
artistLayoutAlbums: 'Albums',
|
||||
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',
|
||||
sidebarFixed: 'Always visible',
|
||||
randomNavSplitTitle: 'Split Mix navigation',
|
||||
|
||||
@@ -270,6 +270,12 @@ export const settings = {
|
||||
artistLayoutSimilar: 'Artistas similares',
|
||||
artistLayoutAlbums: 'Álbumes',
|
||||
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',
|
||||
sidebarFixed: 'Siempre visible',
|
||||
randomNavSplitTitle: 'Dividir navegación Mix',
|
||||
|
||||
@@ -268,6 +268,12 @@ export const settings = {
|
||||
artistLayoutSimilar: 'Artistes similaires',
|
||||
artistLayoutAlbums: 'Albums',
|
||||
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',
|
||||
sidebarFixed: 'Toujours visible',
|
||||
randomNavSplitTitle: 'Diviser la navigation Mix',
|
||||
|
||||
@@ -267,6 +267,12 @@ export const settings = {
|
||||
artistLayoutSimilar: 'Lignende artister',
|
||||
artistLayoutAlbums: 'Album',
|
||||
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',
|
||||
sidebarFixed: 'Alltid synlig',
|
||||
randomNavSplitTitle: 'Del Mix-navigasjon',
|
||||
|
||||
@@ -268,6 +268,12 @@ export const settings = {
|
||||
artistLayoutSimilar: 'Soortgelijke artiesten',
|
||||
artistLayoutAlbums: 'Albums',
|
||||
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',
|
||||
sidebarFixed: 'Altijd zichtbaar',
|
||||
randomNavSplitTitle: 'Mix-navigatie splitsen',
|
||||
|
||||
@@ -273,6 +273,12 @@ export const settings = {
|
||||
artistLayoutSimilar: 'Artiști similari',
|
||||
artistLayoutAlbums: 'Albume',
|
||||
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',
|
||||
sidebarFixed: 'Mereu vizibil',
|
||||
randomNavSplitTitle: 'Navigare Mix împărțită',
|
||||
|
||||
@@ -284,6 +284,12 @@ export const settings = {
|
||||
artistLayoutSimilar: 'Похожие исполнители',
|
||||
artistLayoutAlbums: 'Альбомы',
|
||||
artistLayoutFeatured: 'Также участвует в',
|
||||
playlistLayoutTitle: 'Макет страницы плейлиста',
|
||||
playlistLayoutDesc: 'Показать или скрыть отдельные элементы на странице плейлиста.',
|
||||
playlistLayoutReset: 'Сбросить',
|
||||
advancedMode: 'Дополнительно',
|
||||
advancedModeTooltip: 'Показывать расширенные параметры во всех вкладках настроек. Вклады сообщества, которые не обязательно соответствуют дизайн-философии мейнтейнеров Psysonic.',
|
||||
advancedBadge: 'Дополнительно',
|
||||
sidebarDrag: 'Перетащите для порядка',
|
||||
sidebarFixed: 'Всегда показывать',
|
||||
randomNavSplitTitle: 'Разделить навигацию микса',
|
||||
|
||||
@@ -268,6 +268,12 @@ export const settings = {
|
||||
artistLayoutSimilar: '相似艺术家',
|
||||
artistLayoutAlbums: '专辑',
|
||||
artistLayoutFeatured: '也参与了',
|
||||
playlistLayoutTitle: '播放列表页面布局',
|
||||
playlistLayoutDesc: '在播放列表页面上显示或隐藏各个元素。',
|
||||
playlistLayoutReset: '重置为默认',
|
||||
advancedMode: '高级',
|
||||
advancedModeTooltip: '在所有设置选项卡中显示高级选项。由社区贡献,未必体现 Psysonic 维护者的设计理念。',
|
||||
advancedBadge: '高级',
|
||||
sidebarDrag: '拖动以重新排序',
|
||||
sidebarFixed: '始终显示',
|
||||
randomNavSplitTitle: '拆分混音导航',
|
||||
|
||||
+19
-1
@@ -178,7 +178,7 @@ export default function Settings() {
|
||||
onClick={() => setSearchOpen(true)}
|
||||
aria-label={t('settings.searchPlaceholder')}
|
||||
data-tooltip={t('settings.searchPlaceholder')}
|
||||
data-tooltip-pos="left"
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<Search size={16} />
|
||||
</button>
|
||||
@@ -229,6 +229,24 @@ export default function Settings() {
|
||||
</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>
|
||||
|
||||
{/* Tab navigation */}
|
||||
|
||||
@@ -86,6 +86,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
fsPortraitDim: 28,
|
||||
showChangelogOnUpdate: true,
|
||||
lastSeenChangelogVersion: '',
|
||||
advancedSettingsEnabled: false,
|
||||
seekbarStyle: 'truewave',
|
||||
queueNowPlayingCollapsed: false,
|
||||
enableHiRes: false,
|
||||
|
||||
@@ -130,6 +130,8 @@ export interface AuthState {
|
||||
fsPortraitDim: number;
|
||||
showChangelogOnUpdate: boolean;
|
||||
lastSeenChangelogVersion: string;
|
||||
/** Reveals sub-sections marked `advanced` across all Settings tabs. */
|
||||
advancedSettingsEnabled: boolean;
|
||||
|
||||
seekbarStyle: SeekbarStyle;
|
||||
/** Persisted UI toggle: is the Now Playing section in queue panel collapsed */
|
||||
@@ -284,6 +286,7 @@ export interface AuthState {
|
||||
setFsPortraitDim: (v: number) => void;
|
||||
setShowChangelogOnUpdate: (v: boolean) => void;
|
||||
setLastSeenChangelogVersion: (v: string) => void;
|
||||
setAdvancedSettingsEnabled: (v: boolean) => void;
|
||||
setSeekbarStyle: (v: SeekbarStyle) => void;
|
||||
setQueueNowPlayingCollapsed: (v: boolean) => void;
|
||||
setEnableHiRes: (v: boolean) => void;
|
||||
|
||||
@@ -29,6 +29,7 @@ export function createUiAppearanceActions(set: SetState): Pick<
|
||||
| 'setFsPortraitDim'
|
||||
| 'setShowChangelogOnUpdate'
|
||||
| 'setLastSeenChangelogVersion'
|
||||
| 'setAdvancedSettingsEnabled'
|
||||
> {
|
||||
return {
|
||||
setShowArtistImages: (v) => set({ showArtistImages: v }),
|
||||
@@ -48,5 +49,6 @@ export function createUiAppearanceActions(set: SetState): Pick<
|
||||
setFsPortraitDim: (v) => set({ fsPortraitDim: v }),
|
||||
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
|
||||
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
|
||||
setAdvancedSettingsEnabled: (v) => set({ advancedSettingsEnabled: v }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -145,6 +145,51 @@
|
||||
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 ─────────────────────────────────────────── */
|
||||
.settings-header {
|
||||
display: flex;
|
||||
@@ -156,12 +201,18 @@
|
||||
|
||||
.settings-header .page-title {
|
||||
margin-bottom: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.settings-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user