mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 22:45:41 +00:00
refactor(settings): reusable SettingsGroup/SettingsToggle + boxed sections across tabs (#1126)
* refactor(settings): extract reusable SettingsGroup component
Pull the boxed sub-section pattern (bordered panel + accent uppercase
header) introduced for the Audio tab into a reusable <SettingsGroup
title desc> component, and migrate NormalizationBlock and
PlaybackBehaviorBlock onto it. No visual change.
* feat(settings): box the System behavior section into groups
Split the System -> App Behavior card (which bundled tray toggles, Linux
rendering tweaks and the clock format) into titled SettingsGroup panels
(Tray / Linux rendering / Clock) for a clearer, consistent boxed look.
Adds the group titles across all 9 locales.
* feat(settings): box Appearance visual options; optional SettingsGroup title
Split the Appearance -> Visual options card into a Display group and a
Window group (Linux custom titlebar controls). SettingsGroup now allows an
optional/omitted title for plain boxed panels. Group titles added across
all 9 locales.
* feat(settings): box Discord cover source and templates
Group the Discord cover-source toggles and the activity templates into
separate SettingsGroup panels (templates reuse their existing title/desc),
dropping the manual indent and inline header. No new strings.
* feat(settings): box the Music Network section
Give the scrobble-master toggle, the enrichment-primary picker and the
provider list each a boxed SettingsGroup with an accent header, and switch
the add-a-service provider rows to the boxed panel style for consistent
contrast with the rest of settings.
* feat(settings): titled boxes for Integrations sections + cover source label
Wrap the Discord enable toggle, the Discord cover-source picker (now with a
"Cover art source" title and explainer), Bandsintown and Show-in-Now-Playing
in titled SettingsGroup panels so each single-item section gets the same
accent-headed boxed look. New cover-source strings across all 9 locales.
* feat(settings): box Lyrics tab sections
Wrap the lyrics-sources customizer and the sidebar-style picker in titled
SettingsGroup panels (reusing the existing section titles).
* refactor(settings): consistent content inset in SettingsGroup
Add a body wrapper with a small left inset so every boxed section indents its
controls uniformly (title stays flush at the box edge). Defined once in the
component instead of per section.
* feat(settings): box Storage tab sections incl. cover art cache
Wrap media directory, cover art cache strategy, next-track buffering and
downloads in titled SettingsGroup panels (reusing existing section titles);
align the cover-cache table's first column flush with the new content inset.
* feat(settings): box Library tab, relocate Lucky Mix, add SettingsToggle
- Add a reusable SettingsToggle component for the repeated label/desc/switch row.
- Box the Random Mix blacklist (grouping built-in keywords with the custom
filter), Ratings and the Analytics strategy section.
- Move the "Show Lucky Mix in menu" toggle out of the blacklist and up next to
split-mix navigation and now-playing-at-top in the sidebar customizer.
* feat(settings): box remaining Appearance sections
Wrap library grid, UI scale, font and seekbar style in titled SettingsGroup
panels (reusing the existing section titles).
* feat(settings): box System language, logging and backup sections
Wrap language and logging in titled SettingsGroup panels, and rework the
backup section to drop its redundant inline header (the subsection already
provides it) and box its content. About / Contributors / Licenses stay as
free display content.
* feat(settings): box remaining Audio sections
Wrap Hi-Res, equalizer, playback speed, audio output device and track
previews in titled SettingsGroup panels (reusing the existing section titles).
* feat(settings): box Input tab keybinding sections
Wrap the in-app and global shortcut lists in titled SettingsGroup panels.
* feat(settings): box the Personalisation tab
Convert every customizer (sidebar, home, artist, queue toolbar, playlist,
player bar) to plain inner containers and wrap each in a titled SettingsGroup,
so the boxed look is consistent without nested cards. Box the queue display
mode picker too.
* feat(settings): box the Themes sections above the store
Add a boxed option to the flat Themes sections and apply it to Your Themes,
Auto-Switch Theme and Import (the theme store stays unboxed). Drop the inner
settings-card from InstalledThemes, ThemeImportSection and the scheduler so
the SettingsGroup is the only frame.
* fix(settings): give the Themes boxes a card carrier and accent header
The flat Themes sections had no settings-card behind them, so the bg-app
SettingsGroup had no contrast against the page. Box each above-store section
as settings-card > titled SettingsGroup (accent header) like the rest.
* fix(settings): clean up the server library sync status line
Collapse the offline status to a single icon + phrase ("Server offline —
sync deferred") instead of a redundant "Deferred" badge alongside it, and
drop the now-unused string across all 9 locales.
* fix(queue): keep toolbar toggle buttons coloured active while hovered
The generic .queue-round-btn:hover rule outweighed .active in specificity, so
an active button kept the hover colour until the pointer left — the toggle
only looked on after moving the mouse away. Add an explicit active-hover rule
(mirroring the mini player) so active wins immediately on click.
* fix(settings): fix Lyrics triple title, drop dead Discord cover keys
- Remove LyricsSourcesCustomizer's own section header (the subsection already
titles it) and make its inner cards plain; the Lyrics boxes are now title-less.
- Drop the dead discordCoverSource/discordCoverSourceDesc i18n keys (superseded
by discordCoverTitle/Desc) and un-jam the libraryIndexServer status line in
all 9 locales.
* refactor(settings): drop redundant group titles and double frames
Single-group sections duplicated the subsection title in the group header — make
those groups title-less so the subsection header is the only title. A card whose
sole child is one group now collapses via CSS so single-group sections render a
single frame (matching the card-less Lyrics layout) instead of a card-in-group
double border. Multi-group cards keep their frame as the grouping container.
* fix(settings): keep the flat Themes cards framed when collapsing single-group cards
The single-group card collapse also flattened the Themes section cards, which —
not being inside an accordion — are themselves the contrast surface. Exclude
.themes-section descendants from the collapse.
* refactor(settings): use SettingsToggle for Appearance and System toggle rows
Replace the hand-rolled toggle-row markup in the Appearance visual options and
System tray/Linux/changelog rows with the shared SettingsToggle component, and
extend it with a searchText prop and ReactNode descriptions for the remaining
call sites.
* refactor(settings): roll SettingsToggle out to Integrations, Audio and Storage
Replace hand-rolled toggle rows in the Discord/Bandsintown/Now-Playing,
Hi-Res, hot cache and playback behaviour/rate sections with SettingsToggle.
Make its label optional (desc-only rows whose title is the group header) and
add searchText/id pass-throughs.
* refactor(settings): use SettingsToggle for the remaining standard toggle rows
Track previews master, sidebar lyrics style, YouLyPlus/static-only, queue
display mode and the sidebar split-nav/now-playing/lucky-mix rows now use the
shared component. Genuinely custom rows (drag lists, the skip-star threshold,
the AudioMuse row with its inline link, the fine-step advanced badge) stay
hand-rolled.
* fix(settings): drop duplicate titles on Hi-Res/Bandsintown/Now-Playing, un-jam locale line
These single-toggle sections kept a group title identical to their subsection
header; once the single-group card collapses the title rendered twice stacked.
Make them title-less (the subsection header is the label). Also split the
discordCoverTitle locale entry onto its own line in all 9 locales.
* feat(settings): restore section icons in boxed Themes headers
SettingsGroup gained an optional accent icon slot rendered before the
title; ThemesTab forwards the Palette/Clock/Upload icons the boxed
sections lost when they moved off the bespoke <h2> header.
* style(settings): re-indent SettingsGroup children to nesting level
Pure whitespace: children wrapped in SettingsGroup were left at their
pre-wrap indent. No content change (git diff -w is empty).
* fix(settings): clarify Native Hi-Res Playback description
The old copy led with the disabled-state behaviour ("forces 44.1 kHz"),
which read as if the toggle itself locked output to 44.1 kHz. Describe
what enabling actually does: play each track at its native sample rate
(reconfiguring the output device to match) instead of resampling to
44.1 kHz. Updated across all 9 locales.
* docs: changelog + what's-new for the settings layout refactor (#1126)
This commit is contained in:
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { save as saveDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { writeFile } from '@tauri-apps/plugin-fs';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import { SettingsGroup } from './SettingsGroup';
|
||||
import { useAnalysisStrategyStore } from '../../store/analysisStrategyStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import {
|
||||
@@ -253,11 +254,12 @@ export default function AnalyticsStrategySection() {
|
||||
icon={<BarChart3 size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<SettingsGroup>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 560 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
<th style={{ textAlign: 'left', padding: '8px 10px', paddingLeft: 0, fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('settings.analyticsStrategyServerLabel')}
|
||||
</th>
|
||||
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
@@ -284,7 +286,7 @@ export default function AnalyticsStrategySection() {
|
||||
const label = serverListDisplayLabel(server, servers);
|
||||
return (
|
||||
<tr key={server.id} style={{ borderTop: '1px solid var(--border-subtle, rgba(255,255,255,0.06))' }}>
|
||||
<td style={{ padding: '10px', fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
<td style={{ padding: '10px', paddingLeft: 0, fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
{label}
|
||||
</td>
|
||||
<td style={{ padding: '10px' }}>
|
||||
@@ -365,7 +367,7 @@ export default function AnalyticsStrategySection() {
|
||||
key={serverId}
|
||||
style={{ borderTop: '1px solid var(--border-subtle, rgba(255,255,255,0.06))' }}
|
||||
>
|
||||
<td style={{ padding: '10px', fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
<td style={{ padding: '10px', paddingLeft: 0, fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
<div>{serverId}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--warning, #f59e0b)', marginTop: 2 }}>
|
||||
{t('settings.analyticsStrategyServerRemoved')}
|
||||
@@ -432,6 +434,7 @@ export default function AnalyticsStrategySection() {
|
||||
{t('settings.analyticsStrategyAdvancedWarning')}
|
||||
</span>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
|
||||
{clearTarget &&
|
||||
|
||||
@@ -12,6 +12,8 @@ import { useFontStore, FontId } from '../../store/fontStore';
|
||||
import { useThemeStore } from '../../store/themeStore';
|
||||
import { IS_LINUX, IS_WINDOWS } from '../../utils/platform';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import { SettingsGroup } from './SettingsGroup';
|
||||
import { SettingsToggle } from './SettingsToggle';
|
||||
import { SeekbarPreview } from '../WaveformSeekPreview';
|
||||
import WindowButtonPreview from '../WindowButtonPreview';
|
||||
|
||||
@@ -34,38 +36,40 @@ export function AppearanceTab() {
|
||||
icon={<LayoutGrid size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div className="settings-hint settings-hint-info" style={{ marginBottom: '0.75rem' }}>
|
||||
{t('settings.libraryGridMaxColumnsPerfHint')}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="settings-label" htmlFor="library-grid-max-cols">
|
||||
{t('settings.libraryGridMaxColumnsRangeLabel', {
|
||||
min: LIBRARY_GRID_MAX_COLUMNS_MIN,
|
||||
max: LIBRARY_GRID_MAX_COLUMNS_MAX,
|
||||
})}
|
||||
</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginTop: 8 }}>
|
||||
<input
|
||||
id="library-grid-max-cols"
|
||||
type="range"
|
||||
min={LIBRARY_GRID_MAX_COLUMNS_MIN}
|
||||
max={LIBRARY_GRID_MAX_COLUMNS_MAX}
|
||||
step={1}
|
||||
value={auth.libraryGridMaxColumns}
|
||||
onChange={e => auth.setLibraryGridMaxColumns(Number(e.target.value))}
|
||||
style={{ flex: 1, maxWidth: 360 }}
|
||||
aria-valuemin={LIBRARY_GRID_MAX_COLUMNS_MIN}
|
||||
aria-valuemax={LIBRARY_GRID_MAX_COLUMNS_MAX}
|
||||
aria-valuenow={auth.libraryGridMaxColumns}
|
||||
/>
|
||||
<span style={{ minWidth: 28, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{auth.libraryGridMaxColumns}
|
||||
</span>
|
||||
<SettingsGroup>
|
||||
<div className="settings-hint settings-hint-info" style={{ marginBottom: '0.75rem' }}>
|
||||
{t('settings.libraryGridMaxColumnsPerfHint')}
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: '0.75rem', lineHeight: 1.45 }}>
|
||||
{t('settings.libraryGridMaxColumnsDesc')}
|
||||
</p>
|
||||
<div className="form-group">
|
||||
<label className="settings-label" htmlFor="library-grid-max-cols">
|
||||
{t('settings.libraryGridMaxColumnsRangeLabel', {
|
||||
min: LIBRARY_GRID_MAX_COLUMNS_MIN,
|
||||
max: LIBRARY_GRID_MAX_COLUMNS_MAX,
|
||||
})}
|
||||
</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginTop: 8 }}>
|
||||
<input
|
||||
id="library-grid-max-cols"
|
||||
type="range"
|
||||
min={LIBRARY_GRID_MAX_COLUMNS_MIN}
|
||||
max={LIBRARY_GRID_MAX_COLUMNS_MAX}
|
||||
step={1}
|
||||
value={auth.libraryGridMaxColumns}
|
||||
onChange={e => auth.setLibraryGridMaxColumns(Number(e.target.value))}
|
||||
style={{ flex: 1, maxWidth: 360 }}
|
||||
aria-valuemin={LIBRARY_GRID_MAX_COLUMNS_MIN}
|
||||
aria-valuemax={LIBRARY_GRID_MAX_COLUMNS_MAX}
|
||||
aria-valuenow={auth.libraryGridMaxColumns}
|
||||
/>
|
||||
<span style={{ minWidth: 28, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{auth.libraryGridMaxColumns}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: '0.75rem', lineHeight: 1.45 }}>
|
||||
{t('settings.libraryGridMaxColumnsDesc')}
|
||||
</p>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
@@ -74,103 +78,69 @@ export function AppearanceTab() {
|
||||
icon={<Palette size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.coverArtBackground')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.coverArtBackgroundSub')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch">
|
||||
<input type="checkbox" checked={theme.enableCoverArtBackground} onChange={e => theme.setEnableCoverArtBackground(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.playlistCoverPhoto')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.playlistCoverPhotoSub')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch">
|
||||
<input type="checkbox" checked={theme.enablePlaylistCoverPhoto} onChange={e => theme.setEnablePlaylistCoverPhoto(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.showBitrate')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showBitrateSub')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch">
|
||||
<input type="checkbox" checked={theme.showBitrate} onChange={e => theme.setShowBitrate(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.floatingPlayerBar')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.floatingPlayerBarSub')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch">
|
||||
<input type="checkbox" checked={theme.floatingPlayerBar} onChange={e => theme.setFloatingPlayerBar(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.showArtistImages')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showArtistImagesDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.showArtistImages')}>
|
||||
<input type="checkbox" checked={auth.showArtistImages} onChange={e => auth.setShowArtistImages(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.showOrbitTrigger')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showOrbitTriggerDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.showOrbitTrigger')}>
|
||||
<input type="checkbox" checked={auth.showOrbitTrigger} onChange={e => auth.setShowOrbitTrigger(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
{!IS_WINDOWS && (
|
||||
<>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.preloadMiniPlayer')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.preloadMiniPlayerDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.preloadMiniPlayer')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auth.preloadMiniPlayer}
|
||||
onChange={e => auth.setPreloadMiniPlayer(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<SettingsGroup title={t('settings.groupDisplay')}>
|
||||
<SettingsToggle
|
||||
label={t('settings.coverArtBackground')}
|
||||
desc={t('settings.coverArtBackgroundSub')}
|
||||
checked={theme.enableCoverArtBackground}
|
||||
onChange={theme.setEnableCoverArtBackground}
|
||||
/>
|
||||
<div className="settings-section-divider" />
|
||||
<SettingsToggle
|
||||
label={t('settings.playlistCoverPhoto')}
|
||||
desc={t('settings.playlistCoverPhotoSub')}
|
||||
checked={theme.enablePlaylistCoverPhoto}
|
||||
onChange={theme.setEnablePlaylistCoverPhoto}
|
||||
/>
|
||||
<div className="settings-section-divider" />
|
||||
<SettingsToggle
|
||||
label={t('settings.showBitrate')}
|
||||
desc={t('settings.showBitrateSub')}
|
||||
checked={theme.showBitrate}
|
||||
onChange={theme.setShowBitrate}
|
||||
/>
|
||||
<div className="settings-section-divider" />
|
||||
<SettingsToggle
|
||||
label={t('settings.floatingPlayerBar')}
|
||||
desc={t('settings.floatingPlayerBarSub')}
|
||||
checked={theme.floatingPlayerBar}
|
||||
onChange={theme.setFloatingPlayerBar}
|
||||
/>
|
||||
<div className="settings-section-divider" />
|
||||
<SettingsToggle
|
||||
label={t('settings.showArtistImages')}
|
||||
desc={t('settings.showArtistImagesDesc')}
|
||||
checked={auth.showArtistImages}
|
||||
onChange={auth.setShowArtistImages}
|
||||
/>
|
||||
<div className="settings-section-divider" />
|
||||
<SettingsToggle
|
||||
label={t('settings.showOrbitTrigger')}
|
||||
desc={t('settings.showOrbitTriggerDesc')}
|
||||
checked={auth.showOrbitTrigger}
|
||||
onChange={auth.setShowOrbitTrigger}
|
||||
/>
|
||||
{!IS_WINDOWS && (
|
||||
<>
|
||||
<div className="settings-section-divider" />
|
||||
<SettingsToggle
|
||||
label={t('settings.preloadMiniPlayer')}
|
||||
desc={t('settings.preloadMiniPlayerDesc')}
|
||||
checked={auth.preloadMiniPlayer}
|
||||
onChange={auth.setPreloadMiniPlayer}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
|
||||
{IS_LINUX && !isTilingWm && (
|
||||
<>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.useCustomTitlebar')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.useCustomTitlebarDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.useCustomTitlebar')}>
|
||||
<input type="checkbox" checked={auth.useCustomTitlebar} onChange={e => auth.setUseCustomTitlebar(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<SettingsGroup title={t('settings.groupWindow')}>
|
||||
<SettingsToggle
|
||||
label={t('settings.useCustomTitlebar')}
|
||||
desc={t('settings.useCustomTitlebarDesc')}
|
||||
checked={auth.useCustomTitlebar}
|
||||
onChange={auth.setUseCustomTitlebar}
|
||||
/>
|
||||
{auth.useCustomTitlebar && (
|
||||
<>
|
||||
<div className="settings-section-divider" />
|
||||
@@ -192,19 +162,15 @@ export function AppearanceTab() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.showMinimizeButton')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showMinimizeButtonDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.showMinimizeButton')}>
|
||||
<input type="checkbox" checked={auth.showMinimizeButton} onChange={e => auth.setShowMinimizeButton(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<SettingsToggle
|
||||
label={t('settings.showMinimizeButton')}
|
||||
desc={t('settings.showMinimizeButtonDesc')}
|
||||
checked={auth.showMinimizeButton}
|
||||
onChange={auth.setShowMinimizeButton}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</SettingsGroup>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
@@ -214,57 +180,59 @@ export function AppearanceTab() {
|
||||
icon={<ZoomIn size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.uiScaleLabel')}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--accent)', minWidth: 40, textAlign: 'right' }}>
|
||||
{Math.round(fontStore.uiScale * 100)}%
|
||||
</span>
|
||||
<SettingsGroup>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.uiScaleLabel')}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--accent)', minWidth: 40, textAlign: 'right' }}>
|
||||
{Math.round(fontStore.uiScale * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
{(() => {
|
||||
const presets = [80, 90, 100, 110, 125, 150];
|
||||
const currentPct = Math.round(fontStore.uiScale * 100);
|
||||
let idx = presets.indexOf(currentPct);
|
||||
if (idx < 0) {
|
||||
// Snap legacy off-preset values to the closest preset.
|
||||
idx = presets.reduce((best, p, i) =>
|
||||
Math.abs(p - currentPct) < Math.abs(presets[best] - currentPct) ? i : best, 0);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={presets.length - 1}
|
||||
step={1}
|
||||
value={idx}
|
||||
onChange={e => fontStore.setUiScale(presets[parseInt(e.target.value, 10)] / 100)}
|
||||
className="ui-scale-slider"
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
{presets.map(p => {
|
||||
const active = currentPct === p;
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: '2px 6px',
|
||||
opacity: active ? 1 : 0.5,
|
||||
color: active ? 'var(--accent)' : undefined,
|
||||
}}
|
||||
onClick={() => fontStore.setUiScale(p / 100)}
|
||||
>
|
||||
{p}%
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{(() => {
|
||||
const presets = [80, 90, 100, 110, 125, 150];
|
||||
const currentPct = Math.round(fontStore.uiScale * 100);
|
||||
let idx = presets.indexOf(currentPct);
|
||||
if (idx < 0) {
|
||||
// Snap legacy off-preset values to the closest preset.
|
||||
idx = presets.reduce((best, p, i) =>
|
||||
Math.abs(p - currentPct) < Math.abs(presets[best] - currentPct) ? i : best, 0);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={presets.length - 1}
|
||||
step={1}
|
||||
value={idx}
|
||||
onChange={e => fontStore.setUiScale(presets[parseInt(e.target.value, 10)] / 100)}
|
||||
className="ui-scale-slider"
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
{presets.map(p => {
|
||||
const active = currentPct === p;
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: '2px 6px',
|
||||
opacity: active ? 1 : 0.5,
|
||||
color: active ? 'var(--accent)' : undefined,
|
||||
}}
|
||||
onClick={() => fontStore.setUiScale(p / 100)}
|
||||
>
|
||||
{p}%
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
@@ -273,47 +241,49 @@ export function AppearanceTab() {
|
||||
icon={<Type size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{(
|
||||
[
|
||||
// Accessibility-first: OpenDyslexic at the top so dyslexic
|
||||
// readers don't have to scroll past 14 sans-serifs to find it.
|
||||
{ id: 'opendyslexic', label: 'OpenDyslexic', stack: "'OpenDyslexic', sans-serif", hint: t('settings.fontHintOpenDyslexic') },
|
||||
{ id: 'inter', label: 'Inter', stack: "'Inter Variable', sans-serif" },
|
||||
{ id: 'outfit', label: 'Outfit', stack: "'Outfit Variable', sans-serif" },
|
||||
{ id: 'dm-sans', label: 'DM Sans', stack: "'DM Sans Variable', sans-serif" },
|
||||
{ id: 'nunito', label: 'Nunito', stack: "'Nunito Variable', sans-serif" },
|
||||
{ id: 'rubik', label: 'Rubik', stack: "'Rubik Variable', sans-serif" },
|
||||
{ id: 'space-grotesk', label: 'Space Grotesk', stack: "'Space Grotesk Variable', sans-serif" },
|
||||
{ id: 'figtree', label: 'Figtree', stack: "'Figtree Variable', sans-serif" },
|
||||
{ id: 'manrope', label: 'Manrope', stack: "'Manrope Variable', sans-serif" },
|
||||
{ id: 'plus-jakarta-sans', label: 'Plus Jakarta Sans', stack: "'Plus Jakarta Sans Variable', sans-serif" },
|
||||
{ id: 'lexend', label: 'Lexend', stack: "'Lexend Variable', sans-serif" },
|
||||
{ id: 'geist', label: 'Geist', stack: "'Geist Variable', sans-serif" },
|
||||
{ id: 'jetbrains-mono', label: 'JetBrains Mono', stack: "'JetBrains Mono Variable', monospace" },
|
||||
{ id: 'golos-text', label: 'Golos Text', stack: "'Golos Text Variable', sans-serif" },
|
||||
{ id: 'unbounded', label: 'Unbounded', stack: "'Unbounded Variable', sans-serif" },
|
||||
] as { id: FontId; label: string; stack: string; hint?: string }[]
|
||||
).map(f => (
|
||||
<button
|
||||
key={f.id}
|
||||
className={`btn ${fontStore.font === f.id ? 'btn-primary' : 'btn-ghost'}`}
|
||||
style={{
|
||||
justifyContent: 'flex-start',
|
||||
fontFamily: f.stack,
|
||||
...(f.hint ? { flexDirection: 'column', alignItems: 'flex-start', gap: '2px', paddingTop: '8px', paddingBottom: '8px' } : null),
|
||||
}}
|
||||
onClick={() => fontStore.setFont(f.id)}
|
||||
>
|
||||
<span>{f.label}</span>
|
||||
{f.hint && (
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', fontFamily: 'var(--font-sans)' }}>
|
||||
{f.hint}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<SettingsGroup>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{(
|
||||
[
|
||||
// Accessibility-first: OpenDyslexic at the top so dyslexic
|
||||
// readers don't have to scroll past 14 sans-serifs to find it.
|
||||
{ id: 'opendyslexic', label: 'OpenDyslexic', stack: "'OpenDyslexic', sans-serif", hint: t('settings.fontHintOpenDyslexic') },
|
||||
{ id: 'inter', label: 'Inter', stack: "'Inter Variable', sans-serif" },
|
||||
{ id: 'outfit', label: 'Outfit', stack: "'Outfit Variable', sans-serif" },
|
||||
{ id: 'dm-sans', label: 'DM Sans', stack: "'DM Sans Variable', sans-serif" },
|
||||
{ id: 'nunito', label: 'Nunito', stack: "'Nunito Variable', sans-serif" },
|
||||
{ id: 'rubik', label: 'Rubik', stack: "'Rubik Variable', sans-serif" },
|
||||
{ id: 'space-grotesk', label: 'Space Grotesk', stack: "'Space Grotesk Variable', sans-serif" },
|
||||
{ id: 'figtree', label: 'Figtree', stack: "'Figtree Variable', sans-serif" },
|
||||
{ id: 'manrope', label: 'Manrope', stack: "'Manrope Variable', sans-serif" },
|
||||
{ id: 'plus-jakarta-sans', label: 'Plus Jakarta Sans', stack: "'Plus Jakarta Sans Variable', sans-serif" },
|
||||
{ id: 'lexend', label: 'Lexend', stack: "'Lexend Variable', sans-serif" },
|
||||
{ id: 'geist', label: 'Geist', stack: "'Geist Variable', sans-serif" },
|
||||
{ id: 'jetbrains-mono', label: 'JetBrains Mono', stack: "'JetBrains Mono Variable', monospace" },
|
||||
{ id: 'golos-text', label: 'Golos Text', stack: "'Golos Text Variable', sans-serif" },
|
||||
{ id: 'unbounded', label: 'Unbounded', stack: "'Unbounded Variable', sans-serif" },
|
||||
] as { id: FontId; label: string; stack: string; hint?: string }[]
|
||||
).map(f => (
|
||||
<button
|
||||
key={f.id}
|
||||
className={`btn ${fontStore.font === f.id ? 'btn-primary' : 'btn-ghost'}`}
|
||||
style={{
|
||||
justifyContent: 'flex-start',
|
||||
fontFamily: f.stack,
|
||||
...(f.hint ? { flexDirection: 'column', alignItems: 'flex-start', gap: '2px', paddingTop: '8px', paddingBottom: '8px' } : null),
|
||||
}}
|
||||
onClick={() => fontStore.setFont(f.id)}
|
||||
>
|
||||
<span>{f.label}</span>
|
||||
{f.hint && (
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', fontFamily: 'var(--font-sans)' }}>
|
||||
{f.hint}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
@@ -322,20 +292,22 @@ export function AppearanceTab() {
|
||||
icon={<Sliders size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||||
{t('settings.seekbarStyleDesc')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
|
||||
{(['truewave', 'pseudowave', 'linedot', 'bar', 'thick', 'segmented', 'neon', 'pulsewave', 'particletrail', 'liquidfill', 'retrotape'] as SeekbarStyle[]).map(style => (
|
||||
<SeekbarPreview
|
||||
key={style}
|
||||
style={style}
|
||||
label={t(`settings.seekbar${style.charAt(0).toUpperCase() + style.slice(1)}` as any)}
|
||||
selected={auth.seekbarStyle === style}
|
||||
onClick={() => auth.setSeekbarStyle(style)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<SettingsGroup>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||||
{t('settings.seekbarStyleDesc')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
|
||||
{(['truewave', 'pseudowave', 'linedot', 'bar', 'thick', 'segmented', 'neon', 'pulsewave', 'particletrail', 'liquidfill', 'retrotape'] as SeekbarStyle[]).map(style => (
|
||||
<SeekbarPreview
|
||||
key={style}
|
||||
style={style}
|
||||
label={t(`settings.seekbar${style.charAt(0).toUpperCase() + style.slice(1)}` as any)}
|
||||
selected={auth.seekbarStyle === style}
|
||||
onClick={() => auth.setSeekbarStyle(style)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
</>
|
||||
|
||||
@@ -94,7 +94,6 @@ export function ArtistLayoutCustomizer() {
|
||||
{t('settings.artistLayoutDesc')}
|
||||
</p>
|
||||
<div
|
||||
className="settings-card"
|
||||
style={{ padding: '4px 0' }}
|
||||
ref={setContainerEl}
|
||||
onMouseMove={handleMouseMove}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Music2, Sliders, Waves } from 'lucide-react';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import Equalizer from '../Equalizer';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import { SettingsGroup } from './SettingsGroup';
|
||||
import { SettingsToggle } from './SettingsToggle';
|
||||
import { effectiveLoudnessPreAnalysisAttenuationDb } from '../../utils/audio/loudnessPreAnalysisSlider';
|
||||
import { useAudioDevicesProbe } from '../../hooks/useAudioDevicesProbe';
|
||||
import { AudioOutputDeviceSection } from './audio/AudioOutputDeviceSection';
|
||||
@@ -51,21 +53,15 @@ export function AudioTab() {
|
||||
icon={<Waves size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.hiResEnabled')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.hiResDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.hiResEnabled')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auth.enableHiRes}
|
||||
onChange={e => auth.setEnableHiRes(e.target.checked)}
|
||||
id="hires-enabled-toggle"
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<SettingsGroup>
|
||||
<SettingsToggle
|
||||
desc={t('settings.hiResDesc')}
|
||||
ariaLabel={t('settings.hiResEnabled')}
|
||||
id="hires-enabled-toggle"
|
||||
checked={auth.enableHiRes}
|
||||
onChange={auth.setEnableHiRes}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
@@ -75,7 +71,9 @@ export function AudioTab() {
|
||||
icon={<Sliders size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<Equalizer />
|
||||
<SettingsGroup>
|
||||
<Equalizer />
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
@@ -85,7 +83,9 @@ export function AudioTab() {
|
||||
icon={<Gauge size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<PlaybackRateBlock t={t} />
|
||||
<SettingsGroup>
|
||||
<PlaybackRateBlock t={t} />
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Clock3, Download, HardDrive, Upload } from 'lucide-react';
|
||||
import { Clock3, Download, Upload } from 'lucide-react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { SettingsGroup } from './SettingsGroup';
|
||||
import {
|
||||
exportBackupToPath,
|
||||
importAnyBackupFromPath,
|
||||
@@ -148,13 +149,8 @@ export function BackupSection() {
|
||||
: null;
|
||||
|
||||
return (
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<HardDrive size={18} />
|
||||
<h2>{t('settings.backupTitle')}</h2>
|
||||
</div>
|
||||
|
||||
<div className="settings-card" style={{ marginBottom: '1rem' }}>
|
||||
<div className="settings-card">
|
||||
<SettingsGroup>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginBottom: '0.85rem' }}>
|
||||
{(['full', 'library', 'config'] as BackupMode[]).map(candidate => (
|
||||
<button
|
||||
@@ -197,8 +193,8 @@ export function BackupSection() {
|
||||
{importing ? '…' : importLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
{busyOverlay}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'r
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import { SettingsGroup } from './SettingsGroup';
|
||||
import { useCoverStrategyStore } from '../../store/coverStrategyStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import {
|
||||
@@ -232,12 +233,13 @@ export default function CoverCacheStrategySection() {
|
||||
return (
|
||||
<SettingsSubSection title={t('settings.coverCacheStrategyTitle')} icon={<Image size={16} />}>
|
||||
<div className="settings-card">
|
||||
<SettingsGroup>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={TABLE_STYLE}>
|
||||
<CoverCacheColGroup />
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={TH_STYLE}>{t('settings.imageCacheScopeLabel')}</th>
|
||||
<th style={{ ...TH_STYLE, paddingLeft: 0 }}>{t('settings.imageCacheScopeLabel')}</th>
|
||||
<th style={STRATEGY_GAP_TH} aria-hidden="true" />
|
||||
<th style={TH_STYLE}>{t('settings.coverCacheStrategyProgressLabel')}</th>
|
||||
<th style={TH_STYLE}>{t('settings.coverCacheStrategyActionsLabel')}</th>
|
||||
@@ -245,7 +247,7 @@ export default function CoverCacheStrategySection() {
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style={{ padding: '10px', fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
<td style={{ padding: '10px', paddingLeft: 0, fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
{t('settings.imageCacheSubTitle')}
|
||||
</td>
|
||||
<td style={STRATEGY_GAP_TD} aria-hidden="true" />
|
||||
@@ -285,7 +287,7 @@ export default function CoverCacheStrategySection() {
|
||||
<CoverCacheColGroup />
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={TH_STYLE}>{t('settings.coverCacheStrategyServerLabel')}</th>
|
||||
<th style={{ ...TH_STYLE, paddingLeft: 0 }}>{t('settings.coverCacheStrategyServerLabel')}</th>
|
||||
<th style={TH_STYLE}>{t('settings.coverCacheStrategyLabel')}</th>
|
||||
<th style={TH_STYLE}>{t('settings.coverCacheStrategyProgressLabel')}</th>
|
||||
<th style={TH_STYLE}>{t('settings.coverCacheStrategyActionsLabel')}</th>
|
||||
@@ -299,7 +301,7 @@ export default function CoverCacheStrategySection() {
|
||||
const label = serverListDisplayLabel(server, servers);
|
||||
return (
|
||||
<tr key={server.id} style={ROW_BORDER}>
|
||||
<td style={{ padding: '10px', fontSize: 13, color: 'var(--text-primary)' }}>{label}</td>
|
||||
<td style={{ padding: '10px', paddingLeft: 0, fontSize: 13, color: 'var(--text-primary)' }}>{label}</td>
|
||||
<td style={{ padding: '10px' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||
{COVER_CACHE_STRATEGIES.map(s => (
|
||||
@@ -338,7 +340,7 @@ export default function CoverCacheStrategySection() {
|
||||
})}
|
||||
{removedServerKeys.map(key => (
|
||||
<tr key={`removed-${key}`} style={ROW_BORDER}>
|
||||
<td style={{ padding: '10px', fontSize: 13, color: 'var(--text-muted)' }}>
|
||||
<td style={{ padding: '10px', paddingLeft: 0, fontSize: 13, color: 'var(--text-muted)' }}>
|
||||
{key}
|
||||
<span style={{ marginLeft: 6, fontSize: 11 }}>({t('settings.coverCacheStrategyServerRemoved')})</span>
|
||||
</td>
|
||||
@@ -414,6 +416,7 @@ export default function CoverCacheStrategySection() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@ export function HomeCustomizer() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings-card" style={{ padding: '4px 0' }}>
|
||||
<div style={{ padding: '4px 0' }}>
|
||||
{sections.map(sec => (
|
||||
<div key={sec.id} className="sidebar-customizer-row">
|
||||
<span style={{ flex: 1, fontSize: 14 }}>{SECTION_LABELS[sec.id]}</span>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { IN_APP_SHORTCUT_ACTIONS, GLOBAL_SHORTCUT_ACTIONS } from '../../config/s
|
||||
import { useGlobalShortcutsStore, type GlobalAction, buildGlobalShortcut, formatGlobalShortcut } from '../../store/globalShortcutsStore';
|
||||
import { useKeybindingsStore, type KeyAction, buildInAppBinding, formatBinding } from '../../store/keybindingsStore';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import { SettingsGroup } from './SettingsGroup';
|
||||
|
||||
export function InputTab() {
|
||||
const { t } = useTranslation();
|
||||
@@ -32,69 +33,71 @@ export function InputTab() {
|
||||
}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
{IN_APP_SHORTCUT_ACTIONS.map(({ id: action, getLabel }) => {
|
||||
const label = getLabel(t);
|
||||
const bound = kb.bindings[action];
|
||||
const isListening = listeningFor === action;
|
||||
return (
|
||||
<div key={action} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '8px 10px', borderRadius: 'var(--radius-sm)',
|
||||
background: isListening ? 'var(--accent-dim)' : 'transparent',
|
||||
transition: 'background 0.15s',
|
||||
}}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}>{label}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isListening) { setListeningFor(null); return; }
|
||||
setListeningFor(action);
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.code === 'Escape') {
|
||||
<SettingsGroup>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
{IN_APP_SHORTCUT_ACTIONS.map(({ id: action, getLabel }) => {
|
||||
const label = getLabel(t);
|
||||
const bound = kb.bindings[action];
|
||||
const isListening = listeningFor === action;
|
||||
return (
|
||||
<div key={action} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '8px 10px', borderRadius: 'var(--radius-sm)',
|
||||
background: isListening ? 'var(--accent-dim)' : 'transparent',
|
||||
transition: 'background 0.15s',
|
||||
}}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}>{label}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isListening) { setListeningFor(null); return; }
|
||||
setListeningFor(action);
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.code === 'Escape') {
|
||||
setListeningFor(null);
|
||||
window.removeEventListener('keydown', handler, true);
|
||||
return;
|
||||
}
|
||||
const chord = buildInAppBinding(e);
|
||||
if (!chord) return;
|
||||
const existing = (Object.entries(kb.bindings) as [KeyAction, string | null][])
|
||||
.find(([, c]) => c === chord)?.[0];
|
||||
if (existing && existing !== action) kb.setBinding(existing, null);
|
||||
kb.setBinding(action, chord);
|
||||
setListeningFor(null);
|
||||
window.removeEventListener('keydown', handler, true);
|
||||
return;
|
||||
}
|
||||
const chord = buildInAppBinding(e);
|
||||
if (!chord) return;
|
||||
const existing = (Object.entries(kb.bindings) as [KeyAction, string | null][])
|
||||
.find(([, c]) => c === chord)?.[0];
|
||||
if (existing && existing !== action) kb.setBinding(existing, null);
|
||||
kb.setBinding(action, chord);
|
||||
setListeningFor(null);
|
||||
window.removeEventListener('keydown', handler, true);
|
||||
};
|
||||
window.addEventListener('keydown', handler, true);
|
||||
}}
|
||||
className="keybind-badge"
|
||||
style={{
|
||||
minWidth: 72, padding: '3px 10px', borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 12, fontWeight: 600, fontFamily: 'monospace',
|
||||
background: isListening ? 'var(--accent)' : bound ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: isListening ? 'var(--bg-app)' : bound ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
border: `1px solid ${isListening ? 'var(--accent)' : 'var(--border-subtle)'}`,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{isListening ? t('settings.shortcutListening') : bound ? formatBinding(bound) : t('settings.shortcutUnbound')}
|
||||
</button>
|
||||
{bound && !isListening && (
|
||||
<button
|
||||
onClick={() => kb.setBinding(action, null)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: '2px 4px', lineHeight: 1 }}
|
||||
data-tooltip={t('settings.shortcutClear')}
|
||||
};
|
||||
window.addEventListener('keydown', handler, true);
|
||||
}}
|
||||
className="keybind-badge"
|
||||
style={{
|
||||
minWidth: 72, padding: '3px 10px', borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 12, fontWeight: 600, fontFamily: 'monospace',
|
||||
background: isListening ? 'var(--accent)' : bound ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: isListening ? 'var(--bg-app)' : bound ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
border: `1px solid ${isListening ? 'var(--accent)' : 'var(--border-subtle)'}`,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<X size={12} />
|
||||
{isListening ? t('settings.shortcutListening') : bound ? formatBinding(bound) : t('settings.shortcutUnbound')}
|
||||
</button>
|
||||
)}
|
||||
{bound && !isListening && (
|
||||
<button
|
||||
onClick={() => kb.setBinding(action, null)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: '2px 4px', lineHeight: 1 }}
|
||||
data-tooltip={t('settings.shortcutClear')}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
@@ -116,67 +119,69 @@ export function InputTab() {
|
||||
}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
{GLOBAL_SHORTCUT_ACTIONS.map(({ id: action, getLabel }) => {
|
||||
const label = getLabel(t);
|
||||
const bound = gs.shortcuts[action] ?? null;
|
||||
const isListening = listeningForGlobal === action;
|
||||
return (
|
||||
<div key={action} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '8px 10px', borderRadius: 'var(--radius-sm)',
|
||||
background: isListening ? 'var(--accent-dim)' : 'transparent',
|
||||
transition: 'background 0.15s',
|
||||
}}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}>{label}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isListening) { setListeningForGlobal(null); return; }
|
||||
setListeningForGlobal(action);
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.code === 'Escape') {
|
||||
setListeningForGlobal(null);
|
||||
window.removeEventListener('keydown', handler, true);
|
||||
return;
|
||||
}
|
||||
const shortcut = buildGlobalShortcut(e);
|
||||
if (shortcut) {
|
||||
gs.setShortcut(action, shortcut);
|
||||
setListeningForGlobal(null);
|
||||
window.removeEventListener('keydown', handler, true);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler, true);
|
||||
}}
|
||||
className="keybind-badge"
|
||||
style={{
|
||||
minWidth: 120, padding: '3px 10px', borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 12, fontWeight: 600, fontFamily: 'monospace',
|
||||
background: isListening ? 'var(--accent)' : bound ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: isListening ? 'var(--bg-app)' : bound ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
border: `1px solid ${isListening ? 'var(--accent)' : 'var(--border-subtle)'}`,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{isListening ? t('settings.shortcutListening') : bound ? formatGlobalShortcut(bound) : t('settings.shortcutUnbound')}
|
||||
</button>
|
||||
{bound && !isListening && (
|
||||
<SettingsGroup>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
{GLOBAL_SHORTCUT_ACTIONS.map(({ id: action, getLabel }) => {
|
||||
const label = getLabel(t);
|
||||
const bound = gs.shortcuts[action] ?? null;
|
||||
const isListening = listeningForGlobal === action;
|
||||
return (
|
||||
<div key={action} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '8px 10px', borderRadius: 'var(--radius-sm)',
|
||||
background: isListening ? 'var(--accent-dim)' : 'transparent',
|
||||
transition: 'background 0.15s',
|
||||
}}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}>{label}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<button
|
||||
onClick={() => gs.setShortcut(action, null)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: '2px 4px', lineHeight: 1 }}
|
||||
data-tooltip={t('settings.shortcutClear')}
|
||||
onClick={() => {
|
||||
if (isListening) { setListeningForGlobal(null); return; }
|
||||
setListeningForGlobal(action);
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.code === 'Escape') {
|
||||
setListeningForGlobal(null);
|
||||
window.removeEventListener('keydown', handler, true);
|
||||
return;
|
||||
}
|
||||
const shortcut = buildGlobalShortcut(e);
|
||||
if (shortcut) {
|
||||
gs.setShortcut(action, shortcut);
|
||||
setListeningForGlobal(null);
|
||||
window.removeEventListener('keydown', handler, true);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler, true);
|
||||
}}
|
||||
className="keybind-badge"
|
||||
style={{
|
||||
minWidth: 120, padding: '3px 10px', borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 12, fontWeight: 600, fontFamily: 'monospace',
|
||||
background: isListening ? 'var(--accent)' : bound ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: isListening ? 'var(--bg-app)' : bound ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
border: `1px solid ${isListening ? 'var(--accent)' : 'var(--border-subtle)'}`,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<X size={12} />
|
||||
{isListening ? t('settings.shortcutListening') : bound ? formatGlobalShortcut(bound) : t('settings.shortcutUnbound')}
|
||||
</button>
|
||||
)}
|
||||
{bound && !isListening && (
|
||||
<button
|
||||
onClick={() => gs.setShortcut(action, null)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: '2px 4px', lineHeight: 1 }}
|
||||
data-tooltip={t('settings.shortcutClear')}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
</>
|
||||
|
||||
@@ -71,7 +71,7 @@ export function InstalledThemes() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="settings-card">
|
||||
<div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(72px, 1fr))', gap: '10px' }}>
|
||||
{cards.map(c => {
|
||||
const isActive = active === c.id;
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { AlertTriangle, Info, Sparkles, Wifi } from 'lucide-react';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import { SettingsGroup } from './SettingsGroup';
|
||||
import { SettingsToggle } from './SettingsToggle';
|
||||
import { MusicNetworkSection } from './musicNetwork/MusicNetworkSection';
|
||||
|
||||
export function IntegrationsTab() {
|
||||
@@ -41,55 +43,37 @@ export function IntegrationsTab() {
|
||||
>
|
||||
{t('settings.discordRichPresenceNotice')}
|
||||
</div>
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.discordRichPresence')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.discordRichPresenceDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.discordRichPresence')}>
|
||||
<input type="checkbox" checked={auth.discordRichPresence} onChange={e => auth.setDiscordRichPresence(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<SettingsGroup title={t('settings.discordRichPresence')}>
|
||||
<SettingsToggle
|
||||
desc={t('settings.discordRichPresenceDesc')}
|
||||
ariaLabel={t('settings.discordRichPresence')}
|
||||
checked={auth.discordRichPresence}
|
||||
onChange={auth.setDiscordRichPresence}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
{auth.discordRichPresence && (
|
||||
<>
|
||||
<div className="settings-toggle-row" style={{ padding: '4px var(--space-3) 4px var(--space-6)', fontSize: 13 }}>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.discordCoverNone')}</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.discordCoverNone')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auth.discordCoverSource === 'none'}
|
||||
onChange={e => auth.setDiscordCoverSource(e.target.checked ? 'none' : 'server')}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-toggle-row" style={{ padding: '4px var(--space-3) 4px var(--space-6)', fontSize: 13 }}>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.discordCoverServer')}</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.discordCoverServer')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auth.discordCoverSource === 'server'}
|
||||
onChange={e => auth.setDiscordCoverSource(e.target.checked ? 'server' : 'none')}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-toggle-row" style={{ padding: '4px var(--space-3) 4px var(--space-6)', fontSize: 13 }}>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.discordCoverApple')}</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.discordCoverApple')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auth.discordCoverSource === 'apple'}
|
||||
onChange={e => auth.setDiscordCoverSource(e.target.checked ? 'apple' : 'none')}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div style={{ paddingTop: 8 }}>
|
||||
<div style={{ fontWeight: 500, fontSize: 13, marginBottom: 8 }}>{t('settings.discordTemplates')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 12 }}>{t('settings.discordTemplatesDesc')}</div>
|
||||
<SettingsGroup title={t('settings.discordCoverTitle')} desc={t('settings.discordCoverDesc')}>
|
||||
<SettingsToggle
|
||||
label={t('settings.discordCoverNone')}
|
||||
checked={auth.discordCoverSource === 'none'}
|
||||
onChange={c => auth.setDiscordCoverSource(c ? 'none' : 'server')}
|
||||
/>
|
||||
<div className="settings-section-divider" />
|
||||
<SettingsToggle
|
||||
label={t('settings.discordCoverServer')}
|
||||
checked={auth.discordCoverSource === 'server'}
|
||||
onChange={c => auth.setDiscordCoverSource(c ? 'server' : 'none')}
|
||||
/>
|
||||
<div className="settings-section-divider" />
|
||||
<SettingsToggle
|
||||
label={t('settings.discordCoverApple')}
|
||||
checked={auth.discordCoverSource === 'apple'}
|
||||
onChange={c => auth.setDiscordCoverSource(c ? 'apple' : 'none')}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup title={t('settings.discordTemplates')} desc={t('settings.discordTemplatesDesc')}>
|
||||
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
|
||||
<label style={{ fontSize: 12 }}>{t('settings.discordTemplateName')}</label>
|
||||
<input
|
||||
@@ -130,7 +114,7 @@ export function IntegrationsTab() {
|
||||
placeholder="{album}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -142,16 +126,14 @@ export function IntegrationsTab() {
|
||||
icon={<Info size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.enableBandsintown')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.enableBandsintownDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.enableBandsintown')}>
|
||||
<input type="checkbox" checked={auth.enableBandsintown} onChange={e => auth.setEnableBandsintown(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<SettingsGroup>
|
||||
<SettingsToggle
|
||||
desc={t('settings.enableBandsintownDesc')}
|
||||
ariaLabel={t('settings.enableBandsintown')}
|
||||
checked={auth.enableBandsintown}
|
||||
onChange={auth.setEnableBandsintown}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
@@ -161,17 +143,15 @@ export function IntegrationsTab() {
|
||||
icon={<Wifi size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.nowPlayingEnabled')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.nowPlayingEnabledDesc')}</div>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-secondary)', marginTop: 4 }}>{t('settings.nowPlayingPluginNote')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.nowPlayingEnabled')}>
|
||||
<input type="checkbox" checked={auth.nowPlayingEnabled} onChange={e => auth.setNowPlayingEnabled(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<SettingsGroup>
|
||||
<SettingsToggle
|
||||
desc={t('settings.nowPlayingEnabledDesc')}
|
||||
note={t('settings.nowPlayingPluginNote')}
|
||||
ariaLabel={t('settings.nowPlayingEnabled')}
|
||||
checked={auth.nowPlayingEnabled}
|
||||
onChange={auth.setNowPlayingEnabled}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
</>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Shuffle, Star } from 'lucide-react';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { MIX_MIN_RATING_FILTER_MAX_STARS } from '../../store/authStoreDefaults';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import { SettingsGroup } from './SettingsGroup';
|
||||
import { SettingsToggle } from './SettingsToggle';
|
||||
import StarRating from '../StarRating';
|
||||
import AnalyticsStrategySection from './AnalyticsStrategySection';
|
||||
const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspiel', 'Audiobook', 'Audio Book', 'Spoken Word', 'Spokenword', 'Podcast', 'Kapitel', 'Thriller', 'Krimi', 'Speech', 'Fantasy', 'Comedy', 'Literature'];
|
||||
@@ -23,99 +25,80 @@ export function LibraryTab() {
|
||||
icon={<Shuffle size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '1rem', lineHeight: 1.5 }}>
|
||||
{t('settings.randomMixBlacklistDesc')}
|
||||
</p>
|
||||
<SettingsGroup>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '1rem', lineHeight: 1.5 }}>
|
||||
{t('settings.randomMixBlacklistDesc')}
|
||||
</p>
|
||||
|
||||
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: '0.5rem' }}>{t('settings.randomMixBlacklistTitle')}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginBottom: '0.75rem', minHeight: 32 }}>
|
||||
{auth.customGenreBlacklist.length === 0 ? (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', alignSelf: 'center' }}>{t('settings.randomMixBlacklistEmpty')}</span>
|
||||
) : (
|
||||
auth.customGenreBlacklist.map(genre => (
|
||||
<span key={genre} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
background: 'color-mix(in srgb, var(--accent) 15%, transparent)',
|
||||
color: 'var(--accent)', borderRadius: 'var(--radius-sm)',
|
||||
padding: '2px 8px', fontSize: 12, fontWeight: 500,
|
||||
}}>
|
||||
{genre}
|
||||
<button
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'inherit', padding: 0, lineHeight: 1, fontSize: 14 }}
|
||||
onClick={() => auth.setCustomGenreBlacklist(auth.customGenreBlacklist.filter(g => g !== genre))}
|
||||
aria-label={`Remove ${genre}`}
|
||||
>×</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: '0.5rem' }}>{t('settings.randomMixBlacklistTitle')}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginBottom: '0.75rem', minHeight: 32 }}>
|
||||
{auth.customGenreBlacklist.length === 0 ? (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', alignSelf: 'center' }}>{t('settings.randomMixBlacklistEmpty')}</span>
|
||||
) : (
|
||||
auth.customGenreBlacklist.map(genre => (
|
||||
<span key={genre} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
background: 'color-mix(in srgb, var(--accent) 15%, transparent)',
|
||||
color: 'var(--accent)', borderRadius: 'var(--radius-sm)',
|
||||
padding: '2px 8px', fontSize: 12, fontWeight: 500,
|
||||
}}>
|
||||
{genre}
|
||||
<button
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'inherit', padding: 0, lineHeight: 1, fontSize: 14 }}
|
||||
onClick={() => auth.setCustomGenreBlacklist(auth.customGenreBlacklist.filter(g => g !== genre))}
|
||||
aria-label={`Remove ${genre}`}
|
||||
>×</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', maxWidth: 400 }}>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={newGenre}
|
||||
onChange={e => setNewGenre(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && newGenre.trim()) {
|
||||
<div style={{ display: 'flex', gap: '0.5rem', maxWidth: 400 }}>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={newGenre}
|
||||
onChange={e => setNewGenre(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && newGenre.trim()) {
|
||||
const trimmed = newGenre.trim();
|
||||
if (!auth.customGenreBlacklist.includes(trimmed)) {
|
||||
auth.setCustomGenreBlacklist([...auth.customGenreBlacklist, trimmed]);
|
||||
}
|
||||
setNewGenre('');
|
||||
}
|
||||
}}
|
||||
placeholder={t('settings.randomMixBlacklistPlaceholder')}
|
||||
style={{ fontSize: 13 }}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => {
|
||||
const trimmed = newGenre.trim();
|
||||
if (!auth.customGenreBlacklist.includes(trimmed)) {
|
||||
if (trimmed && !auth.customGenreBlacklist.includes(trimmed)) {
|
||||
auth.setCustomGenreBlacklist([...auth.customGenreBlacklist, trimmed]);
|
||||
}
|
||||
setNewGenre('');
|
||||
}
|
||||
}}
|
||||
placeholder={t('settings.randomMixBlacklistPlaceholder')}
|
||||
style={{ fontSize: 13 }}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => {
|
||||
const trimmed = newGenre.trim();
|
||||
if (trimmed && !auth.customGenreBlacklist.includes(trimmed)) {
|
||||
auth.setCustomGenreBlacklist([...auth.customGenreBlacklist, trimmed]);
|
||||
}
|
||||
setNewGenre('');
|
||||
}}
|
||||
disabled={!newGenre.trim()}
|
||||
>
|
||||
{t('settings.randomMixBlacklistAdd')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="divider" style={{ margin: '1rem 0' }} />
|
||||
|
||||
<div className="settings-toggle-row" style={{ marginBottom: '1rem' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.luckyMixMenuTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('settings.luckyMixMenuDesc')}
|
||||
</div>
|
||||
}}
|
||||
disabled={!newGenre.trim()}
|
||||
>
|
||||
{t('settings.randomMixBlacklistAdd')}
|
||||
</button>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.luckyMixMenuTitle')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auth.showLuckyMixMenu}
|
||||
onChange={e => auth.setShowLuckyMixMenu(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="divider" style={{ margin: '1rem 0' }} />
|
||||
|
||||
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: '0.5rem', color: 'var(--text-muted)' }}>{t('settings.randomMixHardcodedTitle')}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
|
||||
{AUDIOBOOK_GENRES_DISPLAY.map(genre => (
|
||||
<span key={genre} className="genre-keyword-badge" style={{
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
background: 'var(--bg-hover)', color: 'var(--text-muted)',
|
||||
borderRadius: 'var(--radius-sm)', padding: '2px 8px', fontSize: 12,
|
||||
}}>
|
||||
{genre}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, margin: '1rem 0 0.5rem', color: 'var(--text-muted)' }}>{t('settings.randomMixHardcodedTitle')}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
|
||||
{AUDIOBOOK_GENRES_DISPLAY.map(genre => (
|
||||
<span key={genre} className="genre-keyword-badge" style={{
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
background: 'var(--bg-hover)', color: 'var(--text-muted)',
|
||||
borderRadius: 'var(--radius-sm)', padding: '2px 8px', fontSize: 12,
|
||||
}}>
|
||||
{genre}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
@@ -125,101 +108,93 @@ export function LibraryTab() {
|
||||
icon={<Star size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.ratingsSkipStarTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.ratingsSkipStarDesc')}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
|
||||
{auth.skipStarOnManualSkipsEnabled && (
|
||||
<>
|
||||
<label htmlFor="settings-skip-star-threshold" style={{ fontSize: 13, color: 'var(--text-secondary)', whiteSpace: 'nowrap' }}>
|
||||
{t('settings.ratingsSkipStarThresholdLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="settings-skip-star-threshold"
|
||||
className="input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={99}
|
||||
value={auth.skipStarManualSkipThreshold}
|
||||
onChange={e => auth.setSkipStarManualSkipThreshold(Number(e.target.value))}
|
||||
style={{ width: 72, padding: '6px 10px', fontSize: 13 }}
|
||||
aria-label={t('settings.ratingsSkipStarThresholdLabel')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<label className="toggle-switch" aria-label={t('settings.ratingsSkipStarTitle')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auth.skipStarOnManualSkipsEnabled}
|
||||
onChange={e => auth.setSkipStarOnManualSkipsEnabled(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section-divider" />
|
||||
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.ratingsMixFilterTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('settings.ratingsMixFilterDesc', {
|
||||
mix: t('sidebar.randomMix'),
|
||||
albums: t('sidebar.randomAlbums'),
|
||||
})}
|
||||
<SettingsGroup>
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.ratingsSkipStarTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.ratingsSkipStarDesc')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.ratingsMixFilterTitle')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auth.mixMinRatingFilterEnabled}
|
||||
onChange={e => auth.setMixMinRatingFilterEnabled(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
{auth.mixMinRatingFilterEnabled && (
|
||||
<>
|
||||
<div className="settings-section-divider" />
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(90px, 1fr))',
|
||||
gap: '1rem 0.75rem',
|
||||
alignItems: 'start',
|
||||
}}
|
||||
>
|
||||
{([
|
||||
{ key: 'song', label: t('settings.ratingsMixMinSong'), value: auth.mixMinRatingSong, set: auth.setMixMinRatingSong },
|
||||
{ key: 'album', label: t('settings.ratingsMixMinAlbum'), value: auth.mixMinRatingAlbum, set: auth.setMixMinRatingAlbum },
|
||||
{ key: 'artist', label: t('settings.ratingsMixMinArtist'), value: auth.mixMinRatingArtist, set: auth.setMixMinRatingArtist },
|
||||
] as const).map(row => (
|
||||
<div
|
||||
key={row.key}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
minWidth: 0,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)' }}>{row.label}</span>
|
||||
<StarRating
|
||||
maxSelectable={MIX_MIN_RATING_FILTER_MAX_STARS}
|
||||
value={row.value}
|
||||
onChange={row.set}
|
||||
ariaLabel={t('settings.ratingsMixMinThresholdAria', { label: row.label })}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
|
||||
{auth.skipStarOnManualSkipsEnabled && (
|
||||
<>
|
||||
<label htmlFor="settings-skip-star-threshold" style={{ fontSize: 13, color: 'var(--text-secondary)', whiteSpace: 'nowrap' }}>
|
||||
{t('settings.ratingsSkipStarThresholdLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="settings-skip-star-threshold"
|
||||
className="input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={99}
|
||||
value={auth.skipStarManualSkipThreshold}
|
||||
onChange={e => auth.setSkipStarManualSkipThreshold(Number(e.target.value))}
|
||||
style={{ width: 72, padding: '6px 10px', fontSize: 13 }}
|
||||
aria-label={t('settings.ratingsSkipStarThresholdLabel')}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<label className="toggle-switch" aria-label={t('settings.ratingsSkipStarTitle')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auth.skipStarOnManualSkipsEnabled}
|
||||
onChange={e => auth.setSkipStarOnManualSkipsEnabled(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="settings-section-divider" />
|
||||
|
||||
<SettingsToggle
|
||||
label={t('settings.ratingsMixFilterTitle')}
|
||||
desc={t('settings.ratingsMixFilterDesc', {
|
||||
mix: t('sidebar.randomMix'),
|
||||
albums: t('sidebar.randomAlbums'),
|
||||
})}
|
||||
checked={auth.mixMinRatingFilterEnabled}
|
||||
onChange={auth.setMixMinRatingFilterEnabled}
|
||||
/>
|
||||
{auth.mixMinRatingFilterEnabled && (
|
||||
<>
|
||||
<div className="settings-section-divider" />
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(90px, 1fr))',
|
||||
gap: '1rem 0.75rem',
|
||||
alignItems: 'start',
|
||||
}}
|
||||
>
|
||||
{([
|
||||
{ key: 'song', label: t('settings.ratingsMixMinSong'), value: auth.mixMinRatingSong, set: auth.setMixMinRatingSong },
|
||||
{ key: 'album', label: t('settings.ratingsMixMinAlbum'), value: auth.mixMinRatingAlbum, set: auth.setMixMinRatingAlbum },
|
||||
{ key: 'artist', label: t('settings.ratingsMixMinArtist'), value: auth.mixMinRatingArtist, set: auth.setMixMinRatingArtist },
|
||||
] as const).map(row => (
|
||||
<div
|
||||
key={row.key}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
minWidth: 0,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)' }}>{row.label}</span>
|
||||
<StarRating
|
||||
maxSelectable={MIX_MIN_RATING_FILTER_MAX_STARS}
|
||||
value={row.value}
|
||||
onChange={row.set}
|
||||
ariaLabel={t('settings.ratingsMixMinThresholdAria', { label: row.label })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
</>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { GripVertical, Music2 } from 'lucide-react';
|
||||
import { GripVertical } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useDragDrop, useDragSource } from '../../contexts/DragDropContext';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import type { LyricsSourceId } from '../../store/authStoreTypes';
|
||||
import { SettingsToggle } from './SettingsToggle';
|
||||
|
||||
const LYRICS_SOURCE_LABEL_KEYS: Record<LyricsSourceId, string> = {
|
||||
server: 'settings.lyricsSourceServer',
|
||||
@@ -98,11 +99,7 @@ export function LyricsSourcesCustomizer() {
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Music2 size={18} />
|
||||
<h2>{t('settings.lyricsSourcesTitle')}</h2>
|
||||
</div>
|
||||
<>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '0.75rem', lineHeight: 1.5 }}>
|
||||
{t('settings.lyricsSourcesDesc')}
|
||||
</p>
|
||||
@@ -110,28 +107,19 @@ export function LyricsSourcesCustomizer() {
|
||||
{/* YouLyPlus (karaoke) — independent toggle. When on it is tried first and
|
||||
the enabled sources below act as fallback; when off only those sources
|
||||
are used. YouLyPlus off + every source off = lyrics fully disabled. */}
|
||||
<div className="settings-card" style={{ marginBottom: '0.75rem' }}>
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.lyricsYouLyPlus')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.lyricsYouLyPlusDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.lyricsYouLyPlus')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={youLyPlusEnabled}
|
||||
onChange={e => setYouLyPlusEnabled(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ marginBottom: '0.75rem' }}>
|
||||
<SettingsToggle
|
||||
label={t('settings.lyricsYouLyPlus')}
|
||||
desc={t('settings.lyricsYouLyPlusDesc')}
|
||||
checked={youLyPlusEnabled}
|
||||
onChange={setYouLyPlusEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="playback-rate-derived" style={{ fontSize: 12, color: 'var(--text-muted)', margin: '0 0 0.4rem' }}>
|
||||
{youLyPlusEnabled ? t('settings.lyricsSourcesFallbackHint') : t('settings.lyricsSourcesPrimaryHint')}
|
||||
</div>
|
||||
<div
|
||||
className="settings-card"
|
||||
style={{ padding: '4px 0', marginBottom: '0.75rem' }}
|
||||
ref={setContainerEl}
|
||||
onMouseMove={handleMouseMove}
|
||||
@@ -162,18 +150,14 @@ export function LyricsSourcesCustomizer() {
|
||||
</div>
|
||||
|
||||
{/* Static-only toggle — suppresses line/word tracking in both modes. */}
|
||||
<div className="settings-card" style={{ marginBottom: '0.75rem' }}>
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.lyricsStaticOnly')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.lyricsStaticOnlyDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.lyricsStaticOnly')}>
|
||||
<input type="checkbox" checked={lyricsStaticOnly} onChange={e => setLyricsStaticOnly(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ marginBottom: '0.75rem' }}>
|
||||
<SettingsToggle
|
||||
label={t('settings.lyricsStaticOnly')}
|
||||
desc={t('settings.lyricsStaticOnlyDesc')}
|
||||
checked={lyricsStaticOnly}
|
||||
onChange={setLyricsStaticOnly}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { AudioLines, Music2 } from 'lucide-react';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import { SettingsGroup } from './SettingsGroup';
|
||||
import { SettingsToggle } from './SettingsToggle';
|
||||
import { LyricsSourcesCustomizer } from './LyricsSourcesCustomizer';
|
||||
|
||||
export function LyricsTab() {
|
||||
@@ -15,37 +17,32 @@ export function LyricsTab() {
|
||||
title={t('settings.lyricsSourcesTitle')}
|
||||
icon={<Music2 size={16} />}
|
||||
>
|
||||
<LyricsSourcesCustomizer />
|
||||
<SettingsGroup>
|
||||
<LyricsSourcesCustomizer />
|
||||
</SettingsGroup>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection
|
||||
title={t('settings.sidebarLyricsStyle')}
|
||||
icon={<AudioLines size={16} />}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{(['classic', 'apple'] as const).map(style => {
|
||||
<SettingsGroup>
|
||||
{(['classic', 'apple'] as const).map((style, i) => {
|
||||
const key = style === 'classic' ? 'Classic' : 'Apple';
|
||||
const other = style === 'classic' ? 'apple' : 'classic';
|
||||
return (
|
||||
<div key={style} className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t(`settings.sidebarLyricsStyle${key}` as any)}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t(`settings.sidebarLyricsStyle${key}Desc` as any)}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t(`settings.sidebarLyricsStyle${key}` as any)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sidebarLyricsStyle === style}
|
||||
onChange={e => setSidebarLyricsStyle(e.target.checked ? style : other)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div key={style}>
|
||||
{i > 0 && <div className="settings-section-divider" />}
|
||||
<SettingsToggle
|
||||
label={t(`settings.sidebarLyricsStyle${key}` as any)}
|
||||
desc={t(`settings.sidebarLyricsStyle${key}Desc` as any)}
|
||||
checked={sidebarLyricsStyle === style}
|
||||
onChange={c => setSidebarLyricsStyle(c ? style : other)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</SettingsSubSection>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,8 @@ import { usePlaylistLayoutStore } from '../../store/playlistLayoutStore';
|
||||
import { useQueueToolbarStore } from '../../store/queueToolbarStore';
|
||||
import { useSidebarStore } from '../../store/sidebarStore';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import { SettingsGroup } from './SettingsGroup';
|
||||
import { SettingsToggle } from './SettingsToggle';
|
||||
import { ArtistLayoutCustomizer } from './ArtistLayoutCustomizer';
|
||||
import { HomeCustomizer } from './HomeCustomizer';
|
||||
import { PlayerBarLayoutCustomizer } from './PlayerBarLayoutCustomizer';
|
||||
@@ -37,7 +39,9 @@ export function PersonalisationTab() {
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<SidebarCustomizer />
|
||||
<SettingsGroup>
|
||||
<SidebarCustomizer />
|
||||
</SettingsGroup>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection
|
||||
@@ -56,7 +60,9 @@ export function PersonalisationTab() {
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<HomeCustomizer />
|
||||
<SettingsGroup>
|
||||
<HomeCustomizer />
|
||||
</SettingsGroup>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection
|
||||
@@ -76,7 +82,9 @@ export function PersonalisationTab() {
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<ArtistLayoutCustomizer />
|
||||
<SettingsGroup>
|
||||
<ArtistLayoutCustomizer />
|
||||
</SettingsGroup>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection
|
||||
@@ -84,53 +92,31 @@ export function PersonalisationTab() {
|
||||
icon={<ListOrdered size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
{/* Three mutually exclusive modes — exactly one is always active, so
|
||||
turning one on turns the others off; the active one cannot be
|
||||
switched off directly (ignore the uncheck). */}
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('queue.title')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.queueModeQueueSub')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('queue.title')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={queueDisplayMode === 'queue'}
|
||||
onChange={e => { if (e.target.checked) setQueueDisplayMode('queue'); }}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('queue.modePlaylist')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.queueModePlaylistSub')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('queue.modePlaylist')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={queueDisplayMode === 'playlist'}
|
||||
onChange={e => { if (e.target.checked) setQueueDisplayMode('playlist'); }}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('queue.modeTimeline')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.queueModeTimelineSub')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('queue.modeTimeline')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={queueDisplayMode === 'timeline'}
|
||||
onChange={e => { if (e.target.checked) setQueueDisplayMode('timeline'); }}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<SettingsGroup>
|
||||
{/* Three mutually exclusive modes — exactly one is always active, so
|
||||
turning one on turns the others off; the active one cannot be
|
||||
switched off directly (ignore the uncheck). */}
|
||||
<SettingsToggle
|
||||
label={t('queue.title')}
|
||||
desc={t('settings.queueModeQueueSub')}
|
||||
checked={queueDisplayMode === 'queue'}
|
||||
onChange={c => { if (c) setQueueDisplayMode('queue'); }}
|
||||
/>
|
||||
<div className="settings-section-divider" />
|
||||
<SettingsToggle
|
||||
label={t('queue.modePlaylist')}
|
||||
desc={t('settings.queueModePlaylistSub')}
|
||||
checked={queueDisplayMode === 'playlist'}
|
||||
onChange={c => { if (c) setQueueDisplayMode('playlist'); }}
|
||||
/>
|
||||
<div className="settings-section-divider" />
|
||||
<SettingsToggle
|
||||
label={t('queue.modeTimeline')}
|
||||
desc={t('settings.queueModeTimelineSub')}
|
||||
checked={queueDisplayMode === 'timeline'}
|
||||
onChange={c => { if (c) setQueueDisplayMode('timeline'); }}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
@@ -151,7 +137,9 @@ export function PersonalisationTab() {
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<QueueToolbarCustomizer />
|
||||
<SettingsGroup>
|
||||
<QueueToolbarCustomizer />
|
||||
</SettingsGroup>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection
|
||||
@@ -171,7 +159,9 @@ export function PersonalisationTab() {
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<PlaylistLayoutCustomizer />
|
||||
<SettingsGroup>
|
||||
<PlaylistLayoutCustomizer />
|
||||
</SettingsGroup>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection
|
||||
@@ -191,7 +181,9 @@ export function PersonalisationTab() {
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<PlayerBarLayoutCustomizer />
|
||||
<SettingsGroup>
|
||||
<PlayerBarLayoutCustomizer />
|
||||
</SettingsGroup>
|
||||
</SettingsSubSection>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -35,7 +35,7 @@ export function PlayerBarLayoutCustomizer() {
|
||||
const toggleItem = usePlayerBarLayoutStore(s => s.toggleItem);
|
||||
|
||||
return (
|
||||
<div className="settings-card" style={{ padding: '4px 0' }}>
|
||||
<div style={{ padding: '4px 0' }}>
|
||||
{items.map((it) => {
|
||||
const label = t(PLAYER_BAR_LAYOUT_LABEL_KEYS[it.id]);
|
||||
return (
|
||||
|
||||
@@ -24,7 +24,7 @@ export function PlaylistLayoutCustomizer() {
|
||||
const toggleItem = usePlaylistLayoutStore(s => s.toggleItem);
|
||||
|
||||
return (
|
||||
<div className="settings-card" style={{ padding: '4px 0' }}>
|
||||
<div style={{ padding: '4px 0' }}>
|
||||
{items.map((it) => {
|
||||
const Icon = PLAYLIST_LAYOUT_ICONS[it.id];
|
||||
const label = t(PLAYLIST_LAYOUT_LABEL_KEYS[it.id]);
|
||||
|
||||
@@ -104,7 +104,7 @@ export function QueueToolbarCustomizer() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} onMouseMove={handleMouseMove} className="settings-card" style={{ padding: '4px 0' }}>
|
||||
<div ref={containerRef} onMouseMove={handleMouseMove} 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]);
|
||||
|
||||
@@ -63,19 +63,19 @@ export default function ServerLibraryIndexControls({
|
||||
borderTop: '1px solid color-mix(in srgb, var(--text-muted) 18%, transparent)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.45, marginBottom: '0.5rem' }}>
|
||||
{connection === 'offline' && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, marginRight: '0.5rem' }}>
|
||||
<WifiOff size={12} />
|
||||
{t('settings.libraryIndexServerDeferred')}
|
||||
<div style={{ fontSize: 12, lineHeight: 1.45, marginBottom: '0.5rem' }}>
|
||||
{connection === 'offline' ? (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5, color: 'var(--text-muted)' }}>
|
||||
<WifiOff size={12} style={{ flexShrink: 0 }} />
|
||||
{phaseLabel}
|
||||
</span>
|
||||
)}
|
||||
{busy && (
|
||||
<span style={{ color: 'var(--accent)', marginRight: '0.5rem' }}>
|
||||
{t('settings.libraryIndexServerSyncing')}
|
||||
) : busy ? (
|
||||
<span style={{ color: 'var(--accent)' }}>
|
||||
{t('settings.libraryIndexServerSyncing')} {phaseLabel}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-muted)' }}>{phaseLabel}</span>
|
||||
)}
|
||||
{phaseLabel}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
/** Accent uppercase header. Omit for a plain boxed panel (no header) —
|
||||
* used when the surrounding SettingsSubSection already names the group. */
|
||||
title?: string;
|
||||
/** Optional accent-coloured icon shown before the title (e.g. on the flat
|
||||
* Themes sections). Ignored when `title` is omitted. */
|
||||
icon?: React.ReactNode;
|
||||
/** Optional one-line description shown under the title. */
|
||||
desc?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boxed settings sub-section — a bordered panel (optionally with an accent
|
||||
* uppercase header) that sets a group of related controls apart inside a
|
||||
* settings card. Wraps the `.settings-group` styles so the look stays
|
||||
* consistent everywhere it is used (Audio, Appearance, Library, …).
|
||||
*/
|
||||
export function SettingsGroup({ title, icon, desc, children }: Props) {
|
||||
return (
|
||||
<div className="settings-group">
|
||||
{title && (
|
||||
<div className="settings-group-title">
|
||||
{icon && <span className="settings-group-title-icon">{icon}</span>}
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
<div className="settings-group-body">
|
||||
{desc && <div className="settings-group-desc">{desc}</div>}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
/** Bold label. Omit for a desc-only row whose title is the enclosing group
|
||||
* header — pass `ariaLabel` then so the switch keeps an accessible name. */
|
||||
label?: string;
|
||||
/** Muted description under the label (string, or JSX for inline links). */
|
||||
desc?: ReactNode;
|
||||
/** Bold secondary note under the description (e.g. a requirement hint). */
|
||||
note?: ReactNode;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
/** Dims the row and blocks interaction (e.g. mutually-exclusive options). */
|
||||
disabled?: boolean;
|
||||
/** Overrides the toggle's accessible name when it should differ from the label. */
|
||||
ariaLabel?: string;
|
||||
/** Indexes the row for the settings search (data-settings-search). */
|
||||
searchText?: string;
|
||||
/** Forwarded to the checkbox input (e.g. a tour/onboarding anchor). */
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard settings toggle row — a label/description on the left and a switch
|
||||
* on the right. Centralises the markup repeated across every settings tab so
|
||||
* sections only describe what they toggle, not how a toggle row looks.
|
||||
*/
|
||||
export function SettingsToggle({ label, desc, note, checked, onChange, disabled, ariaLabel, searchText, id }: Props) {
|
||||
return (
|
||||
<div className="settings-toggle-row" data-settings-search={searchText} style={disabled ? { opacity: 0.45, pointerEvents: 'none' } : undefined}>
|
||||
<div>
|
||||
{label && <div style={{ fontWeight: 500 }}>{label}</div>}
|
||||
{desc && <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{desc}</div>}
|
||||
{note && <div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-secondary)', marginTop: 4 }}>{note}</div>}
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={ariaLabel ?? label}>
|
||||
<input type="checkbox" id={id} checked={checked} disabled={disabled} onChange={e => onChange(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { useSidebarStore, SidebarItemConfig, CONSERVED_SIDEBAR_NAV_IDS } from '.
|
||||
import { useLuckyMixAvailable } from '../../hooks/useLuckyMixAvailable';
|
||||
import { ALL_NAV_ITEMS } from '../../config/navItems';
|
||||
import { applySidebarDropReorder } from '../../utils/componentHelpers/sidebarNavReorder';
|
||||
import { SettingsToggle } from './SettingsToggle';
|
||||
|
||||
type DropTarget = { idx: number; before: boolean; section: 'library' | 'system' } | null;
|
||||
|
||||
@@ -41,6 +42,8 @@ export function SidebarCustomizer() {
|
||||
const setRandomNavMode = useAuthStore(s => s.setRandomNavMode);
|
||||
const nowPlayingAtTop = useAuthStore(s => s.nowPlayingAtTop);
|
||||
const setNowPlayingAtTop = useAuthStore(s => s.setNowPlayingAtTop);
|
||||
const showLuckyMixMenu = useAuthStore(s => s.showLuckyMixMenu);
|
||||
const setShowLuckyMixMenu = useAuthStore(s => s.setShowLuckyMixMenu);
|
||||
const luckyMixBase = useLuckyMixAvailable();
|
||||
const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate';
|
||||
|
||||
@@ -125,44 +128,35 @@ export function SidebarCustomizer() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="settings-card" style={{ marginBottom: '1rem' }}>
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.randomNavSplitTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.randomNavSplitDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.randomNavSplitTitle')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={randomNavMode === 'separate'}
|
||||
onChange={e => setRandomNavMode(e.target.checked ? 'separate' : 'hub')}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-toggle-row" data-settings-search={t('settings.nowPlayingTopTitle')}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.nowPlayingTopTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.nowPlayingTopDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.nowPlayingTopTitle')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={nowPlayingAtTop}
|
||||
onChange={e => setNowPlayingAtTop(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<SettingsToggle
|
||||
label={t('settings.randomNavSplitTitle')}
|
||||
desc={t('settings.randomNavSplitDesc')}
|
||||
checked={randomNavMode === 'separate'}
|
||||
onChange={c => setRandomNavMode(c ? 'separate' : 'hub')}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label={t('settings.nowPlayingTopTitle')}
|
||||
desc={t('settings.nowPlayingTopDesc')}
|
||||
searchText={t('settings.nowPlayingTopTitle')}
|
||||
checked={nowPlayingAtTop}
|
||||
onChange={setNowPlayingAtTop}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label={t('settings.luckyMixMenuTitle')}
|
||||
desc={t('settings.luckyMixMenuDesc')}
|
||||
checked={showLuckyMixMenu}
|
||||
onChange={setShowLuckyMixMenu}
|
||||
/>
|
||||
</div>
|
||||
<div ref={containerRef} onMouseMove={handleMouseMove} style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{/* Library block */}
|
||||
<div className="settings-card" style={{ padding: '4px 0' }}>
|
||||
<div style={{ padding: '4px 0' }}>
|
||||
<div className="sidebar-customizer-block-label">{t('sidebar.library')}</div>
|
||||
{libraryItems.map((cfg, i) => renderRow(cfg, i, 'library'))}
|
||||
</div>
|
||||
{/* System block */}
|
||||
<div className="settings-card" style={{ padding: '4px 0' }}>
|
||||
<div style={{ padding: '4px 0' }}>
|
||||
<div className="sidebar-customizer-block-label">{t('sidebar.system')}</div>
|
||||
{systemItems.map((cfg, i) => renderRow(cfg, i, 'system'))}
|
||||
<div className="sidebar-customizer-fixed-hint">
|
||||
|
||||
@@ -8,6 +8,8 @@ import { countHotCacheTracks } from '../../store/hotCacheStore';
|
||||
import { useLocalPlaybackStore } from '../../store/localPlaybackStore';
|
||||
import { formatBytes, snapHotCacheMb } from '../../utils/format/formatBytes';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import { SettingsGroup } from './SettingsGroup';
|
||||
import { SettingsToggle } from './SettingsToggle';
|
||||
import CoverCacheStrategySection from './CoverCacheStrategySection';
|
||||
|
||||
export function StorageTab() {
|
||||
@@ -74,36 +76,35 @@ export function StorageTab() {
|
||||
icon={<FolderOpen size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14, lineHeight: 1.5 }}>
|
||||
{t('settings.mediaDirDesc')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
readOnly
|
||||
value={auth.mediaDir || t('settings.mediaDirDefault')}
|
||||
style={{ flex: 1, fontSize: 13, color: auth.mediaDir ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
|
||||
/>
|
||||
{auth.mediaDir && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => { auth.setMediaDir(''); refreshHotCacheSize(); }}
|
||||
data-tooltip={t('settings.mediaDirClear')}
|
||||
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
|
||||
>
|
||||
<X size={16} />
|
||||
<SettingsGroup desc={t('settings.mediaDirDesc')}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
readOnly
|
||||
value={auth.mediaDir || t('settings.mediaDirDefault')}
|
||||
style={{ flex: 1, fontSize: 13, color: auth.mediaDir ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
|
||||
/>
|
||||
{auth.mediaDir && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => { auth.setMediaDir(''); refreshHotCacheSize(); }}
|
||||
data-tooltip={t('settings.mediaDirClear')}
|
||||
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-surface" onClick={pickMediaDir} style={{ flexShrink: 0 }}>
|
||||
<FolderOpen size={16} /> {t('settings.mediaDirChange')}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-surface" onClick={pickMediaDir} style={{ flexShrink: 0 }}>
|
||||
<FolderOpen size={16} /> {t('settings.mediaDirChange')}
|
||||
</button>
|
||||
</div>
|
||||
{auth.mediaDir && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 8, lineHeight: 1.4 }}>
|
||||
{t('settings.mediaDirHint')}
|
||||
</div>
|
||||
)}
|
||||
{auth.mediaDir && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 8, lineHeight: 1.4 }}>
|
||||
{t('settings.mediaDirHint')}
|
||||
</div>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
@@ -114,78 +115,72 @@ export function StorageTab() {
|
||||
icon={<Download size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.hotCacheTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.hotCacheDisclaimer')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.hotCacheEnabled')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auth.hotCacheEnabled}
|
||||
onChange={async e => {
|
||||
const enabled = e.target.checked;
|
||||
if (!enabled) {
|
||||
await clearHotCacheDisk(mediaDir);
|
||||
setHotCacheBytes(0);
|
||||
auth.setHotCacheEnabled(false);
|
||||
} else {
|
||||
auth.setHotCacheEnabled(true);
|
||||
refreshHotCacheSize();
|
||||
}
|
||||
}}
|
||||
id="hot-cache-enabled-toggle"
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{auth.hotCacheEnabled && (
|
||||
<div style={{ marginTop: '1.25rem' }}>
|
||||
<div style={{ fontSize: 12, marginBottom: 12, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<div style={{ color: 'var(--text-secondary)' }}>
|
||||
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.cacheUsedHot')}</span>
|
||||
{hotCacheBytes !== null ? formatBytes(hotCacheBytes) : '…'}
|
||||
</div>
|
||||
<div style={{ color: 'var(--text-secondary)' }}>
|
||||
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.hotCacheTrackCount')}</span>
|
||||
{hotCacheTrackCount}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: 6 }}>{t('settings.hotCacheMaxMb')}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<input type="range" min={32} max={20000} step={32} value={snapHotCacheMb(auth.hotCacheMaxMb)} onChange={e => auth.setHotCacheMaxMb(parseInt(e.target.value, 10))} style={{ flex: 1, minWidth: 80, maxWidth: 200 }} id="hot-cache-max-mb-slider" />
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 60 }}>{snapHotCacheMb(auth.hotCacheMaxMb)} MB</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<div style={{ fontWeight: 500, marginBottom: 6 }}>{t('settings.hotCacheDebounce')}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<input type="range" min={0} max={600} step={1} value={Math.min(600, Math.max(0, auth.hotCacheDebounceSec))} onChange={e => auth.setHotCacheDebounceSec(parseInt(e.target.value, 10))} style={{ flex: 1, minWidth: 80, maxWidth: 200 }} id="hot-cache-debounce-slider" />
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 80 }}>
|
||||
{Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) === 0
|
||||
? t('settings.hotCacheDebounceImmediate')
|
||||
: t('settings.hotCacheDebounceSeconds', { n: Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--border)', margin: '16px 0' }} />
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 13 }}
|
||||
onClick={async () => {
|
||||
<SettingsGroup>
|
||||
<SettingsToggle
|
||||
label={t('settings.hotCacheTitle')}
|
||||
desc={t('settings.hotCacheDisclaimer')}
|
||||
ariaLabel={t('settings.hotCacheEnabled')}
|
||||
id="hot-cache-enabled-toggle"
|
||||
checked={auth.hotCacheEnabled}
|
||||
onChange={async enabled => {
|
||||
if (!enabled) {
|
||||
await clearHotCacheDisk(mediaDir);
|
||||
setHotCacheBytes(0);
|
||||
auth.setHotCacheEnabled(false);
|
||||
} else {
|
||||
auth.setHotCacheEnabled(true);
|
||||
refreshHotCacheSize();
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} /> {t('settings.hotCacheClearBtn')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{auth.hotCacheEnabled && (
|
||||
<div style={{ marginTop: '1.25rem' }}>
|
||||
<div style={{ fontSize: 12, marginBottom: 12, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<div style={{ color: 'var(--text-secondary)' }}>
|
||||
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.cacheUsedHot')}</span>
|
||||
{hotCacheBytes !== null ? formatBytes(hotCacheBytes) : '…'}
|
||||
</div>
|
||||
<div style={{ color: 'var(--text-secondary)' }}>
|
||||
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.hotCacheTrackCount')}</span>
|
||||
{hotCacheTrackCount}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: 6 }}>{t('settings.hotCacheMaxMb')}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<input type="range" min={32} max={20000} step={32} value={snapHotCacheMb(auth.hotCacheMaxMb)} onChange={e => auth.setHotCacheMaxMb(parseInt(e.target.value, 10))} style={{ flex: 1, minWidth: 80, maxWidth: 200 }} id="hot-cache-max-mb-slider" />
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 60 }}>{snapHotCacheMb(auth.hotCacheMaxMb)} MB</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<div style={{ fontWeight: 500, marginBottom: 6 }}>{t('settings.hotCacheDebounce')}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<input type="range" min={0} max={600} step={1} value={Math.min(600, Math.max(0, auth.hotCacheDebounceSec))} onChange={e => auth.setHotCacheDebounceSec(parseInt(e.target.value, 10))} style={{ flex: 1, minWidth: 80, maxWidth: 200 }} id="hot-cache-debounce-slider" />
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 80 }}>
|
||||
{Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) === 0
|
||||
? t('settings.hotCacheDebounceImmediate')
|
||||
: t('settings.hotCacheDebounceSeconds', { n: Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--border)', margin: '16px 0' }} />
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 13 }}
|
||||
onClick={async () => {
|
||||
await clearHotCacheDisk(mediaDir);
|
||||
refreshHotCacheSize();
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} /> {t('settings.hotCacheClearBtn')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
@@ -194,32 +189,31 @@ export function StorageTab() {
|
||||
icon={<FolderOpen size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14, lineHeight: 1.5 }}>
|
||||
{t('settings.downloadsFolderDesc')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
readOnly
|
||||
value={auth.downloadFolder || t('settings.downloadsDefault')}
|
||||
style={{ flex: 1, fontSize: 13, color: auth.downloadFolder ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
|
||||
/>
|
||||
{auth.downloadFolder && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => auth.setDownloadFolder('')}
|
||||
aria-label={t('settings.clearFolder')}
|
||||
data-tooltip={t('settings.clearFolder')}
|
||||
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
|
||||
>
|
||||
<X size={16} />
|
||||
<SettingsGroup desc={t('settings.downloadsFolderDesc')}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
readOnly
|
||||
value={auth.downloadFolder || t('settings.downloadsDefault')}
|
||||
style={{ flex: 1, fontSize: 13, color: auth.downloadFolder ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
|
||||
/>
|
||||
{auth.downloadFolder && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => auth.setDownloadFolder('')}
|
||||
aria-label={t('settings.clearFolder')}
|
||||
data-tooltip={t('settings.clearFolder')}
|
||||
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-surface" onClick={pickDownloadFolder} style={{ flexShrink: 0 }} id="settings-download-folder-btn">
|
||||
<FolderOpen size={16} /> {t('settings.pickFolder')}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-surface" onClick={pickDownloadFolder} style={{ flexShrink: 0 }} id="settings-download-folder-btn">
|
||||
<FolderOpen size={16} /> {t('settings.pickFolder')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
</>
|
||||
|
||||
@@ -15,6 +15,8 @@ import { AboutPsysonicBrandHeader } from '../AboutPsysonicLol';
|
||||
import CustomSelect from '../CustomSelect';
|
||||
import LicensesPanel from '../LicensesPanel';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import { SettingsGroup } from './SettingsGroup';
|
||||
import { SettingsToggle } from './SettingsToggle';
|
||||
import { BackupSection } from './BackupSection';
|
||||
import { CONTRIBUTORS, MAINTAINERS } from '../../config/settingsCredits';
|
||||
|
||||
@@ -55,23 +57,25 @@ export function SystemTab() {
|
||||
icon={<Globe size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div className="form-group" style={{ maxWidth: '300px' }}>
|
||||
<CustomSelect
|
||||
value={i18n.language}
|
||||
onChange={v => i18n.changeLanguage(v)}
|
||||
options={[
|
||||
{ value: 'en', label: t('settings.languageEn') },
|
||||
{ value: 'de', label: t('settings.languageDe') },
|
||||
{ value: 'es', label: t('settings.languageEs') },
|
||||
{ value: 'fr', label: t('settings.languageFr') },
|
||||
{ value: 'nl', label: t('settings.languageNl') },
|
||||
{ value: 'nb', label: t('settings.languageNb') },
|
||||
{ value: 'ru', label: t('settings.languageRu') },
|
||||
{ value: 'zh', label: t('settings.languageZh') },
|
||||
{ value: 'ro', label: t('settings.languageRo') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<SettingsGroup>
|
||||
<div className="form-group" style={{ maxWidth: '300px' }}>
|
||||
<CustomSelect
|
||||
value={i18n.language}
|
||||
onChange={v => i18n.changeLanguage(v)}
|
||||
options={[
|
||||
{ value: 'en', label: t('settings.languageEn') },
|
||||
{ value: 'de', label: t('settings.languageDe') },
|
||||
{ value: 'es', label: t('settings.languageEs') },
|
||||
{ value: 'fr', label: t('settings.languageFr') },
|
||||
{ value: 'nl', label: t('settings.languageNl') },
|
||||
{ value: 'nb', label: t('settings.languageNb') },
|
||||
{ value: 'ru', label: t('settings.languageRu') },
|
||||
{ value: 'zh', label: t('settings.languageZh') },
|
||||
{ value: 'ro', label: t('settings.languageRo') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
@@ -81,59 +85,37 @@ export function SystemTab() {
|
||||
icon={<AppWindow size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.showTrayIcon')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showTrayIconDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.showTrayIcon')}>
|
||||
<input type="checkbox" checked={auth.showTrayIcon} onChange={e => auth.setShowTrayIcon(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.minimizeToTray')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.minimizeToTrayDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.minimizeToTray')}>
|
||||
<input type="checkbox" checked={auth.minimizeToTray} onChange={e => auth.setMinimizeToTray(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<SettingsGroup title={t('settings.groupTray')}>
|
||||
<SettingsToggle
|
||||
label={t('settings.showTrayIcon')}
|
||||
desc={t('settings.showTrayIconDesc')}
|
||||
checked={auth.showTrayIcon}
|
||||
onChange={auth.setShowTrayIcon}
|
||||
/>
|
||||
<div className="settings-section-divider" />
|
||||
<SettingsToggle
|
||||
label={t('settings.minimizeToTray')}
|
||||
desc={t('settings.minimizeToTrayDesc')}
|
||||
checked={auth.minimizeToTray}
|
||||
onChange={auth.setMinimizeToTray}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
|
||||
{IS_LINUX && (
|
||||
<>
|
||||
<SettingsGroup title={t('settings.groupLinuxRendering')}>
|
||||
<SettingsToggle
|
||||
label={t('settings.linuxWebkitSmoothScroll')}
|
||||
desc={t('settings.linuxWebkitSmoothScrollDesc')}
|
||||
checked={auth.linuxWebkitKineticScroll}
|
||||
onChange={auth.setLinuxWebkitKineticScroll}
|
||||
/>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.linuxWebkitSmoothScroll')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.linuxWebkitSmoothScrollDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.linuxWebkitSmoothScroll')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auth.linuxWebkitKineticScroll}
|
||||
onChange={e => auth.setLinuxWebkitKineticScroll(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.linuxWebkitInputForceRepaint')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.linuxWebkitInputForceRepaintDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.linuxWebkitInputForceRepaint')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auth.linuxWebkitInputForceRepaint}
|
||||
onChange={e => auth.setLinuxWebkitInputForceRepaint(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<SettingsToggle
|
||||
label={t('settings.linuxWebkitInputForceRepaint')}
|
||||
desc={t('settings.linuxWebkitInputForceRepaintDesc')}
|
||||
checked={auth.linuxWebkitInputForceRepaint}
|
||||
onChange={auth.setLinuxWebkitInputForceRepaint}
|
||||
/>
|
||||
{waylandTextRenderAvailable && (
|
||||
<>
|
||||
<div className="settings-section-divider" />
|
||||
@@ -155,26 +137,28 @@ export function SystemTab() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</SettingsGroup>
|
||||
)}
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.clockFormat')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.clockFormatDesc')}</div>
|
||||
|
||||
<SettingsGroup title={t('settings.groupClock')}>
|
||||
<div className="settings-toggle-row">
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.clockFormat')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.clockFormatDesc')}</div>
|
||||
</div>
|
||||
<div style={{ minWidth: 160 }}>
|
||||
<CustomSelect
|
||||
value={auth.clockFormat}
|
||||
onChange={(v) => auth.setClockFormat(v as ClockFormat)}
|
||||
options={[
|
||||
{ value: 'auto', label: t('settings.clockFormatAuto') },
|
||||
{ value: '24h', label: t('settings.clockFormatTwentyFour') },
|
||||
{ value: '12h', label: t('settings.clockFormatTwelve') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ minWidth: 160 }}>
|
||||
<CustomSelect
|
||||
value={auth.clockFormat}
|
||||
onChange={(v) => auth.setClockFormat(v as ClockFormat)}
|
||||
options={[
|
||||
{ value: 'auto', label: t('settings.clockFormatAuto') },
|
||||
{ value: '24h', label: t('settings.clockFormatTwentyFour') },
|
||||
{ value: '12h', label: t('settings.clockFormatTwelve') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
@@ -190,26 +174,28 @@ export function SystemTab() {
|
||||
icon={<Sliders size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||||
{t('settings.loggingModeDesc')}
|
||||
</div>
|
||||
<CustomSelect
|
||||
value={auth.loggingMode}
|
||||
onChange={(v) => auth.setLoggingMode(v as LoggingMode)}
|
||||
options={[
|
||||
{ value: 'off', label: t('settings.loggingModeOff') },
|
||||
{ value: 'normal', label: t('settings.loggingModeNormal') },
|
||||
{ value: 'debug', label: t('settings.loggingModeDebug') },
|
||||
]}
|
||||
/>
|
||||
{auth.loggingMode === 'debug' && (
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<button className="btn btn-surface" onClick={exportRuntimeLogs}>
|
||||
<Download size={14} />
|
||||
{t('settings.loggingExport')}
|
||||
</button>
|
||||
<SettingsGroup>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||||
{t('settings.loggingModeDesc')}
|
||||
</div>
|
||||
)}
|
||||
<CustomSelect
|
||||
value={auth.loggingMode}
|
||||
onChange={(v) => auth.setLoggingMode(v as LoggingMode)}
|
||||
options={[
|
||||
{ value: 'off', label: t('settings.loggingModeOff') },
|
||||
{ value: 'normal', label: t('settings.loggingModeNormal') },
|
||||
{ value: 'debug', label: t('settings.loggingModeDebug') },
|
||||
]}
|
||||
/>
|
||||
{auth.loggingMode === 'debug' && (
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<button className="btn btn-surface" onClick={exportRuntimeLogs}>
|
||||
<Download size={14} />
|
||||
{t('settings.loggingExport')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
@@ -271,20 +257,12 @@ export function SystemTab() {
|
||||
</div>
|
||||
|
||||
<div className="settings-section-divider" style={{ marginTop: '1.25rem' }} />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.showChangelogOnUpdate')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showChangelogOnUpdateDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.showChangelogOnUpdate')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auth.showChangelogOnUpdate}
|
||||
onChange={e => auth.setShowChangelogOnUpdate(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<SettingsToggle
|
||||
label={t('settings.showChangelogOnUpdate')}
|
||||
desc={t('settings.showChangelogOnUpdateDesc')}
|
||||
checked={auth.showChangelogOnUpdate}
|
||||
onChange={auth.setShowChangelogOnUpdate}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1.25rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
|
||||
@@ -62,7 +62,7 @@ export function ThemeImportSection() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings-card">
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImport}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { FIXED_THEMES } from './fixedThemes';
|
||||
import { InstalledThemes } from './InstalledThemes';
|
||||
import { ThemeImportSection } from './ThemeImportSection';
|
||||
import { ThemeStoreSection } from './ThemeStoreSection';
|
||||
import { SettingsGroup } from './SettingsGroup';
|
||||
|
||||
/**
|
||||
* A flat, always-visible section. The Themes tab has a single purpose, so its
|
||||
@@ -16,7 +17,16 @@ import { ThemeStoreSection } from './ThemeStoreSection';
|
||||
* the collapsible <details> SettingsSubSection used elsewhere. `data-settings-
|
||||
* search` keeps each section reachable from the global settings search.
|
||||
*/
|
||||
function ThemesSection({ icon, title, children }: { icon: ReactNode; title: string; children: ReactNode }) {
|
||||
function ThemesSection({ icon, title, children, boxed }: { icon: ReactNode; title: string; children: ReactNode; boxed?: boolean }) {
|
||||
if (boxed) {
|
||||
return (
|
||||
<section className="themes-section" data-settings-search={title} style={{ marginBottom: '1.75rem' }}>
|
||||
<div className="settings-card">
|
||||
<SettingsGroup title={title} icon={icon}>{children}</SettingsGroup>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<section className="themes-section" data-settings-search={title} style={{ marginBottom: '1.75rem' }}>
|
||||
<h2 style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 15, fontWeight: 600, margin: '0 0 0.75rem' }}>
|
||||
@@ -39,7 +49,7 @@ export function ThemesTab() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<ThemesSection icon={<Palette size={16} />} title={t('settings.themesYourThemesTitle')}>
|
||||
<ThemesSection icon={<Palette size={16} />} title={t('settings.themesYourThemesTitle')} boxed>
|
||||
{theme.enableThemeScheduler && (
|
||||
<div className="settings-hint settings-hint-info" style={{ marginBottom: '0.75rem' }}>
|
||||
{t('settings.themeSchedulerActiveHint')}
|
||||
@@ -48,8 +58,8 @@ export function ThemesTab() {
|
||||
<InstalledThemes />
|
||||
</ThemesSection>
|
||||
|
||||
<ThemesSection icon={<Clock size={16} />} title={t('settings.themeSchedulerTitle')}>
|
||||
<div className="settings-card">
|
||||
<ThemesSection icon={<Clock size={16} />} title={t('settings.themeSchedulerTitle')} boxed>
|
||||
<div>
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.themeSchedulerEnable')}</div>
|
||||
@@ -114,7 +124,7 @@ export function ThemesTab() {
|
||||
</div>
|
||||
</ThemesSection>
|
||||
|
||||
<ThemesSection icon={<Upload size={16} />} title={t('settings.themeImportTitle')}>
|
||||
<ThemesSection icon={<Upload size={16} />} title={t('settings.themeImportTitle')} boxed>
|
||||
<ThemeImportSection />
|
||||
</ThemesSection>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AudioLines, RotateCcw } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import CustomSelect from '../../CustomSelect';
|
||||
import SettingsSubSection from '../../SettingsSubSection';
|
||||
import { SettingsGroup } from '../SettingsGroup';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
import { IS_MACOS } from '../../../utils/platform';
|
||||
import { buildAudioDeviceSelectOptions } from '../../../utils/audio/audioDeviceLabels';
|
||||
@@ -43,49 +44,51 @@ export function AudioOutputDeviceSection({
|
||||
icon={<AudioLines size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
{IS_MACOS ? (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.55 }}>
|
||||
{t('settings.audioOutputDeviceMacNotice')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||||
{t('settings.audioOutputDeviceDesc')}
|
||||
<SettingsGroup>
|
||||
{IS_MACOS ? (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.55 }}>
|
||||
{t('settings.audioOutputDeviceMacNotice')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
<CustomSelect
|
||||
style={{ flex: 1 }}
|
||||
value={audioOutputDevice ?? ''}
|
||||
disabled={deviceSwitching || devicesLoading}
|
||||
onChange={async (val) => {
|
||||
const device = val || null;
|
||||
setDeviceSwitching(true);
|
||||
try {
|
||||
await invoke('audio_set_device', { deviceName: device });
|
||||
setAudioOutputDevice(device);
|
||||
} catch { /* device open failed — don't persist */ }
|
||||
setDeviceSwitching(false);
|
||||
}}
|
||||
options={buildAudioDeviceSelectOptions(
|
||||
audioDevices,
|
||||
t('settings.audioOutputDeviceDefault'),
|
||||
osDefaultAudioDeviceId,
|
||||
t('settings.audioOutputDeviceOsDefaultNow'),
|
||||
audioOutputDevice,
|
||||
t('settings.audioOutputDeviceNotInCurrentList'),
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
className="icon-btn"
|
||||
onClick={() => refreshAudioDevices()}
|
||||
disabled={devicesLoading || deviceSwitching}
|
||||
data-tooltip={t('settings.audioOutputDeviceRefresh')}
|
||||
>
|
||||
<RotateCcw size={15} className={devicesLoading ? 'spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
) : (
|
||||
<>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||||
{t('settings.audioOutputDeviceDesc')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
<CustomSelect
|
||||
style={{ flex: 1 }}
|
||||
value={audioOutputDevice ?? ''}
|
||||
disabled={deviceSwitching || devicesLoading}
|
||||
onChange={async (val) => {
|
||||
const device = val || null;
|
||||
setDeviceSwitching(true);
|
||||
try {
|
||||
await invoke('audio_set_device', { deviceName: device });
|
||||
setAudioOutputDevice(device);
|
||||
} catch { /* device open failed — don't persist */ }
|
||||
setDeviceSwitching(false);
|
||||
}}
|
||||
options={buildAudioDeviceSelectOptions(
|
||||
audioDevices,
|
||||
t('settings.audioOutputDeviceDefault'),
|
||||
osDefaultAudioDeviceId,
|
||||
t('settings.audioOutputDeviceOsDefaultNow'),
|
||||
audioOutputDevice,
|
||||
t('settings.audioOutputDeviceNotInCurrentList'),
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
className="icon-btn"
|
||||
onClick={() => refreshAudioDevices()}
|
||||
disabled={devicesLoading || deviceSwitching}
|
||||
data-tooltip={t('settings.audioOutputDeviceRefresh')}
|
||||
>
|
||||
<RotateCcw size={15} className={devicesLoading ? 'spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { TFunction } from 'i18next';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
import { DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB } from '../../../store/authStoreDefaults';
|
||||
import { LoudnessLufsButtonGroup } from '../LoudnessLufsButtonGroup';
|
||||
import { SettingsGroup } from '../SettingsGroup';
|
||||
|
||||
interface Props {
|
||||
preAnalysisEffectiveDb: number;
|
||||
@@ -28,9 +29,10 @@ export function NormalizationBlock({ preAnalysisEffectiveDb, t }: Props) {
|
||||
const auth = useAuthStore();
|
||||
|
||||
return (
|
||||
<div className="settings-group">
|
||||
<div className="settings-group-title">{t('settings.normalization', { defaultValue: 'Normalization' })}</div>
|
||||
<div className="settings-group-desc">{t('settings.normalizationDesc')}</div>
|
||||
<SettingsGroup
|
||||
title={t('settings.normalization', { defaultValue: 'Normalization' })}
|
||||
desc={t('settings.normalizationDesc')}
|
||||
>
|
||||
<div className="settings-segmented" style={{ marginBottom: auth.normalizationEngine === 'off' ? 0 : '0.85rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -176,6 +178,6 @@ export function NormalizationBlock({ preAnalysisEffectiveDb, t }: Props) {
|
||||
<div className="settings-norm-note">{t('settings.loudnessFirstPlayNote')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
setTransitionMode,
|
||||
type TransitionMode,
|
||||
} from '../../../utils/playback/playbackTransition';
|
||||
import { SettingsGroup } from '../SettingsGroup';
|
||||
import { SettingsToggle } from '../SettingsToggle';
|
||||
|
||||
interface Props {
|
||||
t: TFunction;
|
||||
@@ -35,10 +37,7 @@ export function PlaybackBehaviorBlock({ t }: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="settings-group">
|
||||
<div className="settings-group-title">{t('settings.transitionsTitle')}</div>
|
||||
<div className="settings-group-desc">{t('settings.transitionsDesc')}</div>
|
||||
|
||||
<SettingsGroup title={t('settings.transitionsTitle')} desc={t('settings.transitionsDesc')}>
|
||||
<div className="settings-segmented">
|
||||
{transitions.map(item => (
|
||||
<button
|
||||
@@ -74,27 +73,16 @@ export function PlaybackBehaviorBlock({ t }: Props) {
|
||||
{t('settings.autoDjDesc')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
|
||||
<div className="settings-group">
|
||||
<div className="settings-group-title">{t('settings.queueBehaviourTitle')}</div>
|
||||
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{t('settings.preservePlayNextOrder')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('settings.preservePlayNextOrderDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.preservePlayNextOrder')}>
|
||||
<input type="checkbox" checked={auth.preservePlayNextOrder}
|
||||
onChange={e => auth.setPreservePlayNextOrder(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsGroup title={t('settings.queueBehaviourTitle')}>
|
||||
<SettingsToggle
|
||||
label={t('settings.preservePlayNextOrder')}
|
||||
desc={t('settings.preservePlayNextOrderDesc')}
|
||||
checked={auth.preservePlayNextOrder}
|
||||
onChange={auth.setPreservePlayNextOrder}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { usePlaybackRateStore } from '../../../store/playbackRateStore';
|
||||
import { useOrbitStore } from '../../../store/orbitStore';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
import { isOrbitPlaybackSyncActive } from '../../../utils/orbit';
|
||||
import { SettingsToggle } from '../SettingsToggle';
|
||||
|
||||
interface Props {
|
||||
t: TFunction;
|
||||
@@ -109,22 +110,12 @@ export function PlaybackRateControls({ t, showEnable = true }: Props) {
|
||||
onWheel={compact ? handleWheelSpeed : undefined}
|
||||
>
|
||||
{showEnable && (
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.playbackRateEnabled')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('settings.playbackRateEnabledDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.playbackRateEnabled')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={e => setEnabled(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<SettingsToggle
|
||||
label={t('settings.playbackRateEnabled')}
|
||||
desc={t('settings.playbackRateEnabledDesc')}
|
||||
checked={enabled}
|
||||
onChange={setEnabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(!showEnable || enabled) && (
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useAuthStore } from '../../../store/authStore';
|
||||
import { TRACK_PREVIEW_LOCATIONS } from '../../../store/authStoreDefaults';
|
||||
import type { TrackPreviewLocation } from '../../../store/authStoreTypes';
|
||||
import SettingsSubSection from '../../SettingsSubSection';
|
||||
import { SettingsGroup } from '../SettingsGroup';
|
||||
import { SettingsToggle } from '../SettingsToggle';
|
||||
|
||||
interface Props {
|
||||
t: TFunction;
|
||||
@@ -26,103 +28,96 @@ export function TrackPreviewsSection({ t }: Props) {
|
||||
icon={<Play size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{t('settings.trackPreviewsToggle')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('settings.trackPreviewsDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.trackPreviewsToggle')}>
|
||||
<input type="checkbox" checked={auth.trackPreviewsEnabled}
|
||||
onChange={e => auth.setTrackPreviewsEnabled(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<SettingsGroup>
|
||||
<SettingsToggle
|
||||
label={t('settings.trackPreviewsToggle')}
|
||||
desc={t('settings.trackPreviewsDesc')}
|
||||
checked={auth.trackPreviewsEnabled}
|
||||
onChange={auth.setTrackPreviewsEnabled}
|
||||
/>
|
||||
|
||||
{auth.trackPreviewsEnabled && (
|
||||
<>
|
||||
<div className="divider" />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: 4 }}>
|
||||
{t('settings.trackPreviewLocationsTitle')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 12 }}>
|
||||
{t('settings.trackPreviewLocationsDesc')}
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
}}>
|
||||
{TRACK_PREVIEW_LOCATIONS.map((loc: TrackPreviewLocation) => (
|
||||
<div key={loc} className="settings-toggle-row" style={{ padding: '6px var(--space-3)' }}>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
{t(`settings.trackPreviewLocation_${loc}`)}
|
||||
{auth.trackPreviewsEnabled && (
|
||||
<>
|
||||
<div className="divider" />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: 4 }}>
|
||||
{t('settings.trackPreviewLocationsTitle')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 12 }}>
|
||||
{t('settings.trackPreviewLocationsDesc')}
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
}}>
|
||||
{TRACK_PREVIEW_LOCATIONS.map((loc: TrackPreviewLocation) => (
|
||||
<div key={loc} className="settings-toggle-row" style={{ padding: '6px var(--space-3)' }}>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
{t(`settings.trackPreviewLocation_${loc}`)}
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t(`settings.trackPreviewLocation_${loc}`)}>
|
||||
<input type="checkbox" checked={auth.trackPreviewLocations[loc]}
|
||||
onChange={e => auth.setTrackPreviewLocation(loc, e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t(`settings.trackPreviewLocation_${loc}`)}>
|
||||
<input type="checkbox" checked={auth.trackPreviewLocations[loc]}
|
||||
onChange={e => auth.setTrackPreviewLocation(loc, e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: 4 }}>
|
||||
{t('settings.trackPreviewStart')}
|
||||
<div className="divider" />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: 4 }}>
|
||||
{t('settings.trackPreviewStart')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 8 }}>
|
||||
{t('settings.trackPreviewStartDesc')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={0.9}
|
||||
step={0.01}
|
||||
value={auth.trackPreviewStartRatio}
|
||||
onChange={e => auth.setTrackPreviewStartRatio(parseFloat(e.target.value))}
|
||||
style={{ flex: 1, minWidth: 80, maxWidth: 240 }}
|
||||
aria-label={t('settings.trackPreviewStart')}
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 44 }}>
|
||||
{Math.round(auth.trackPreviewStartRatio * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 8 }}>
|
||||
{t('settings.trackPreviewStartDesc')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={0.9}
|
||||
step={0.01}
|
||||
value={auth.trackPreviewStartRatio}
|
||||
onChange={e => auth.setTrackPreviewStartRatio(parseFloat(e.target.value))}
|
||||
style={{ flex: 1, minWidth: 80, maxWidth: 240 }}
|
||||
aria-label={t('settings.trackPreviewStart')}
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 44 }}>
|
||||
{Math.round(auth.trackPreviewStartRatio * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: 4 }}>
|
||||
{t('settings.trackPreviewDuration')}
|
||||
<div className="divider" />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: 4 }}>
|
||||
{t('settings.trackPreviewDuration')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 8 }}>
|
||||
{t('settings.trackPreviewDurationDesc')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<input
|
||||
type="range"
|
||||
min={5}
|
||||
max={60}
|
||||
step={1}
|
||||
value={auth.trackPreviewDurationSec}
|
||||
onChange={e => auth.setTrackPreviewDurationSec(parseInt(e.target.value, 10))}
|
||||
style={{ flex: 1, minWidth: 80, maxWidth: 240 }}
|
||||
aria-label={t('settings.trackPreviewDuration')}
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 44 }}>
|
||||
{t('settings.trackPreviewDurationSecs', { n: auth.trackPreviewDurationSec })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 8 }}>
|
||||
{t('settings.trackPreviewDurationDesc')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<input
|
||||
type="range"
|
||||
min={5}
|
||||
max={60}
|
||||
step={1}
|
||||
value={auth.trackPreviewDurationSec}
|
||||
onChange={e => auth.setTrackPreviewDurationSec(parseInt(e.target.value, 10))}
|
||||
style={{ flex: 1, minWidth: 80, maxWidth: 240 }}
|
||||
aria-label={t('settings.trackPreviewDuration')}
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 44 }}>
|
||||
{t('settings.trackPreviewDurationSecs', { n: auth.trackPreviewDurationSec })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
);
|
||||
|
||||
@@ -71,13 +71,13 @@ export function ConnectProviderForm({
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<div style={{ fontWeight: 500, fontSize: 13 }}>{t('musicNetwork.addService')}</div>
|
||||
<div className="settings-group-title">{t('musicNetwork.addService')}</div>
|
||||
{available.map(preset => {
|
||||
const id = preset.manifest.presetId;
|
||||
const isExpanded = expanded === id;
|
||||
const isBusy = busy === id;
|
||||
return (
|
||||
<div key={id} className="settings-card" style={{ padding: '0.75rem 1rem' }}>
|
||||
<div key={id} className="settings-group" style={{ padding: '0.75rem 1rem', marginTop: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<div style={{ flexShrink: 0 }} aria-hidden="true">{renderPresetIcon(preset.manifest.icon, 18)}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CustomSelect from '../../CustomSelect';
|
||||
import { SettingsGroup } from '../SettingsGroup';
|
||||
import type { Account } from '../../../music-network';
|
||||
|
||||
/**
|
||||
@@ -26,28 +27,24 @@ export function EnrichmentPrimarySelect({
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '1rem',
|
||||
flexWrap: 'wrap',
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 500 }}>{t('musicNetwork.primaryLabel')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('musicNetwork.primaryDesc')}</div>
|
||||
<SettingsGroup title={t('musicNetwork.primaryLabel')}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '1rem',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0, fontSize: 12, color: 'var(--text-muted)' }}>{t('musicNetwork.primaryDesc')}</div>
|
||||
<CustomSelect
|
||||
value={primaryId ?? ''}
|
||||
options={options}
|
||||
onChange={v => onChange(v || null)}
|
||||
style={{ minWidth: 180 }}
|
||||
/>
|
||||
</div>
|
||||
<CustomSelect
|
||||
value={primaryId ?? ''}
|
||||
options={options}
|
||||
onChange={v => onChange(v || null)}
|
||||
style={{ minWidth: 180 }}
|
||||
/>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Share2 } from 'lucide-react';
|
||||
import SettingsSubSection from '../../SettingsSubSection';
|
||||
import { SettingsGroup } from '../SettingsGroup';
|
||||
import { showToast } from '../../../utils/ui/toast';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
import {
|
||||
@@ -77,38 +78,24 @@ export function MusicNetworkSection() {
|
||||
{t('musicNetwork.desc')}
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '1rem',
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 500 }}>{t('musicNetwork.masterToggle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('musicNetwork.masterToggleDesc')}</div>
|
||||
<SettingsGroup title={t('musicNetwork.masterToggle')}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem' }}>
|
||||
<div style={{ minWidth: 0, fontSize: 12, color: 'var(--text-muted)' }}>{t('musicNetwork.masterToggleDesc')}</div>
|
||||
<label className="toggle-switch" style={{ flexShrink: 0 }} aria-label={t('musicNetwork.masterToggle')}>
|
||||
<input type="checkbox" checked={scrobblingMasterEnabled} onChange={e => setMaster(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<label className="toggle-switch" style={{ flexShrink: 0 }} aria-label={t('musicNetwork.masterToggle')}>
|
||||
<input type="checkbox" checked={scrobblingMasterEnabled} onChange={e => setMaster(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<EnrichmentPrimarySelect
|
||||
accounts={accounts}
|
||||
primaryId={enrichmentPrimaryId}
|
||||
onChange={setPrimary}
|
||||
/>
|
||||
</div>
|
||||
<EnrichmentPrimarySelect
|
||||
accounts={accounts}
|
||||
primaryId={enrichmentPrimaryId}
|
||||
onChange={setPrimary}
|
||||
/>
|
||||
|
||||
{accounts.length > 0 && (
|
||||
<>
|
||||
<div className="settings-section-divider" />
|
||||
<SettingsGroup>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{accounts.map(account => (
|
||||
<ScrobbleDestinationCard
|
||||
@@ -120,13 +107,13 @@ export function MusicNetworkSection() {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<MalojaProxyWarning accounts={accounts} />
|
||||
</>
|
||||
</SettingsGroup>
|
||||
)}
|
||||
|
||||
<div className="settings-section-divider" />
|
||||
<ConnectProviderForm connectedPresetIds={connectedPresetIds} onConnect={connect} />
|
||||
<div style={{ marginTop: 'var(--space-3)' }}>
|
||||
<ConnectProviderForm connectedPresetIds={connectedPresetIds} onConnect={connect} />
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user