/* ============================================================ EFFECTS PAGE — Sound Effect Settings Design: Reference luxsin_x8_音效.jpg Layout: - Page header with global Effect toggle - Glass cards: Style, 声场宽度, 交叉反馈, 音调, 响度 All dropdowns → navigate to /select page ============================================================ */ import { useDevice } from "@/contexts/DeviceContext"; import { ChevronLeft, ChevronRight } from "lucide-react"; import { useLocation } from "wouter"; import { useState, useEffect, useRef, useMemo } from "react"; import BottomNav from "@/components/BottomNav"; import SubwooferLpfChart from "@/components/SubwooferLpfChart"; import { FeatureGate } from "@/components/FeatureGate"; import { navigateToSelect } from "./SelectPage"; import { parseFirmwareVersion } from "@/lib/luxsinApi"; import localeZh from "@/locales/data-zh.json"; import localeZhHK from "@/locales/data-zh-HK.json"; import localeEn from "@/locales/data-en.json"; type EffectLocale = NonNullable<(typeof localeZh)["effect"]>; function clampPickIndex(i: number, len: number) { if (len <= 0) return 0; return Math.min(Math.max(i, 0), len - 1); } const SUBWOOFER_DELAY_MS_DIVISOR = 48; const SUBWOOFER_DELAY_CM_NUMERATOR = 34; const SUBWOOFER_DELAY_MAX = 1920; function clampSubwooferDelayValue(v: number) { return Math.round(Math.min(SUBWOOFER_DELAY_MAX, Math.max(0, v))); } function subwooferDelayToMs(value: number) { return value / SUBWOOFER_DELAY_MS_DIVISOR; } function subwooferDelayToCm(value: number) { return (value * SUBWOOFER_DELAY_CM_NUMERATOR) / SUBWOOFER_DELAY_MS_DIVISOR; } function formatSubwooferDelayValue(value: number) { const ms = subwooferDelayToMs(value); const cm = subwooferDelayToCm(value); return `${ms.toFixed(2)}ms(${cm.toFixed(1)}cm)`; } type DelayChannel = "L" | "R"; function DelaySpeakerIcon({ direction }: { direction: "left" | "right" }) { return ( ); } function FullRangeToggleButton({ label, active, onClick, }: { label: string; active: boolean; onClick: () => void; }) { return ( ); } function DelayChannelToggle({ channel, onChange, leftLabel, rightLabel, }: { channel: DelayChannel; onChange: (ch: DelayChannel) => void; leftLabel: string; rightLabel: string; }) { const btnClass = (active: boolean) => `flex items-center justify-center w-10 h-10 rounded-lg transition-colors ${ active ? "bg-[#00FFF6]/20 text-[#00FFF6]" : "text-white/35 active:bg-white/10" }`; return (
); } function IOSToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { return ( ); } function CyanSlider({ value, min, max, step = 0.1, onChange, onRelease }: { value: number; min: number; max: number; step?: number; onChange: (v: number) => void; /** Fires once when the user releases the thumb (pointer/mouse/touch). Read value from DOM to avoid stale React state. */ onRelease?: (v: number) => void; }) { const fillPct = ((value - min) / (max - min)) * 100; const emitRelease = (el: EventTarget | null) => { if (!(el instanceof HTMLInputElement)) return; onRelease?.(Number(el.value)); }; return (
onChange(Number(e.target.value))} onPointerUp={(e) => emitRelease(e.currentTarget)} onPointerCancel={(e) => emitRelease(e.currentTarget)} onKeyUp={(e) => { if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End", "PageUp", "PageDown"].includes(e.key)) { emitRelease(e.currentTarget); } }} />
); } export default function EffectsPage() { const [, setLocation] = useLocation(); const { deviceState, updateSetting } = useDevice(); const effectText = useMemo((): EffectLocale => { const lang = deviceState?.language; const pack = lang === 0 ? localeEn : lang === 1 ? localeZhHK : localeZh; return (pack.effect ?? localeZh.effect) as EffectLocale; }, [deviceState?.language]); const sceneLabels = useMemo(() => { const opts = effectText.style?.options ?? []; return [...opts].sort((a, b) => a.index - b.index).map((o) => o.label); }, [effectText]); const crossfeedLabels = useMemo( () => effectText.crossfeed?.options ?? [], [effectText], ); const ds = deviceState; const bypassOn = (ds?.dsp_enable ?? 0) === 0; const audioOn = (ds?.audio_enable ?? 0) === 1; const effectOn = audioOn && !bypassOn; const widthOn = (ds?.width_enable ?? 0) === 1; const widthVal = ds?.width_value ?? 50; const crossfeedOn = (ds?.crossfeed_enable ?? 0) === 1; const crossfeedVal = ds?.crossfeed_value ?? 0; const sceneOn = (ds?.effect_enable ?? 0) === 1; const sceneVal = ds?.effect_value ?? 0; const colorOn = (ds?.color_enable ?? 0) === 1; const colorBassGain = (ds?.color_bass_gain ?? 0) / 10; const colorMidGain = (ds?.color_mid_gain ?? 0) / 10; const colorTrebleGain = (ds?.color_treble_gain ?? 0) / 10; const loudnessOn = (ds?.loudness_enable ?? 0) === 1; const loudnessThreshold = ds?.loudness_threshold_gain ?? 0; const loudnessBassGain = (ds?.loudness_bass_gain ?? 0) / 10; const loudnessTrebleGain = (ds?.loudness_treble_gain ?? 0) / 10; const subwooferOn = (ds?.subwoofer_enable ?? 0) === 1; const subwooferFreq = Math.min(300, Math.max(40, ds?.subwoofer_value ?? 80)); const subwooferRate = Math.min(7, Math.max(0, ds?.subwoofer_rate ?? 0)); const subwooferGain = Math.min(15, Math.max(-15, ds?.subwoofer_gain ?? 0)); const subwooferMixType = ds?.subwoofer_mix_type ?? 0; const subwooferLpfOn = (ds?.subwoofer_lpf_enable ?? 0) === 1; const subwooferHpfOn = (ds?.subwoofer_hpf_enable ?? 0) === 1; const firmwareVersion = parseFirmwareVersion(ds?.version); const showSubwooferFullRange = firmwareVersion >= 2001; const subwooferDelayMainL = clampSubwooferDelayValue(ds?.subwoofer_delay_main ?? 0); const subwooferDelaySubL = clampSubwooferDelayValue(ds?.subwoofer_delay ?? 0); const subwooferDelayMainR = clampSubwooferDelayValue(ds?.subwoofer_delay_main_r ?? 0); const subwooferDelaySubR = clampSubwooferDelayValue(ds?.subwoofer_delay_r ?? 0); const subwooferText = effectText.subwoofer; const subwooferDelayText = subwooferText?.delay; const subwooferRateLabels = useMemo( () => subwooferText?.rateOptions ?? [ "6dB/oct", "12dB/oct", "18dB/oct", "24dB/oct", "30dB/oct", "36dB/oct", "42dB/oct", "48dB/oct", ], [subwooferText], ); const subwooferOutputLabels = useMemo(() => { const opts = subwooferText?.output?.options ?? []; return [...opts].sort((a, b) => a.index - b.index).map((o) => o.label); }, [subwooferText]); const [localBass, setLocalBass] = useState(colorBassGain); const [localMid, setLocalMid] = useState(colorMidGain); const [localTreble, setLocalTreble] = useState(colorTrebleGain); const isDraggingBass = useRef(false); const isDraggingMid = useRef(false); const isDraggingTreble = useRef(false); const [localLoudnessThreshold, setLocalLoudnessThreshold] = useState(loudnessThreshold); const [localLoudnessBass, setLocalLoudnessBass] = useState(loudnessBassGain); const [localLoudnessTreble, setLocalLoudnessTreble] = useState(loudnessTrebleGain); const isDraggingLoudnessThreshold = useRef(false); const isDraggingLoudnessBass = useRef(false); const isDraggingLoudnessTreble = useRef(false); useEffect(() => { if (!isDraggingBass.current) setLocalBass(colorBassGain); if (!isDraggingMid.current) setLocalMid(colorMidGain); if (!isDraggingTreble.current) setLocalTreble(colorTrebleGain); if (!isDraggingLoudnessThreshold.current) setLocalLoudnessThreshold(loudnessThreshold); if (!isDraggingLoudnessBass.current) setLocalLoudnessBass(loudnessBassGain); if (!isDraggingLoudnessTreble.current) setLocalLoudnessTreble(loudnessTrebleGain); }, [colorBassGain, colorMidGain, colorTrebleGain, loudnessThreshold, loudnessBassGain, loudnessTrebleGain]); const [localWidth, setLocalWidth] = useState(() => Math.round(widthVal)); const isDragging = useRef(false); const [delayChannel, setDelayChannel] = useState("L"); const subwooferDelayMain = delayChannel === "L" ? subwooferDelayMainL : subwooferDelayMainR; const subwooferDelaySub = delayChannel === "L" ? subwooferDelaySubL : subwooferDelaySubR; const subwooferDelayMainField = delayChannel === "L" ? "subwoofer_delay_main" : "subwoofer_delay_main_r"; const subwooferDelaySubField = delayChannel === "L" ? "subwoofer_delay" : "subwoofer_delay_r"; const [localSubFreq, setLocalSubFreq] = useState(subwooferFreq); const [localSubGain, setLocalSubGain] = useState(subwooferGain); const [localSubDelayMain, setLocalSubDelayMain] = useState(subwooferDelayMain); const [localSubDelaySub, setLocalSubDelaySub] = useState(subwooferDelaySub); const isDraggingSubFreq = useRef(false); const isDraggingSubGain = useRef(false); const isDraggingSubDelayMain = useRef(false); const isDraggingSubDelaySub = useRef(false); useEffect(() => { if (!isDragging.current) setLocalWidth(Math.round(widthVal)); }, [widthVal]); useEffect(() => { if (!isDraggingSubFreq.current) setLocalSubFreq(subwooferFreq); if (!isDraggingSubGain.current) setLocalSubGain(subwooferGain); if (!isDraggingSubDelayMain.current) setLocalSubDelayMain(subwooferDelayMain); if (!isDraggingSubDelaySub.current) setLocalSubDelaySub(subwooferDelaySub); }, [subwooferFreq, subwooferGain, subwooferDelayMain, subwooferDelaySub]); const goSelect = (title: string, options: string[], selected: number, key: string) => { navigateToSelect(title, options, selected, "/effects", key); setLocation("/select"); }; const sceneIdx = clampPickIndex(sceneVal, sceneLabels.length); const sceneDisplay = sceneLabels[sceneIdx] ?? ""; const crossIdx = clampPickIndex(crossfeedVal, crossfeedLabels.length); const crossfeedDisplay = crossfeedLabels[crossIdx] ?? ""; const subRateIdx = clampPickIndex(subwooferRate, subwooferRateLabels.length); const subRateDisplay = subwooferRateLabels[subRateIdx] ?? ""; const subMixIdx = clampPickIndex(subwooferMixType, subwooferOutputLabels.length); const subMixDisplay = subwooferOutputLabels[subMixIdx] ?? ""; return (

{effectText.pageTitle}

{ if (v) { void updateSetting( bypassOn ? { audio_enable: 1, dsp_enable: 1 } : { audio_enable: 1 }, ); } else { void updateSetting({ audio_enable: 0 }); } }} />
{ const bypass = (ds?.dsp_enable ?? 0) === 0; return updateSetting(bypass ? { audio_enable: 1, dsp_enable: 1 } : { audio_enable: 1 }); }} > {/* ── Style ── */}
{effectText.style.label} updateSetting({ effect_enable: v ? 1 : 0 })} />
{/* ── 声场宽度 ── */}
{effectText.stereoWidth.label} {Math.round(localWidth)}
updateSetting({ width_enable: v ? 1 : 0 })} />
{ isDragging.current = true; setLocalWidth(Math.round(v)); }} onRelease={(v) => { isDragging.current = false; const w = Math.round(v); setLocalWidth(w); updateSetting({ width_value: w }); }} />
{/* ── 交叉反馈 ── */}
{effectText.crossfeed.label} updateSetting({ crossfeed_enable: v ? 1 : 0 })} />
{/* ── 音调 ── */}
{effectText.tone} updateSetting({ color_enable: v ? 1 : 0 })} />
{colorOn && (
{/* 低音 */}
{effectText.lowBass} {localBass.toFixed(1)}
{ isDraggingBass.current = true; setLocalBass(v); }} onRelease={(v) => { isDraggingBass.current = false; updateSetting({ color_bass_gain: Math.round(v * 10) }); }} />
{/* 中音 */}
{effectText.enterBass} {localMid.toFixed(1)}
{ isDraggingMid.current = true; setLocalMid(v); }} onRelease={(v) => { isDraggingMid.current = false; updateSetting({ color_mid_gain: Math.round(v * 10) }); }} />
{/* 高音 */}
{effectText.highBass} {localTreble.toFixed(1)}
{ isDraggingTreble.current = true; setLocalTreble(v); }} onRelease={(v) => { isDraggingTreble.current = false; updateSetting({ color_treble_gain: Math.round(v * 10) }); }} />
)}
{/* ── 响度 ── */}
{effectText.loudness} updateSetting({ loudness_enable: v ? 1 : 0 })} />
{loudnessOn && (
{/* 阈值 */}
{effectText.threshold} {localLoudnessThreshold}
{ isDraggingLoudnessThreshold.current = true; setLocalLoudnessThreshold(v); }} onRelease={(v) => { isDraggingLoudnessThreshold.current = false; updateSetting({ loudness_threshold_gain: Math.round(v) }); }} />
{/* Loudness bass */}
{effectText.lowBass} {localLoudnessBass.toFixed(1)}
{ isDraggingLoudnessBass.current = true; setLocalLoudnessBass(v); }} onRelease={(v) => { isDraggingLoudnessBass.current = false; updateSetting({ loudness_bass_gain: Math.round(v * 10) }); }} />
{/* Loudness treble */}
{effectText.highBass} {localLoudnessTreble.toFixed(1)}
{ isDraggingLoudnessTreble.current = true; setLocalLoudnessTreble(v); }} onRelease={(v) => { isDraggingLoudnessTreble.current = false; updateSetting({ loudness_treble_gain: Math.round(v * 10) }); }} />
)}
{/* ── 低音炮输出 ── */}
{subwooferText?.label ?? "低音炮输出"} updateSetting({ subwoofer_enable: v ? 1 : 0 })} />
{subwooferOn && (
{subwooferText?.cutOffFrequency ?? "截止频率"} {localSubFreq}Hz
{ isDraggingSubFreq.current = true; setLocalSubFreq(Math.round(v)); }} onRelease={(v) => { isDraggingSubFreq.current = false; const freq = Math.round(Math.min(300, Math.max(40, v))); setLocalSubFreq(freq); updateSetting({ subwoofer_value: freq }); }} /> {showSubwooferFullRange && (
updateSetting({ subwoofer_lpf_enable: subwooferLpfOn ? 0 : 1 }) } /> updateSetting({ subwoofer_hpf_enable: subwooferHpfOn ? 0 : 1 }) } />
)}
{subwooferText?.attenuationSlope ?? "衰减斜率"}
{subwooferText?.gain ?? "增益"} {localSubGain >= 0 ? `+${localSubGain}` : localSubGain} dB
{ isDraggingSubGain.current = true; setLocalSubGain(Math.round(v)); }} onRelease={(v) => { isDraggingSubGain.current = false; const gain = Math.round(Math.min(15, Math.max(-15, v))); setLocalSubGain(gain); updateSetting({ subwoofer_gain: gain }); }} />
{subwooferText?.output?.label ?? "输出方式"}
{subwooferDelayText?.label ?? "延时"}
{subwooferDelayText?.mainSpeaker ?? "主机箱"} {formatSubwooferDelayValue(localSubDelayMain)}
{ isDraggingSubDelayMain.current = true; setLocalSubDelayMain(clampSubwooferDelayValue(v)); }} onRelease={(v) => { isDraggingSubDelayMain.current = false; const delayVal = clampSubwooferDelayValue(v); setLocalSubDelayMain(delayVal); updateSetting({ [subwooferDelayMainField]: delayVal }); }} />
{subwooferDelayText?.subwoofer ?? "低音炮"} {formatSubwooferDelayValue(localSubDelaySub)}
{ isDraggingSubDelaySub.current = true; setLocalSubDelaySub(clampSubwooferDelayValue(v)); }} onRelease={(v) => { isDraggingSubDelaySub.current = false; const delayVal = clampSubwooferDelayValue(v); setLocalSubDelaySub(delayVal); updateSetting({ [subwooferDelaySubField]: delayVal }); }} />
)}
); }