From 8e7dc35d565644102191b7151cb4a5b8783fe228 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Wed, 13 May 2026 02:23:32 +0200 Subject: [PATCH] =?UTF-8?q?refactor(settings):=20G.58=20=E2=80=94=20extrac?= =?UTF-8?q?t=20AudioTab=20(#623)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the Audio tab body into AudioTab: output-device picker (with the canonicalize + list-refresh dance and the audio:device-changed / -reset listener), Hi-Res toggle, embedded Equalizer, the normalization block (off / replaygain / loudness with pre-analysis attenuation slider and LoudnessLufsButtonGroup), Crossfade + Gapless mutual-exclusion toggles, preserve-play-next-order, and Track Previews (locations + start ratio + duration). The audio-devices state (audioDevices, osDefaultAudioDeviceId, deviceSwitching, devicesLoading) and refreshAudioDevices useCallback move into AudioTab; preAnalysisEffectiveDb useMemo moves with them. Settings.tsx 2266 → 1761 LOC (−505). Drops 8 imports that only the audio tab used (lucide Play/Waves; listen; effectiveLoudnessPreAnalysisAttenuationDb; LoudnessLufsButtonGroup; Equalizer; audio-device label helpers; the TRACK_PREVIEW_LOCATIONS / DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB constants; TrackPreviewLocation type). Pure code move — no behaviour change. --- src/components/settings/AudioTab.tsx | 521 +++++++++++++++++++++++++++ src/pages/Settings.tsx | 515 +------------------------- 2 files changed, 526 insertions(+), 510 deletions(-) create mode 100644 src/components/settings/AudioTab.tsx diff --git a/src/components/settings/AudioTab.tsx b/src/components/settings/AudioTab.tsx new file mode 100644 index 00000000..2e8590e8 --- /dev/null +++ b/src/components/settings/AudioTab.tsx @@ -0,0 +1,521 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { invoke } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; +import { AudioLines, Music2, Play, RotateCcw, Sliders, Waves } from 'lucide-react'; +import { useAuthStore } from '../../store/authStore'; +import { DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB, TRACK_PREVIEW_LOCATIONS } from '../../store/authStoreDefaults'; +import type { TrackPreviewLocation } from '../../store/authStoreTypes'; +import CustomSelect from '../CustomSelect'; +import Equalizer from '../Equalizer'; +import SettingsSubSection from '../SettingsSubSection'; +import { LoudnessLufsButtonGroup } from './LoudnessLufsButtonGroup'; +import { IS_MACOS } from '../../utils/platform'; +import { buildAudioDeviceSelectOptions, sortAudioDeviceIds } from '../../utils/audioDeviceLabels'; +import { effectiveLoudnessPreAnalysisAttenuationDb } from '../../utils/loudnessPreAnalysisSlider'; +import { showToast } from '../../utils/toast'; + +export function AudioTab() { + const { t } = useTranslation(); + const auth = useAuthStore(); + const [audioDevices, setAudioDevices] = useState([]); + const [osDefaultAudioDeviceId, setOsDefaultAudioDeviceId] = useState(null); + const [deviceSwitching, setDeviceSwitching] = useState(false); + const [devicesLoading, setDevicesLoading] = useState(false); + + const preAnalysisEffectiveDb = useMemo( + () => effectiveLoudnessPreAnalysisAttenuationDb( + auth.loudnessPreAnalysisAttenuationDb, + auth.loudnessTargetLufs, + ), + [auth.loudnessPreAnalysisAttenuationDb, auth.loudnessTargetLufs], + ); + + const refreshAudioDevices = useCallback((opts?: { silent?: boolean }) => { + const silent = !!opts?.silent; + if (!silent) setDevicesLoading(true); + const listP = invoke('audio_list_devices').catch((e) => { + console.error(e); + showToast(t('settings.audioOutputDeviceListError'), 5000, 'error'); + return [] as string[]; + }); + const defP = invoke('audio_default_output_device_name').catch(() => null); + Promise.all([listP, defP]) + .then(async ([devices, osDefault]) => { + let canon: string | null = null; + try { + canon = await invoke('audio_canonicalize_selected_device'); + if (canon) useAuthStore.getState().setAudioOutputDevice(canon); + } catch { + /* ignore */ + } + const finalList = canon + ? await invoke('audio_list_devices').catch(() => devices) + : devices; + const defId = osDefault ?? null; + setAudioDevices(sortAudioDeviceIds(finalList, defId)); + setOsDefaultAudioDeviceId(defId); + }) + .finally(() => { + if (!silent) setDevicesLoading(false); + }); + }, [t]); + + // Load available audio output devices on mount. + // Skipped on macOS — the stream is pinned to the system default (see + // audioOutputDeviceMacNotice) so there is no picker to populate. + useEffect(() => { + if (IS_MACOS) return; + refreshAudioDevices(); + }, [refreshAudioDevices]); + + // Keep device list + "current system output" mark in sync when the backend reopens the stream. + useEffect(() => { + if (IS_MACOS) return; + let cancelled = false; + const unlisteners: Array<() => void> = []; + (async () => { + for (const ev of ['audio:device-changed', 'audio:device-reset'] as const) { + const u = await listen(ev, () => { + if (!cancelled) refreshAudioDevices({ silent: true }); + }); + if (cancelled) { + u(); + return; + } + unlisteners.push(u); + } + })(); + return () => { + cancelled = true; + for (const u of unlisteners) u(); + }; + }, [refreshAudioDevices]); + + return ( + <> + {/* Audio Output Device */} + } + > +
+ {IS_MACOS ? ( +
+ {t('settings.audioOutputDeviceMacNotice')} +
+ ) : ( + <> +
+ {t('settings.audioOutputDeviceDesc')} +
+
+ { + const device = val || null; + setDeviceSwitching(true); + try { + await invoke('audio_set_device', { deviceName: device }); + auth.setAudioOutputDevice(device); + } catch { /* device open failed — don't persist */ } + setDeviceSwitching(false); + }} + options={buildAudioDeviceSelectOptions( + audioDevices, + t('settings.audioOutputDeviceDefault'), + osDefaultAudioDeviceId, + t('settings.audioOutputDeviceOsDefaultNow'), + auth.audioOutputDevice, + t('settings.audioOutputDeviceNotInCurrentList'), + )} + /> + +
+ + )} +
+
+ + {/* Native Hi-Res Playback */} + } + > +
+
+
+
{t('settings.hiResEnabled')}
+
{t('settings.hiResDesc')}
+
+ +
+
+
+ + {/* Equalizer */} + } + > +
+ +
+
+ + {/* Replay Gain + Crossfade + Gapless */} + } + > +
+ {/* Normalization */} +
+
{t('settings.normalization', { defaultValue: 'Normalization' })}
+
+ {t('settings.normalizationDesc')} +
+
+
+ + + +
+ {auth.normalizationEngine === 'replaygain' && ( +
+
+
+ {t('settings.replayGainMode')} +
+ + + +
+
+ {auth.replayGainMode === 'auto' && ( +
{t('settings.replayGainAutoDesc')}
+ )} +
+
+
+ {t('settings.replayGainPreGain')} + auth.setReplayGainPreGainDb(Number(e.target.value))} + /> + + {auth.replayGainPreGainDb > 0 ? `+${auth.replayGainPreGainDb}` : auth.replayGainPreGainDb} dB + +
+
{t('settings.replayGainPreGainDesc')}
+
+
+
+ {t('settings.replayGainFallback')} + auth.setReplayGainFallbackDb(Number(e.target.value))} + /> + + {auth.replayGainFallbackDb > 0 ? `+${auth.replayGainFallbackDb}` : auth.replayGainFallbackDb} dB + +
+
{t('settings.replayGainFallbackDesc')}
+
+
+ )} + {auth.normalizationEngine === 'loudness' && ( +
+
+
+ {t('settings.loudnessTargetLufs')} + +
+
{t('settings.loudnessTargetLufsDesc')}
+
+
+
+ {t('settings.loudnessPreAnalysisAttenuation')} + auth.setLoudnessPreAnalysisAttenuationDb(Number(e.target.value))} + /> + + {preAnalysisEffectiveDb} dB + + +
+
+ {t('settings.loudnessPreAnalysisAttenuationDesc')}{' '} + {t('settings.loudnessPreAnalysisAttenuationRef', { + ref: auth.loudnessPreAnalysisAttenuationDb, + eff: preAnalysisEffectiveDb, + tgt: auth.loudnessTargetLufs, + })} +
+
+
{t('settings.loudnessFirstPlayNote')}
+
+ )} + +
+ + {/* Crossfade */} +
+
+
+ {t('settings.crossfade')} +
+
+ {auth.gaplessEnabled ? t('settings.notWithGapless') : t('settings.crossfadeDesc')} +
+
+ +
+ {auth.crossfadeEnabled && !auth.gaplessEnabled && ( +
+ auth.setCrossfadeSecs(parseFloat(e.target.value))} + style={{ flex: 1, minWidth: 80, maxWidth: 200 }} + id="crossfade-secs-slider" + /> + + {t('settings.crossfadeSecs', { n: auth.crossfadeSecs.toFixed(1) })} + +
+ )} + +
+ + {/* Gapless */} +
+
+
+ {t('settings.gapless')} +
+
+ {auth.crossfadeEnabled ? t('settings.notWithCrossfade') : t('settings.gaplessDesc')} +
+
+ +
+ +
+
+
+ {t('settings.preservePlayNextOrder')} +
+
+ {t('settings.preservePlayNextOrderDesc')} +
+
+ +
+
+ + + } + > +
+
+
+
+ {t('settings.trackPreviewsToggle')} +
+
+ {t('settings.trackPreviewsDesc')} +
+
+ +
+ + {auth.trackPreviewsEnabled && ( + <> +
+
+
+ {t('settings.trackPreviewLocationsTitle')} +
+
+ {t('settings.trackPreviewLocationsDesc')} +
+
+ {TRACK_PREVIEW_LOCATIONS.map((loc: TrackPreviewLocation) => ( +
+
+ {t(`settings.trackPreviewLocation_${loc}`)} +
+ +
+ ))} +
+
+ +
+
+
+ {t('settings.trackPreviewStart')} +
+
+ {t('settings.trackPreviewStartDesc')} +
+
+ auth.setTrackPreviewStartRatio(parseFloat(e.target.value))} + style={{ flex: 1, minWidth: 80, maxWidth: 240 }} + aria-label={t('settings.trackPreviewStart')} + /> + + {Math.round(auth.trackPreviewStartRatio * 100)}% + +
+
+ +
+
+
+ {t('settings.trackPreviewDuration')} +
+
+ {t('settings.trackPreviewDurationDesc')} +
+
+ auth.setTrackPreviewDurationSec(parseInt(e.target.value, 10))} + style={{ flex: 1, minWidth: 80, maxWidth: 240 }} + aria-label={t('settings.trackPreviewDuration')} + /> + + {t('settings.trackPreviewDurationSecs', { n: auth.trackPreviewDurationSec })} + +
+
+ + )} +
+ + + ); +} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 0852df84..749f26bc 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1,18 +1,16 @@ -import type { ServerProfile, SeekbarStyle, LoggingMode, LoudnessLufsPreset, TrackPreviewLocation } from '../store/authStoreTypes'; -import { DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB, TRACK_PREVIEW_LOCATIONS } from '../store/authStoreDefaults'; +import type { ServerProfile, SeekbarStyle, LoggingMode, LoudnessLufsPreset } from '../store/authStoreTypes'; import React, { useState, useMemo, useCallback, useEffect, useLayoutEffect, useRef } from 'react'; import { version as appVersion } from '../../package.json'; import { useNavigate, useLocation } from 'react-router-dom'; import { Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, - Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, X, Play, Type, Keyboard, ChevronDown, - RotateCcw, LayoutGrid, AppWindow, HardDrive, Download, Waves, Clock, ZoomIn, Sparkles, AlertTriangle, Maximize2, AudioLines, User, Lock, + Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, X, Type, Keyboard, ChevronDown, + RotateCcw, LayoutGrid, AppWindow, HardDrive, Download, Clock, ZoomIn, Sparkles, AlertTriangle, Maximize2, AudioLines, User, Lock, Users, Search, Scale } from 'lucide-react'; import i18n from '../i18n'; import { showToast } from '../utils/toast'; import { invoke } from '@tauri-apps/api/core'; -import { listen } from '@tauri-apps/api/event'; import { open as openUrl } from '@tauri-apps/plugin-shell'; import { getImageCacheSize, clearImageCache } from '../utils/imageCache'; import { useOfflineStore } from '../store/offlineStore'; @@ -28,30 +26,25 @@ import { SeekbarPreview } from '../components/WaveformSeek'; import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform'; import { useThemeStore } from '../store/themeStore'; import { useFontStore, FontId } from '../store/fontStore'; -import { - effectiveLoudnessPreAnalysisAttenuationDb, -} from '../utils/loudnessPreAnalysisSlider'; import { useDragDrop } from '../contexts/DragDropContext'; import { AddServerForm } from '../components/settings/AddServerForm'; +import { AudioTab } from '../components/settings/AudioTab'; import { BackupSection } from '../components/settings/BackupSection'; import { InputTab } from '../components/settings/InputTab'; import { IntegrationsTab } from '../components/settings/IntegrationsTab'; import { LibraryTab } from '../components/settings/LibraryTab'; -import { LoudnessLufsButtonGroup } from '../components/settings/LoudnessLufsButtonGroup'; import { LyricsTab } from '../components/settings/LyricsTab'; import { PersonalisationTab } from '../components/settings/PersonalisationTab'; import { ServerGripHandle } from '../components/settings/ServerGripHandle'; import { SETTINGS_INDEX, type Tab, matchScore, resolveTab } from '../components/settings/settingsTabs'; import { UserManagementSection } from '../components/settings/UserManagementSection'; import { CONTRIBUTORS, MAINTAINERS } from '../config/settingsCredits'; -import { buildAudioDeviceSelectOptions, formatAudioDeviceLabel, sortAudioDeviceIds } from '../utils/audioDeviceLabels'; import { formatBytes, snapHotCacheMb } from '../utils/formatBytes'; import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic'; import { ndLogin } from '../api/navidromeAdmin'; import { switchActiveServer } from '../utils/switchActiveServer'; import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog'; import { Trans, useTranslation } from 'react-i18next'; -import Equalizer from '../components/Equalizer'; import { showAudiomuseNavidromeServerSetting } from '../utils/subsonicServerIdentity'; import { type ServerMagicPayload } from '../utils/serverMagicString'; import { shortHostFromServerUrl, serverListDisplayLabel } from '../utils/serverDisplayName'; @@ -80,13 +73,6 @@ export default function Settings() { return Object.keys(hotCacheEntries).filter(k => k.startsWith(prefix)).length; }, [hotCacheEntries, serverId]); - const preAnalysisEffectiveDb = useMemo( - () => effectiveLoudnessPreAnalysisAttenuationDb( - auth.loudnessPreAnalysisAttenuationDb, - auth.loudnessTargetLufs, - ), - [auth.loudnessPreAnalysisAttenuationDb, auth.loudnessTargetLufs], - ); const navigate = useNavigate(); const location = useLocation(); const routeState = location.state; @@ -115,10 +101,6 @@ export default function Settings() { const [imageCacheBytes, setImageCacheBytes] = useState(null); const [offlineCacheBytes, setOfflineCacheBytes] = useState(null); const [hotCacheBytes, setHotCacheBytes] = useState(null); - const [audioDevices, setAudioDevices] = useState([]); - const [osDefaultAudioDeviceId, setOsDefaultAudioDeviceId] = useState(null); - const [deviceSwitching, setDeviceSwitching] = useState(false); - const [devicesLoading, setDevicesLoading] = useState(false); const [showClearConfirm, setShowClearConfirm] = useState(false); const [clearing, setClearing] = useState(false); const [ndAdminAuth, setNdAdminAuth] = useState<{ token: string; serverUrl: string; username: string } | null>(null); @@ -249,67 +231,6 @@ export default function Settings() { invoke('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0)); }, [activeTab, auth.offlineDownloadDir, auth.hotCacheDownloadDir]); - const refreshAudioDevices = useCallback((opts?: { silent?: boolean }) => { - const silent = !!opts?.silent; - if (!silent) setDevicesLoading(true); - const listP = invoke('audio_list_devices').catch((e) => { - console.error(e); - showToast(t('settings.audioOutputDeviceListError'), 5000, 'error'); - return [] as string[]; - }); - const defP = invoke('audio_default_output_device_name').catch(() => null); - Promise.all([listP, defP]) - .then(async ([devices, osDefault]) => { - let canon: string | null = null; - try { - canon = await invoke('audio_canonicalize_selected_device'); - if (canon) useAuthStore.getState().setAudioOutputDevice(canon); - } catch { - /* ignore */ - } - const finalList = canon - ? await invoke('audio_list_devices').catch(() => devices) - : devices; - const defId = osDefault ?? null; - setAudioDevices(sortAudioDeviceIds(finalList, defId)); - setOsDefaultAudioDeviceId(defId); - }) - .finally(() => { - if (!silent) setDevicesLoading(false); - }); - }, [t]); - - // Load available audio output devices when Audio tab opens. - // Skipped on macOS — the stream is pinned to the system default (see - // audioOutputDeviceMacNotice) so there is no picker to populate. - useEffect(() => { - if (activeTab !== 'audio' || IS_MACOS) return; - refreshAudioDevices(); - }, [activeTab, refreshAudioDevices]); - - // Keep device list + "current system output" mark in sync when the backend reopens the stream. - useEffect(() => { - if (activeTab !== 'audio' || IS_MACOS) return; - let cancelled = false; - const unlisteners: Array<() => void> = []; - (async () => { - for (const ev of ['audio:device-changed', 'audio:device-reset'] as const) { - const u = await listen(ev, () => { - if (!cancelled) refreshAudioDevices({ silent: true }); - }); - if (cancelled) { - u(); - return; - } - unlisteners.push(u); - } - })(); - return () => { - cancelled = true; - for (const u of unlisteners) u(); - }; - }, [activeTab, refreshAudioDevices]); - /** Live disk usage for hot cache while Audio settings are open (interval + refresh when index changes). */ useEffect(() => { if (activeTab !== 'audio') return; @@ -659,433 +580,7 @@ export default function Settings() { {!searchQuery && <> {/* ── Audio ────────────────────────────────────────────────────────────── */} - {activeTab === 'audio' && ( - <> - {/* Audio Output Device */} - } - > -
- {IS_MACOS ? ( -
- {t('settings.audioOutputDeviceMacNotice')} -
- ) : ( - <> -
- {t('settings.audioOutputDeviceDesc')} -
-
- { - const device = val || null; - setDeviceSwitching(true); - try { - await invoke('audio_set_device', { deviceName: device }); - auth.setAudioOutputDevice(device); - } catch { /* device open failed — don't persist */ } - setDeviceSwitching(false); - }} - options={buildAudioDeviceSelectOptions( - audioDevices, - t('settings.audioOutputDeviceDefault'), - osDefaultAudioDeviceId, - t('settings.audioOutputDeviceOsDefaultNow'), - auth.audioOutputDevice, - t('settings.audioOutputDeviceNotInCurrentList'), - )} - /> - -
- - )} -
-
- - {/* Native Hi-Res Playback */} - } - > -
-
-
-
{t('settings.hiResEnabled')}
-
{t('settings.hiResDesc')}
-
- -
-
-
- - {/* Equalizer */} - } - > -
- -
-
- - {/* Replay Gain + Crossfade + Gapless */} - } - > -
- {/* Normalization */} -
-
{t('settings.normalization', { defaultValue: 'Normalization' })}
-
- {t('settings.normalizationDesc')} -
-
-
- - - -
- {auth.normalizationEngine === 'replaygain' && ( -
-
-
- {t('settings.replayGainMode')} -
- - - -
-
- {auth.replayGainMode === 'auto' && ( -
{t('settings.replayGainAutoDesc')}
- )} -
-
-
- {t('settings.replayGainPreGain')} - auth.setReplayGainPreGainDb(Number(e.target.value))} - /> - - {auth.replayGainPreGainDb > 0 ? `+${auth.replayGainPreGainDb}` : auth.replayGainPreGainDb} dB - -
-
{t('settings.replayGainPreGainDesc')}
-
-
-
- {t('settings.replayGainFallback')} - auth.setReplayGainFallbackDb(Number(e.target.value))} - /> - - {auth.replayGainFallbackDb > 0 ? `+${auth.replayGainFallbackDb}` : auth.replayGainFallbackDb} dB - -
-
{t('settings.replayGainFallbackDesc')}
-
-
- )} - {auth.normalizationEngine === 'loudness' && ( -
-
-
- {t('settings.loudnessTargetLufs')} - -
-
{t('settings.loudnessTargetLufsDesc')}
-
-
-
- {t('settings.loudnessPreAnalysisAttenuation')} - auth.setLoudnessPreAnalysisAttenuationDb(Number(e.target.value))} - /> - - {preAnalysisEffectiveDb} dB - - -
-
- {t('settings.loudnessPreAnalysisAttenuationDesc')}{' '} - {t('settings.loudnessPreAnalysisAttenuationRef', { - ref: auth.loudnessPreAnalysisAttenuationDb, - eff: preAnalysisEffectiveDb, - tgt: auth.loudnessTargetLufs, - })} -
-
-
{t('settings.loudnessFirstPlayNote')}
-
- )} - -
- - {/* Crossfade */} -
-
-
- {t('settings.crossfade')} -
-
- {auth.gaplessEnabled ? t('settings.notWithGapless') : t('settings.crossfadeDesc')} -
-
- -
- {auth.crossfadeEnabled && !auth.gaplessEnabled && ( -
- auth.setCrossfadeSecs(parseFloat(e.target.value))} - style={{ flex: 1, minWidth: 80, maxWidth: 200 }} - id="crossfade-secs-slider" - /> - - {t('settings.crossfadeSecs', { n: auth.crossfadeSecs.toFixed(1) })} - -
- )} - -
- - {/* Gapless */} -
-
-
- {t('settings.gapless')} -
-
- {auth.crossfadeEnabled ? t('settings.notWithCrossfade') : t('settings.gaplessDesc')} -
-
- -
- -
-
-
- {t('settings.preservePlayNextOrder')} -
-
- {t('settings.preservePlayNextOrderDesc')} -
-
- -
-
- - - } - > -
-
-
-
- {t('settings.trackPreviewsToggle')} -
-
- {t('settings.trackPreviewsDesc')} -
-
- -
- - {auth.trackPreviewsEnabled && ( - <> -
-
-
- {t('settings.trackPreviewLocationsTitle')} -
-
- {t('settings.trackPreviewLocationsDesc')} -
-
- {TRACK_PREVIEW_LOCATIONS.map((loc: TrackPreviewLocation) => ( -
-
- {t(`settings.trackPreviewLocation_${loc}`)} -
- -
- ))} -
-
- -
-
-
- {t('settings.trackPreviewStart')} -
-
- {t('settings.trackPreviewStartDesc')} -
-
- auth.setTrackPreviewStartRatio(parseFloat(e.target.value))} - style={{ flex: 1, minWidth: 80, maxWidth: 240 }} - aria-label={t('settings.trackPreviewStart')} - /> - - {Math.round(auth.trackPreviewStartRatio * 100)}% - -
-
- -
-
-
- {t('settings.trackPreviewDuration')} -
-
- {t('settings.trackPreviewDurationDesc')} -
-
- auth.setTrackPreviewDurationSec(parseInt(e.target.value, 10))} - style={{ flex: 1, minWidth: 80, maxWidth: 240 }} - aria-label={t('settings.trackPreviewDuration')} - /> - - {t('settings.trackPreviewDurationSecs', { n: auth.trackPreviewDurationSec })} - -
-
- - )} -
- - - - )} + {activeTab === 'audio' && } {/* ── Lyrics ───────────────────────────────────────────────────────────── */} {activeTab === 'lyrics' && }