/* ============================================================
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 (
);
}
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 });
}}
/>
)}
);
}