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 })}
)}
); }