mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 22:45:41 +00:00
refactor(audio-tab): I.3 — split AudioTab.tsx 521 → 97 LOC across 6 files (#674)
* refactor(audio-tab): extract useAudioDevicesProbe hook Pull the device-list state, refreshAudioDevices callback, mount probe, and the audio:device-changed / audio:device-reset listener wiring into hooks/useAudioDevicesProbe.ts. macOS short-circuit lives in the hook. AudioTab.tsx: 521 → 463 LOC. * refactor(audio-tab): extract AudioOutputDeviceSection Pull the audio output device picker (macOS notice + CustomSelect + refresh button) into components/settings/audio/AudioOutputDeviceSection.tsx. AudioTab.tsx: 463 → 418 LOC. * refactor(audio-tab): extract NormalizationBlock Pull the engine picker (Off / ReplayGain / LUFS) and the engine-specific config blocks (RG mode + pre-gain + fallback; LUFS target + pre-analysis attenuation with reset) into components/settings/audio/NormalizationBlock.tsx. AudioTab.tsx: 418 → 267 LOC. * refactor(audio-tab): extract PlaybackBehaviorBlock Pull Crossfade ↔ Gapless mutually-exclusive toggles + Preserve Play Next Order into components/settings/audio/PlaybackBehaviorBlock.tsx. The crossfade-seconds slider only renders while crossfade is the active mode. AudioTab.tsx: 267 → 201 LOC. * refactor(audio-tab): extract TrackPreviewsSection Pull the track previews subsection (master toggle, per-location grid, start-ratio slider, duration slider) into components/settings/audio/TrackPreviewsSection.tsx. AudioTab.tsx: 201 → 97 LOC.
This commit is contained in:
committed by
GitHub
parent
f14c8f21e6
commit
59772db5ee
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { AudioLines, RotateCcw } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import CustomSelect from '../../CustomSelect';
|
||||
import SettingsSubSection from '../../SettingsSubSection';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
import { IS_MACOS } from '../../../utils/platform';
|
||||
import { buildAudioDeviceSelectOptions } from '../../../utils/audioDeviceLabels';
|
||||
|
||||
interface Props {
|
||||
audioDevices: string[];
|
||||
osDefaultAudioDeviceId: string | null;
|
||||
deviceSwitching: boolean;
|
||||
devicesLoading: boolean;
|
||||
setDeviceSwitching: (v: boolean) => void;
|
||||
refreshAudioDevices: (opts?: { silent?: boolean }) => void;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio output device picker. macOS is hard-pinned to the system default,
|
||||
* so the picker collapses to a notice on that platform.
|
||||
*
|
||||
* The device switch is best-effort: if `audio_set_device` rejects (e.g.
|
||||
* device disappeared) we leave the previous selection in the store.
|
||||
*/
|
||||
export function AudioOutputDeviceSection({
|
||||
audioDevices,
|
||||
osDefaultAudioDeviceId,
|
||||
deviceSwitching,
|
||||
devicesLoading,
|
||||
setDeviceSwitching,
|
||||
refreshAudioDevices,
|
||||
t,
|
||||
}: Props) {
|
||||
const audioOutputDevice = useAuthStore(s => s.audioOutputDevice);
|
||||
const setAudioOutputDevice = useAuthStore(s => s.setAudioOutputDevice);
|
||||
|
||||
return (
|
||||
<SettingsSubSection
|
||||
title={t('settings.audioOutputDevice')}
|
||||
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')}
|
||||
</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>
|
||||
</SettingsSubSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import React from 'react';
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
import { DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB } from '../../../store/authStoreDefaults';
|
||||
import { LoudnessLufsButtonGroup } from '../LoudnessLufsButtonGroup';
|
||||
|
||||
interface Props {
|
||||
preAnalysisEffectiveDb: number;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalization engine picker (Off / ReplayGain / LUFS) plus the
|
||||
* engine-specific configuration blocks.
|
||||
*
|
||||
* - ReplayGain → mode (auto/track/album), pre-gain slider, fallback gain.
|
||||
* `auto` mode toggles between track/album based on what the playlist
|
||||
* provides; the help line explains that.
|
||||
* - Loudness → target LUFS button group + pre-analysis attenuation slider
|
||||
* with reset-to-default. The effective dB readout reflects how much
|
||||
* headroom is being applied for the current target.
|
||||
*
|
||||
* Switching engines clears the other engine's enabled flag so only one
|
||||
* can be live at a time.
|
||||
*/
|
||||
export function NormalizationBlock({ preAnalysisEffectiveDb, t }: Props) {
|
||||
const auth = useAuthStore();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ marginBottom: '0.6rem' }}>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.normalization', { defaultValue: 'Normalization' })}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
|
||||
{t('settings.normalizationDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-segmented" style={{ marginBottom: auth.normalizationEngine === 'off' ? 0 : '0.85rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${auth.normalizationEngine === 'off' ? 'btn-primary' : 'btn-ghost'}`}
|
||||
onClick={() => {
|
||||
auth.setReplayGainEnabled(false);
|
||||
auth.setNormalizationEngine('off');
|
||||
}}
|
||||
>
|
||||
{t('settings.normalizationOff')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${auth.normalizationEngine === 'replaygain' ? 'btn-primary' : 'btn-ghost'}`}
|
||||
onClick={() => {
|
||||
auth.setReplayGainEnabled(true);
|
||||
auth.setNormalizationEngine('replaygain');
|
||||
}}
|
||||
>
|
||||
{t('settings.normalizationReplayGain')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${auth.normalizationEngine === 'loudness' ? 'btn-primary' : 'btn-ghost'}`}
|
||||
onClick={() => {
|
||||
auth.setReplayGainEnabled(false);
|
||||
if (auth.normalizationEngine !== 'loudness') auth.setLoudnessTargetLufs(-12);
|
||||
auth.setNormalizationEngine('loudness');
|
||||
}}
|
||||
>
|
||||
{t('settings.normalizationLufs')}
|
||||
</button>
|
||||
</div>
|
||||
{auth.normalizationEngine === 'replaygain' && (
|
||||
<div className="settings-norm-block">
|
||||
<div className="settings-norm-field">
|
||||
<div className="settings-norm-row">
|
||||
<span className="settings-norm-label">{t('settings.replayGainMode')}</span>
|
||||
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className={`btn ${auth.replayGainMode === 'auto' ? 'btn-primary' : 'btn-ghost'}`}
|
||||
style={{ fontSize: 12, padding: '4px 14px' }}
|
||||
onClick={() => auth.setReplayGainMode('auto')}
|
||||
>
|
||||
{t('settings.replayGainAuto')}
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${auth.replayGainMode === 'track' ? 'btn-primary' : 'btn-ghost'}`}
|
||||
style={{ fontSize: 12, padding: '4px 14px' }}
|
||||
onClick={() => auth.setReplayGainMode('track')}
|
||||
>
|
||||
{t('settings.replayGainTrack')}
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${auth.replayGainMode === 'album' ? 'btn-primary' : 'btn-ghost'}`}
|
||||
style={{ fontSize: 12, padding: '4px 14px' }}
|
||||
onClick={() => auth.setReplayGainMode('album')}
|
||||
>
|
||||
{t('settings.replayGainAlbum')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{auth.replayGainMode === 'auto' && (
|
||||
<div className="settings-norm-help">{t('settings.replayGainAutoDesc')}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="settings-norm-field">
|
||||
<div className="settings-norm-row">
|
||||
<span className="settings-norm-label">{t('settings.replayGainPreGain')}</span>
|
||||
<input
|
||||
type="range" min={0} max={6} step={0.5}
|
||||
value={auth.replayGainPreGainDb}
|
||||
onChange={e => auth.setReplayGainPreGainDb(Number(e.target.value))}
|
||||
/>
|
||||
<span className="settings-norm-value">
|
||||
{auth.replayGainPreGainDb > 0 ? `+${auth.replayGainPreGainDb}` : auth.replayGainPreGainDb} dB
|
||||
</span>
|
||||
</div>
|
||||
<div className="settings-norm-help">{t('settings.replayGainPreGainDesc')}</div>
|
||||
</div>
|
||||
<div className="settings-norm-field">
|
||||
<div className="settings-norm-row">
|
||||
<span className="settings-norm-label">{t('settings.replayGainFallback')}</span>
|
||||
<input
|
||||
type="range" min={-6} max={0} step={0.5}
|
||||
value={auth.replayGainFallbackDb}
|
||||
onChange={e => auth.setReplayGainFallbackDb(Number(e.target.value))}
|
||||
/>
|
||||
<span className="settings-norm-value">
|
||||
{auth.replayGainFallbackDb > 0 ? `+${auth.replayGainFallbackDb}` : auth.replayGainFallbackDb} dB
|
||||
</span>
|
||||
</div>
|
||||
<div className="settings-norm-help">{t('settings.replayGainFallbackDesc')}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{auth.normalizationEngine === 'loudness' && (
|
||||
<div className="settings-norm-block">
|
||||
<div className="settings-norm-field">
|
||||
<div className="settings-norm-row">
|
||||
<span className="settings-norm-label">{t('settings.loudnessTargetLufs')}</span>
|
||||
<LoudnessLufsButtonGroup value={auth.loudnessTargetLufs} onSelect={auth.setLoudnessTargetLufs} />
|
||||
</div>
|
||||
<div className="settings-norm-help">{t('settings.loudnessTargetLufsDesc')}</div>
|
||||
</div>
|
||||
<div className="settings-norm-field">
|
||||
<div className="settings-norm-row">
|
||||
<span className="settings-norm-label">{t('settings.loudnessPreAnalysisAttenuation')}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={-24}
|
||||
max={0}
|
||||
step={0.5}
|
||||
value={auth.loudnessPreAnalysisAttenuationDb}
|
||||
onChange={e => auth.setLoudnessPreAnalysisAttenuationDb(Number(e.target.value))}
|
||||
/>
|
||||
<span className="settings-norm-value">
|
||||
{preAnalysisEffectiveDb} dB
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn"
|
||||
style={{ flexShrink: 0 }}
|
||||
disabled={
|
||||
auth.loudnessPreAnalysisAttenuationDb === DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB
|
||||
}
|
||||
onClick={() => auth.resetLoudnessPreAnalysisAttenuationDbDefault()}
|
||||
data-tooltip={t('settings.loudnessPreAnalysisAttenuationReset')}
|
||||
aria-label={t('settings.loudnessPreAnalysisAttenuationReset')}
|
||||
>
|
||||
<RotateCcw size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="settings-norm-help">
|
||||
{t('settings.loudnessPreAnalysisAttenuationDesc')}{' '}
|
||||
{t('settings.loudnessPreAnalysisAttenuationRef', {
|
||||
ref: auth.loudnessPreAnalysisAttenuationDb,
|
||||
eff: preAnalysisEffectiveDb,
|
||||
tgt: auth.loudnessTargetLufs,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-norm-note">{t('settings.loudnessFirstPlayNote')}</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
|
||||
interface Props {
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crossfade ↔ Gapless are mutually exclusive — enabling one forces the
|
||||
* other off (`setGaplessEnabled(false)` / `setCrossfadeEnabled(false)`
|
||||
* on the toggle handlers) and the inactive row dims via opacity +
|
||||
* pointerEvents:none. The crossfade-seconds slider only renders while
|
||||
* crossfade is the active mode.
|
||||
*
|
||||
* The `preservePlayNextOrder` toggle is independent of both and pinned
|
||||
* to the bottom of the block.
|
||||
*/
|
||||
export function PlaybackBehaviorBlock({ t }: Props) {
|
||||
const auth = useAuthStore();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="settings-toggle-row" style={auth.gaplessEnabled ? { opacity: 0.45, pointerEvents: 'none' } : undefined}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{t('settings.crossfade')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{auth.gaplessEnabled ? t('settings.notWithGapless') : t('settings.crossfadeDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.crossfade')}>
|
||||
<input type="checkbox" checked={auth.crossfadeEnabled} disabled={auth.gaplessEnabled}
|
||||
onChange={e => { auth.setGaplessEnabled(false); auth.setCrossfadeEnabled(e.target.checked); }} id="crossfade-toggle" />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
{auth.crossfadeEnabled && !auth.gaplessEnabled && (
|
||||
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
type="range"
|
||||
min={0.1}
|
||||
max={10}
|
||||
step={0.1}
|
||||
value={auth.crossfadeSecs}
|
||||
onChange={e => auth.setCrossfadeSecs(parseFloat(e.target.value))}
|
||||
style={{ flex: 1, minWidth: 80, maxWidth: 200 }}
|
||||
id="crossfade-secs-slider"
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 36 }}>
|
||||
{t('settings.crossfadeSecs', { n: auth.crossfadeSecs.toFixed(1) })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
<div className="settings-toggle-row" style={auth.crossfadeEnabled ? { opacity: 0.45, pointerEvents: 'none' } : undefined}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{t('settings.gapless')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{auth.crossfadeEnabled ? t('settings.notWithCrossfade') : t('settings.gaplessDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.gapless')}>
|
||||
<input type="checkbox" checked={auth.gaplessEnabled} disabled={auth.crossfadeEnabled}
|
||||
onChange={e => { auth.setCrossfadeEnabled(false); auth.setGaplessEnabled(e.target.checked); }} id="gapless-toggle" />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="settings-toggle-row" style={{ marginTop: '0.75rem' }}>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import React from 'react';
|
||||
import { Play } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
import { TRACK_PREVIEW_LOCATIONS } from '../../../store/authStoreDefaults';
|
||||
import type { TrackPreviewLocation } from '../../../store/authStoreTypes';
|
||||
import SettingsSubSection from '../../SettingsSubSection';
|
||||
|
||||
interface Props {
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track previews subsection: master toggle on top, then (when enabled)
|
||||
* a per-location toggle grid, a "start at %" slider, and a duration
|
||||
* slider. Locations come from `TRACK_PREVIEW_LOCATIONS` so adding a new
|
||||
* surface (Search, Now Playing suggestions, …) only needs a single
|
||||
* source-of-truth update.
|
||||
*/
|
||||
export function TrackPreviewsSection({ t }: Props) {
|
||||
const auth = useAuthStore();
|
||||
|
||||
return (
|
||||
<SettingsSubSection
|
||||
title={t('settings.trackPreviewsTitle')}
|
||||
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>
|
||||
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 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>
|
||||
</SettingsSubSection>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user