import { useMove } from '@mantine/hooks'; import isElectron from 'is-electron'; import { memo, useCallback, useContext, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { buildMpvAudioFilters, type CompressorSettings, type EqSettings as EqSettingsType, } from './mpv-audio-filters'; import { WebAudioContext } from '/@/renderer/features/player/context/webaudio-context'; import { SettingOption, SettingsSection, } from '/@/renderer/features/settings/components/settings-section'; import { usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store'; import { Button } from '/@/shared/components/button/button'; import { Divider } from '/@/shared/components/divider/divider'; import { Group } from '/@/shared/components/group/group'; import { NumberInput } from '/@/shared/components/number-input/number-input'; import { Select } from '/@/shared/components/select/select'; import { Slider } from '/@/shared/components/slider/slider'; import { Stack } from '/@/shared/components/stack/stack'; import { Switch } from '/@/shared/components/switch/switch'; import { TextInput } from '/@/shared/components/text-input/text-input'; import { Text } from '/@/shared/components/text/text'; import { PlayerType } from '/@/shared/types/types'; const mpvPlayer = isElectron() ? window.api.mpvPlayer : null; const BAND_LABELS = [ '31.5', '63', '125', '250', '500', '1k', '2k', '3k', '4k', '6.3k', '10k', '16k', ]; const EQ_MIN = -12; const EQ_MAX = 12; const EQ_STEP = 0.5; // ─── Built-in EQ presets ────────────────────────────────────────────────────── const EQ_PRESETS: Record = { Acoustic: [2, 2, 3, 2, 1, 0, 1, 2, 2, 2, 2, 1], 'Bass Boost': [6, 5, 4, 2, 1, 0, 0, 0, 0, 0, 0, 0], 'Bass Cut': [-6, -5, -4, -2, -1, 0, 0, 0, 0, 0, 0, 0], Classical: [0, 0, 0, 0, 0, 0, -1, -1, 0, 0, 0, -3], Electronic: [4, 3, 1, 0, -1, 0, 1, 0, 0, 2, 3, 4], Flat: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 'Hip-Hop': [5, 4, 2, 1, 0, -1, 0, 1, 0, 1, 2, 3], Jazz: [2, 1, 0, 1, 2, 2, 1, 0, 0, 1, 2, 2], Loudness: [5, 3, 1, 0, -1, -2, -2, -1, 0, 1, 3, 6], Pop: [-1, 0, 2, 3, 3, 2, 0, -1, -1, 0, 0, 0], Rock: [3, 2, 1, 0, -1, 0, 1, 2, 2, 2, 3, 3], 'Treble Boost': [0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6], 'Treble Cut': [0, 0, 0, 0, 0, 0, -1, -2, -3, -4, -5, -6], 'V-Shape': [5, 3, 1, 0, -1, -2, -2, -1, 0, 1, 3, 5], 'Vocal Boost': [-1, 0, 1, 2, 3, 4, 4, 3, 2, 1, 0, -1], }; // ─── Built-in compressor presets ───────────────────────────────────────────── type CompressorPreset = Omit; const COMP_PRESETS: Record = { Broadcast: { attack: 15, knee: 3, makeup: 6, ratio: 5, release: 200, threshold: -20 }, Default: { attack: 20, knee: 2.83, makeup: 6, ratio: 4, release: 250, threshold: -24 }, Gentle: { attack: 50, knee: 6, makeup: 2, ratio: 1.5, release: 500, threshold: -15 }, Heavy: { attack: 10, knee: 2, makeup: 8, ratio: 8, release: 150, threshold: -30 }, Light: { attack: 30, knee: 4, makeup: 3, ratio: 2, release: 400, threshold: -18 }, Limiter: { attack: 1, knee: 1, makeup: 0, ratio: 20, release: 100, threshold: -3 }, 'Loud Master': { attack: 5, knee: 2, makeup: 10, ratio: 6, release: 100, threshold: -28 }, Moderate: { attack: 20, knee: 3, makeup: 5, ratio: 4, release: 300, threshold: -24 }, }; // ─── Storage helpers ────────────────────────────────────────────────────────── const LS_EQ_PRESETS = 'feishin_eq_custom_presets'; const LS_COMP_PRESETS = 'feishin_comp_custom_presets'; function loadCustomPresets(key: string): Record { try { return JSON.parse(localStorage.getItem(key) || '{}'); } catch { return {}; } } function saveCustomPresets(key: string, presets: Record) { localStorage.setItem(key, JSON.stringify(presets)); } // ─── Vertical EQ band slider ────────────────────────────────────────────────── // Mantine v8 does not include orientation="vertical" on Slider. // We use useMove from @mantine/hooks (the Mantine-recommended approach for // vertical sliders in v8) so drag direction is correct — dragging up // increases the value, dragging down decreases it. // Styling uses the same CSS variables as the existing Slider module CSS // so it inherits the app theme correctly. const TRACK_H = 120; // px — rendered height of the vertical track const THUMB_R = 6; // px — thumb radius function EqBandSlider({ gain, label, onChangeEnd, }: { freq: number; gain: number; label: string; onChangeEnd: (v: number) => void; }) { // currentGain drives the live visual during dragging. // It is synced from the `gain` prop when external changes arrive // (preset applied, reset). const [currentGain, setCurrentGain] = useState(gain); const currentGainRef = useRef(currentGain); // Stable ref so onScrubEnd always calls the latest onChangeEnd even // though useMove's refCallback closes over the initial handlers object. const onChangeEndRef = useRef(onChangeEnd); useEffect(() => { setCurrentGain(gain); currentGainRef.current = gain; }, [gain]); useEffect(() => { onChangeEndRef.current = onChangeEnd; }, [onChangeEnd]); // handleMove must be stable (empty deps) so useMove's internal // refCallback is only created once and listeners are not re-bound // on every render. const handleMove = useCallback(({ y }: { x: number; y: number }) => { // useMove gives y=0 at the top of the element and y=1 at the bottom. // Invert so dragging upward increases the value. const raw = EQ_MAX - y * (EQ_MAX - EQ_MIN); const stepped = Math.round(raw / EQ_STEP) * EQ_STEP; const clamped = Math.max(EQ_MIN, Math.min(EQ_MAX, stepped)); setCurrentGain(clamped); currentGainRef.current = clamped; }, []); const { active, ref } = useMove(handleMove, { onScrubEnd: () => { // Access ref so the latest onChangeEnd is called even though // this handler was captured in the initial closure. onChangeEndRef.current(currentGainRef.current); }, }); // Percentage from bottom: 0 = min (-12dB), 100 = max (+12dB) const thumbPct = ((currentGain - EQ_MIN) / (EQ_MAX - EQ_MIN)) * 100; const zeroPct = ((0 - EQ_MIN) / (EQ_MAX - EQ_MIN)) * 100; // 50 for ±12 range // Fill spans between zero line and thumb, regardless of direction const fillBottomPct = Math.min(thumbPct, zeroPct); const fillTopPct = 100 - Math.max(thumbPct, zeroPct); return ( {/* Manual value input — keyed on gain prop so it remounts when an external change arrives (preset, reset) */} { const val = parseFloat(e.currentTarget.value); if (!isNaN(val)) { const clamped = Math.max(EQ_MIN, Math.min(EQ_MAX, val)); setCurrentGain(clamped); currentGainRef.current = clamped; onChangeEndRef.current(clamped); } }} onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur(); }} size="xs" step={EQ_STEP} w={52} /> {/* Vertical track — useMove attaches pointer listeners here */}
{/* Coloured fill between the zero line and the thumb */}
{/* Zero-line tick mark */}
{/* Thumb — centre is at thumbPct% from the bottom */}
{/* Frequency label */} {label} ); } // ─── Main component ─────────────────────────────────────────────────────────── export const EqSettings = memo(() => { const { t } = useTranslation(); const settings = usePlaybackSettings(); const { setSettings } = useSettingsStoreActions(); // Ref pattern to avoid stale closure when reading webAudio DSP nodes. // webAudio?.dsp is undefined at callback creation time; the closure // would capture that undefined even after AudioContext initialises. const webAudioContext = useContext(WebAudioContext); const webAudioContextRef = useRef(webAudioContext); useEffect(() => { webAudioContextRef.current = webAudioContext; }, [webAudioContext]); // Custom preset state — stored in localStorage separately from main store const [customEqPresets, setCustomEqPresets] = useState>(() => loadCustomPresets(LS_EQ_PRESETS), ); const [customCompPresets, setCustomCompPresets] = useState>( () => loadCustomPresets(LS_COMP_PRESETS), ); const [saveEqName, setSaveEqName] = useState(''); const [saveCompName, setSaveCompName] = useState(''); const applyFilters = useCallback( (eq: EqSettingsType, compressor: CompressorSettings) => { // ── MPV player ──────────────────────────────────────────────── if (settings.type === PlayerType.LOCAL) { const filterStr = buildMpvAudioFilters(eq, compressor); mpvPlayer?.setProperties({ af: filterStr }); return; } // ── Web Audio player ────────────────────────────────────────── // Read from ref so we always get the current AudioContext state, // not the stale value captured when this callback was created. const dsp = webAudioContextRef.current.webAudio?.dsp; if (!dsp) return; // Mutations to Web Audio API AudioParam values are intentional // side effects on the live audio graph, not React state mutations. // eslint-disable-next-line react-hooks/immutability dsp.preampGain.gain.value = eq.enabled ? Math.pow(10, eq.preamp / 20) : 1; dsp.eqFilters.forEach((filter, i) => { const band = eq.bands[i]; if (band) { filter.gain.value = eq.enabled ? band.gain : 0; } }); if (compressor.enabled) { dsp.compressor.threshold.value = compressor.threshold; dsp.compressor.ratio.value = compressor.ratio; dsp.compressor.attack.value = compressor.attack / 1000; dsp.compressor.release.value = compressor.release / 1000; dsp.compressor.knee.value = compressor.knee; } else { dsp.compressor.threshold.value = 0; dsp.compressor.ratio.value = 1; dsp.compressor.attack.value = 0; dsp.compressor.release.value = 0.25; dsp.compressor.knee.value = 0; } }, // settings.type is the only reactive dep — webAudioContextRef is a // stable ref that always holds the latest context value. [settings.type], ); // Re-apply filters when switching to Web Audio so DSP nodes reflect // persisted settings immediately without requiring a slider interaction. useEffect(() => { if (settings.type === PlayerType.WEB) { applyFilters(settings.equalizer, settings.compressor); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [settings.type]); // ── EQ handlers ────────────────────────────────────────────────────────── const handleEqToggle = (enabled: boolean) => { const newEq = { ...settings.equalizer, enabled }; setSettings({ playback: { equalizer: newEq } }); applyFilters(newEq, settings.compressor); }; const handlePreampChangeEnd = (preamp: number) => { const newEq = { ...settings.equalizer, preamp }; setSettings({ playback: { equalizer: newEq } }); applyFilters(newEq, settings.compressor); }; const handleBandChangeEnd = (index: number, gain: number) => { const newBands = settings.equalizer.bands.map((b, i) => (i === index ? { ...b, gain } : b)); const newEq = { ...settings.equalizer, bands: newBands }; setSettings({ playback: { equalizer: newEq } }); applyFilters(newEq, settings.compressor); }; const applyEqPreset = (gains: number[]) => { const newBands = settings.equalizer.bands.map((b, i) => ({ ...b, gain: gains[i] ?? 0 })); const newEq = { ...settings.equalizer, bands: newBands, preamp: 0 }; setSettings({ playback: { equalizer: newEq } }); applyFilters(newEq, settings.compressor); }; const handleSaveEqPreset = () => { const name = saveEqName.trim(); if (!name) return; const gains = settings.equalizer.bands.map((b) => b.gain); const updated = { ...customEqPresets, [name]: gains }; setCustomEqPresets(updated); saveCustomPresets(LS_EQ_PRESETS, updated); setSaveEqName(''); }; const handleDeleteEqPreset = (name: string) => { const updated = { ...customEqPresets }; delete updated[name]; setCustomEqPresets(updated); saveCustomPresets(LS_EQ_PRESETS, updated); }; const handleResetEq = () => { const newEq = { ...settings.equalizer, bands: settings.equalizer.bands.map((b) => ({ ...b, gain: 0 })), preamp: 0, }; setSettings({ playback: { equalizer: newEq } }); applyFilters(newEq, settings.compressor); }; // ── Compressor handlers ─────────────────────────────────────────────────── const handleCompToggle = (enabled: boolean) => { const newComp = { ...settings.compressor, enabled }; setSettings({ playback: { compressor: newComp } }); applyFilters(settings.equalizer, newComp); }; const handleCompChangeEnd = (key: keyof CompressorSettings, value: number) => { const newComp = { ...settings.compressor, [key]: value }; setSettings({ playback: { compressor: newComp } }); applyFilters(settings.equalizer, newComp); }; const applyCompPreset = (preset: CompressorPreset) => { const newComp = { ...settings.compressor, ...preset }; setSettings({ playback: { compressor: newComp } }); applyFilters(settings.equalizer, newComp); }; const handleSaveCompPreset = () => { const name = saveCompName.trim(); if (!name) return; const rest = Object.fromEntries( Object.entries(settings.compressor).filter(([key]) => key !== 'enabled'), ) as CompressorPreset; const updated = { ...customCompPresets, [name]: rest }; setCustomCompPresets(updated); saveCustomPresets(LS_COMP_PRESETS, updated); setSaveCompName(''); }; const handleDeleteCompPreset = (name: string) => { const updated = { ...customCompPresets }; delete updated[name]; setCustomCompPresets(updated); saveCustomPresets(LS_COMP_PRESETS, updated); }; const handleResetComp = () => { const newComp = { attack: 20, enabled: settings.compressor.enabled, knee: 2.83, makeup: 6, ratio: 4, release: 250, threshold: -24, }; setSettings({ playback: { compressor: newComp } }); applyFilters(settings.equalizer, newComp); }; // ── Preset select data ──────────────────────────────────────────────────── const eqPresetSelectData = [ { group: t('setting.equalizerPresetGroupBuiltIn'), items: Object.keys(EQ_PRESETS).map((name) => ({ label: name, value: name })), }, ...(Object.keys(customEqPresets).length > 0 ? [ { group: t('setting.equalizerPresetGroupCustom'), items: Object.keys(customEqPresets).map((name) => ({ label: name, value: name, })), }, ] : []), ]; const compPresetSelectData = [ { group: t('setting.equalizerPresetGroupBuiltIn'), items: Object.keys(COMP_PRESETS).map((name) => ({ label: name, value: name })), }, ...(Object.keys(customCompPresets).length > 0 ? [ { group: t('setting.equalizerPresetGroupCustom'), items: Object.keys(customCompPresets).map((name) => ({ label: name, value: name, })), }, ] : []), ]; // ── EQ SettingsSection options ──────────────────────────────────────────── const eqOptions: SettingOption[] = [ { control: ( handleEqToggle(e.currentTarget.checked)} /> ), description: settings.type === PlayerType.LOCAL ? t('setting.equalizer', { context: 'descriptionMpv' }) : t('setting.equalizer', { context: 'descriptionWebAudio' }), title: t('setting.equalizer'), }, ...(settings.equalizer.enabled ? ([ { control: ( ({ label: name, value: name, }))} onChange={(name) => { if (!name) return; handleDeleteEqPreset(name); }} placeholder={t('setting.equalizerPresetDeletePlaceholder')} value={null} w={160} /> )} ), description: t('setting.equalizerPreset', { context: 'description' }), title: t('setting.equalizerPreset'), }, { control: ( setSaveEqName(e.currentTarget.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleSaveEqPreset(); }} placeholder={t('setting.equalizerPresetNamePlaceholder')} value={saveEqName} w={180} /> ), description: t('setting.equalizerSavePreset', { context: 'description' }), title: t('setting.equalizerSavePreset'), }, { control: ( `${v > 0 ? '+' : ''}${v} dB`} max={EQ_MAX} min={EQ_MIN} onChange={(v) => { setSettings({ playback: { equalizer: { ...settings.equalizer, preamp: v }, }, }); }} onChangeEnd={handlePreampChangeEnd} step={EQ_STEP} value={settings.equalizer.preamp} w={200} /> {/* Manual preamp input */} { const val = parseFloat(e.currentTarget.value); if (!isNaN(val)) { const clamped = Math.max(EQ_MIN, Math.min(EQ_MAX, val)); handlePreampChangeEnd(clamped); } }} onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur(); }} rightSection={ dB } size="sm" step={EQ_STEP} value={settings.equalizer.preamp} w={70} /> ), description: t('setting.equalizerPreamp', { context: 'description' }), title: t('setting.equalizerPreamp'), }, { control: ( // EqBandSlider uses useMove for correct vertical drag direction // (up = higher value, down = lower value). The NumberInput above // each band allows precise manual entry. {settings.equalizer.bands.map((band, i) => ( handleBandChangeEnd(i, v)} /> ))} ), description: t('setting.equalizerBands', { context: 'description' }), title: t('setting.equalizerBands'), }, ] as SettingOption[]) : []), ]; // ── Compressor param definitions ────────────────────────────────────────── const compParams: { description: string; key: keyof CompressorSettings; max: number; min: number; step: number; title: string; unit: string; }[] = [ { description: t('setting.compressorThreshold', { context: 'description' }), key: 'threshold', max: 0, min: -60, step: 1, title: t('setting.compressorThreshold'), unit: 'dB', }, { description: t('setting.compressorRatio', { context: 'description' }), key: 'ratio', max: 20, min: 1, step: 0.5, title: t('setting.compressorRatio'), unit: ':1', }, { description: t('setting.compressorAttack', { context: 'description' }), key: 'attack', max: 2000, min: 0.1, step: 1, title: t('setting.compressorAttack'), unit: 'ms', }, { description: t('setting.compressorRelease', { context: 'description' }), key: 'release', max: 9000, min: 1, step: 10, title: t('setting.compressorRelease'), unit: 'ms', }, { description: t('setting.compressorMakeupGain', { context: 'description' }), key: 'makeup', max: 30, min: 0, step: 0.5, title: t('setting.compressorMakeupGain'), unit: 'dB', }, { description: t('setting.compressorKnee', { context: 'description' }), key: 'knee', max: 10, min: 1, step: 0.5, title: t('setting.compressorKnee'), unit: 'dB', }, ]; // ── Compressor SettingsSection options ──────────────────────────────────── const compressorOptions: SettingOption[] = [ { control: ( handleCompToggle(e.currentTarget.checked)} /> ), description: settings.type === PlayerType.LOCAL ? t('setting.compressor', { context: 'descriptionMpv' }) : t('setting.compressor', { context: 'descriptionWebAudio' }), title: t('setting.compressor'), }, ...(settings.compressor.enabled ? ([ { control: ( ({ label: name, value: name, }))} onChange={(name) => { if (!name) return; handleDeleteCompPreset(name); }} placeholder={t('setting.equalizerPresetDeletePlaceholder')} value={null} w={160} /> )} ), description: t('setting.compressorPreset', { context: 'description' }), title: t('setting.equalizerPreset'), }, { control: ( setSaveCompName(e.currentTarget.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleSaveCompPreset(); }} placeholder={t('setting.equalizerPresetNamePlaceholder')} value={saveCompName} w={180} /> ), description: t('setting.compressorSavePreset', { context: 'description' }), title: t('setting.equalizerSavePreset'), }, // One SettingOption per compressor parameter — Slider + NumberInput ...compParams.map(({ description, key, max, min, step, title, unit }) => ({ control: ( `${v}${unit}`} max={max} min={min} onChange={(v) => { setSettings({ playback: { compressor: { ...settings.compressor, [key]: v }, }, }); }} onChangeEnd={(v) => handleCompChangeEnd(key, v)} step={step} value={settings.compressor[key] as number} w={200} /> {/* Manual value input — remounts with new defaultValue when settings change (preset applied, slider moved) */} { const val = parseFloat(e.currentTarget.value); if (!isNaN(val)) { const clamped = Math.max(min, Math.min(max, val)); handleCompChangeEnd(key, clamped); } }} onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur(); }} rightSection={ {unit} } size="sm" step={step} value={settings.compressor[key] as number} w={80} /> ), description, title, })), { control: ( ), description: t('setting.compressorReset', { context: 'description' }), title: t('common.reset'), }, ] as SettingOption[]) : []), ]; return ( <> ); });