2026-03-31 18:52:37 +08:00
|
|
|
/* ============================================================
|
|
|
|
|
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";
|
2026-05-13 15:18:07 +08:00
|
|
|
import { useState, useEffect, useRef, useMemo } from "react";
|
2026-03-31 18:52:37 +08:00
|
|
|
import BottomNav from "@/components/BottomNav";
|
2026-05-26 18:46:41 +08:00
|
|
|
import SubwooferLpfChart from "@/components/SubwooferLpfChart";
|
2026-05-19 13:33:10 +08:00
|
|
|
import { FeatureGate } from "@/components/FeatureGate";
|
2026-03-31 18:52:37 +08:00
|
|
|
import { navigateToSelect } from "./SelectPage";
|
2026-05-26 18:46:41 +08:00
|
|
|
import { parseFirmwareVersion } from "@/lib/luxsinApi";
|
2026-05-13 15:18:07 +08:00
|
|
|
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);
|
|
|
|
|
}
|
2026-03-31 18:52:37 +08:00
|
|
|
|
2026-05-26 18:46:41 +08:00
|
|
|
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 (
|
|
|
|
|
<svg
|
|
|
|
|
width="22"
|
|
|
|
|
height="22"
|
|
|
|
|
viewBox="0 0 24 24"
|
|
|
|
|
fill="none"
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
strokeWidth="1.75"
|
|
|
|
|
strokeLinecap="round"
|
|
|
|
|
strokeLinejoin="round"
|
|
|
|
|
className={direction === "left" ? "scale-x-[-1]" : undefined}
|
|
|
|
|
aria-hidden
|
|
|
|
|
>
|
|
|
|
|
<path d="M11 5 6 9H3v6h3l5 4V5z" />
|
|
|
|
|
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
|
|
|
|
|
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
|
|
|
|
|
</svg>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function FullRangeToggleButton({
|
|
|
|
|
label,
|
|
|
|
|
active,
|
|
|
|
|
onClick,
|
|
|
|
|
}: {
|
|
|
|
|
label: string;
|
|
|
|
|
active: boolean;
|
|
|
|
|
onClick: () => void;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={onClick}
|
|
|
|
|
className={`rounded-lg border-2 px-4 py-2.5 text-[13px] font-medium whitespace-nowrap transition-all active:scale-[0.98] ${
|
|
|
|
|
active
|
|
|
|
|
? "border-[#00FFF6] bg-[#00FFF6] text-black shadow-[0_0_10px_rgba(0,255,246,0.35)]"
|
|
|
|
|
: "border-[#00FFF6]/45 bg-transparent text-white/75"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{label}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 (
|
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className={btnClass(channel === "L")}
|
|
|
|
|
onClick={() => onChange("L")}
|
|
|
|
|
aria-label={leftLabel}
|
|
|
|
|
aria-pressed={channel === "L"}
|
|
|
|
|
>
|
|
|
|
|
<DelaySpeakerIcon direction="left" />
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className={btnClass(channel === "R")}
|
|
|
|
|
onClick={() => onChange("R")}
|
|
|
|
|
aria-label={rightLabel}
|
|
|
|
|
aria-pressed={channel === "R"}
|
|
|
|
|
>
|
|
|
|
|
<DelaySpeakerIcon direction="right" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 18:52:37 +08:00
|
|
|
function IOSToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
|
|
|
|
return (
|
|
|
|
|
<label className="ios-toggle" onClick={(e) => e.stopPropagation()}>
|
|
|
|
|
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
|
|
|
|
<span className="ios-toggle-track"><span className="ios-toggle-thumb" /></span>
|
|
|
|
|
</label>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 15:18:07 +08:00
|
|
|
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;
|
2026-03-31 18:52:37 +08:00
|
|
|
}) {
|
|
|
|
|
const fillPct = ((value - min) / (max - min)) * 100;
|
2026-05-13 15:18:07 +08:00
|
|
|
const emitRelease = (el: EventTarget | null) => {
|
|
|
|
|
if (!(el instanceof HTMLInputElement)) return;
|
|
|
|
|
onRelease?.(Number(el.value));
|
|
|
|
|
};
|
2026-03-31 18:52:37 +08:00
|
|
|
return (
|
|
|
|
|
<div className="relative flex items-center w-full mt-3">
|
|
|
|
|
<div className="absolute left-0 h-[4px] rounded-full pointer-events-none"
|
|
|
|
|
style={{ width: `${fillPct}%`, background: "#00FFF6", boxShadow: "0 0 6px rgba(0,255,246,0.5)" }} />
|
|
|
|
|
<input type="range" min={min} max={max} step={step} value={value}
|
|
|
|
|
className="cyan-slider relative z-10"
|
|
|
|
|
onChange={(e) => onChange(Number(e.target.value))}
|
2026-05-13 15:18:07 +08:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}} />
|
2026-03-31 18:52:37 +08:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function EffectsPage() {
|
|
|
|
|
const [, setLocation] = useLocation();
|
|
|
|
|
const { deviceState, updateSetting } = useDevice();
|
|
|
|
|
|
2026-05-13 15:18:07 +08:00
|
|
|
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],
|
|
|
|
|
);
|
|
|
|
|
|
2026-03-31 18:52:37 +08:00
|
|
|
const ds = deviceState;
|
2026-05-19 13:33:10 +08:00
|
|
|
const bypassOn = (ds?.dsp_enable ?? 0) === 0;
|
|
|
|
|
const audioOn = (ds?.audio_enable ?? 0) === 1;
|
|
|
|
|
const effectOn = audioOn && !bypassOn;
|
2026-03-31 18:52:37 +08:00
|
|
|
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;
|
2026-04-01 16:29:52 +08:00
|
|
|
const loudnessThreshold = ds?.loudness_threshold_gain ?? 0;
|
|
|
|
|
const loudnessBassGain = (ds?.loudness_bass_gain ?? 0) / 10;
|
|
|
|
|
const loudnessTrebleGain = (ds?.loudness_treble_gain ?? 0) / 10;
|
2026-05-26 18:46:41 +08:00
|
|
|
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]);
|
2026-03-31 18:52:37 +08:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
2026-04-01 16:29:52 +08:00
|
|
|
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);
|
|
|
|
|
|
2026-03-31 18:52:37 +08:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (!isDraggingBass.current) setLocalBass(colorBassGain);
|
|
|
|
|
if (!isDraggingMid.current) setLocalMid(colorMidGain);
|
|
|
|
|
if (!isDraggingTreble.current) setLocalTreble(colorTrebleGain);
|
2026-04-01 16:29:52 +08:00
|
|
|
if (!isDraggingLoudnessThreshold.current) setLocalLoudnessThreshold(loudnessThreshold);
|
|
|
|
|
if (!isDraggingLoudnessBass.current) setLocalLoudnessBass(loudnessBassGain);
|
|
|
|
|
if (!isDraggingLoudnessTreble.current) setLocalLoudnessTreble(loudnessTrebleGain);
|
|
|
|
|
}, [colorBassGain, colorMidGain, colorTrebleGain, loudnessThreshold, loudnessBassGain, loudnessTrebleGain]);
|
2026-03-31 18:52:37 +08:00
|
|
|
|
2026-05-13 15:18:07 +08:00
|
|
|
const [localWidth, setLocalWidth] = useState(() => Math.round(widthVal));
|
2026-03-31 18:52:37 +08:00
|
|
|
const isDragging = useRef(false);
|
|
|
|
|
|
2026-05-26 18:46:41 +08:00
|
|
|
const [delayChannel, setDelayChannel] = useState<DelayChannel>("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);
|
|
|
|
|
|
2026-03-31 18:52:37 +08:00
|
|
|
useEffect(() => {
|
2026-05-13 15:18:07 +08:00
|
|
|
if (!isDragging.current) setLocalWidth(Math.round(widthVal));
|
2026-03-31 18:52:37 +08:00
|
|
|
}, [widthVal]);
|
|
|
|
|
|
2026-05-26 18:46:41 +08:00
|
|
|
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]);
|
|
|
|
|
|
2026-03-31 18:52:37 +08:00
|
|
|
const goSelect = (title: string, options: string[], selected: number, key: string) => {
|
|
|
|
|
navigateToSelect(title, options, selected, "/effects", key);
|
|
|
|
|
setLocation("/select");
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-13 15:18:07 +08:00
|
|
|
const sceneIdx = clampPickIndex(sceneVal, sceneLabels.length);
|
|
|
|
|
const sceneDisplay = sceneLabels[sceneIdx] ?? "";
|
|
|
|
|
const crossIdx = clampPickIndex(crossfeedVal, crossfeedLabels.length);
|
|
|
|
|
const crossfeedDisplay = crossfeedLabels[crossIdx] ?? "";
|
2026-05-26 18:46:41 +08:00
|
|
|
const subRateIdx = clampPickIndex(subwooferRate, subwooferRateLabels.length);
|
|
|
|
|
const subRateDisplay = subwooferRateLabels[subRateIdx] ?? "";
|
|
|
|
|
const subMixIdx = clampPickIndex(subwooferMixType, subwooferOutputLabels.length);
|
|
|
|
|
const subMixDisplay = subwooferOutputLabels[subMixIdx] ?? "";
|
2026-05-13 15:18:07 +08:00
|
|
|
|
2026-03-31 18:52:37 +08:00
|
|
|
return (
|
|
|
|
|
<div className="min-h-screen bg-black">
|
|
|
|
|
<div style={{
|
|
|
|
|
filter: effectOn ? "none" : "grayscale(1) opacity(0.4)",
|
|
|
|
|
transition: "filter 0.3s ease-in-out"
|
|
|
|
|
}}>
|
|
|
|
|
<div className="page-header">
|
|
|
|
|
<button onClick={() => setLocation("/")} className="mr-4 text-white/60 active:text-white transition-colors">
|
|
|
|
|
<ChevronLeft size={24} />
|
|
|
|
|
</button>
|
2026-05-13 15:18:07 +08:00
|
|
|
<h1 className="flex-1 text-center text-[17px] font-semibold text-white">{effectText.pageTitle}</h1>
|
2026-05-19 13:33:10 +08:00
|
|
|
<IOSToggle
|
|
|
|
|
checked={effectOn}
|
|
|
|
|
onChange={(v) => {
|
|
|
|
|
if (v) {
|
|
|
|
|
void updateSetting(
|
|
|
|
|
bypassOn ? { audio_enable: 1, dsp_enable: 1 } : { audio_enable: 1 },
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
void updateSetting({ audio_enable: 0 });
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
/>
|
2026-03-31 18:52:37 +08:00
|
|
|
</div>
|
|
|
|
|
|
2026-05-19 13:33:10 +08:00
|
|
|
<FeatureGate
|
|
|
|
|
enabled={effectOn}
|
|
|
|
|
className="px-4 pb-32 pt-3 space-y-3"
|
|
|
|
|
labels={{
|
|
|
|
|
title: effectText.enableConfirmTitle ?? "音效未开启",
|
|
|
|
|
description: effectText.enableConfirmDesc ?? "当前 Effect 功能已关闭,是否开启?",
|
|
|
|
|
cancel: effectText.enableConfirmCancel ?? "取消",
|
|
|
|
|
confirm: effectText.enableConfirmOk ?? "开启",
|
|
|
|
|
}}
|
|
|
|
|
onEnable={() => {
|
|
|
|
|
const bypass = (ds?.dsp_enable ?? 0) === 0;
|
|
|
|
|
return updateSetting(bypass ? { audio_enable: 1, dsp_enable: 1 } : { audio_enable: 1 });
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-03-31 18:52:37 +08:00
|
|
|
{/* ── Style ── */}
|
|
|
|
|
<div className="ios-list-group p-4">
|
|
|
|
|
<div className="flex items-center justify-between mb-3">
|
2026-05-13 15:18:07 +08:00
|
|
|
<span className="text-[16px] font-medium text-white">{effectText.style.label}</span>
|
2026-03-31 18:52:37 +08:00
|
|
|
<IOSToggle checked={sceneOn} onChange={(v) => updateSetting({ effect_enable: v ? 1 : 0 })} />
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
className="flex items-center justify-between w-full active:opacity-70 transition-opacity"
|
2026-05-13 15:18:07 +08:00
|
|
|
onClick={() => goSelect(effectText.selectStyleTitle, sceneLabels, sceneVal, "effect_value")}>
|
|
|
|
|
<span className="text-[15px] text-white/55">{sceneDisplay}</span>
|
2026-03-31 18:52:37 +08:00
|
|
|
<ChevronRight size={16} className="ios-chevron" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* ── 声场宽度 ── */}
|
|
|
|
|
<div className="ios-list-group p-4">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div className="flex items-center gap-3">
|
2026-05-13 15:18:07 +08:00
|
|
|
<span className="text-[16px] font-medium text-white">{effectText.stereoWidth.label}</span>
|
|
|
|
|
<span className="text-[16px] font-bold" style={{ color: "#00FFF6" }}>{Math.round(localWidth)}</span>
|
2026-03-31 18:52:37 +08:00
|
|
|
</div>
|
|
|
|
|
<IOSToggle checked={widthOn} onChange={(v) => updateSetting({ width_enable: v ? 1 : 0 })} />
|
|
|
|
|
</div>
|
2026-05-13 15:18:07 +08:00
|
|
|
<CyanSlider
|
|
|
|
|
value={localWidth}
|
|
|
|
|
min={0}
|
|
|
|
|
max={100}
|
|
|
|
|
step={1}
|
2026-03-31 18:52:37 +08:00
|
|
|
onChange={(v) => {
|
|
|
|
|
isDragging.current = true;
|
2026-05-13 15:18:07 +08:00
|
|
|
setLocalWidth(Math.round(v));
|
|
|
|
|
}}
|
|
|
|
|
onRelease={(v) => {
|
|
|
|
|
isDragging.current = false;
|
|
|
|
|
const w = Math.round(v);
|
|
|
|
|
setLocalWidth(w);
|
|
|
|
|
updateSetting({ width_value: w });
|
2026-03-31 18:52:37 +08:00
|
|
|
}} />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* ── 交叉反馈 ── */}
|
|
|
|
|
<div className="ios-list-group p-4">
|
|
|
|
|
<div className="flex items-center justify-between mb-3">
|
2026-05-13 15:18:07 +08:00
|
|
|
<span className="text-[16px] font-medium text-white">{effectText.crossfeed.label}</span>
|
2026-03-31 18:52:37 +08:00
|
|
|
<IOSToggle checked={crossfeedOn} onChange={(v) => updateSetting({ crossfeed_enable: v ? 1 : 0 })} />
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
className="flex items-center justify-between w-full active:opacity-70 transition-opacity"
|
2026-05-13 15:18:07 +08:00
|
|
|
onClick={() => goSelect(effectText.selectCrossfeedTitle, crossfeedLabels, crossfeedVal, "crossfeed_value")}>
|
|
|
|
|
<span className="text-[14px] text-white/45">{crossfeedDisplay}</span>
|
2026-03-31 18:52:37 +08:00
|
|
|
<ChevronRight size={16} className="ios-chevron" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* ── 音调 ── */}
|
|
|
|
|
<div className="ios-list-group p-4">
|
|
|
|
|
<div className="flex items-center justify-between">
|
2026-05-13 15:18:07 +08:00
|
|
|
<span className="text-[16px] font-medium text-white">{effectText.tone}</span>
|
2026-03-31 18:52:37 +08:00
|
|
|
<IOSToggle checked={colorOn} onChange={(v) => updateSetting({ color_enable: v ? 1 : 0 })} />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{colorOn && (
|
|
|
|
|
<div className="space-y-4 mt-4">
|
|
|
|
|
{/* 低音 */}
|
|
|
|
|
<div>
|
|
|
|
|
<div className="flex items-center justify-between mb-2">
|
2026-05-13 15:18:07 +08:00
|
|
|
<span className="text-[14px] text-white/60">{effectText.lowBass}</span>
|
2026-03-31 18:52:37 +08:00
|
|
|
<span className="text-[14px] font-bold" style={{ color: "#00FFF6" }}>{localBass.toFixed(1)}</span>
|
|
|
|
|
</div>
|
2026-05-13 15:18:07 +08:00
|
|
|
<CyanSlider
|
|
|
|
|
value={localBass}
|
|
|
|
|
min={-10}
|
|
|
|
|
max={10}
|
2026-03-31 18:52:37 +08:00
|
|
|
onChange={(v) => {
|
|
|
|
|
isDraggingBass.current = true;
|
|
|
|
|
setLocalBass(v);
|
|
|
|
|
}}
|
2026-05-13 15:18:07 +08:00
|
|
|
onRelease={(v) => {
|
2026-03-31 18:52:37 +08:00
|
|
|
isDraggingBass.current = false;
|
2026-05-13 15:18:07 +08:00
|
|
|
updateSetting({ color_bass_gain: Math.round(v * 10) });
|
2026-03-31 18:52:37 +08:00
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 中音 */}
|
|
|
|
|
<div>
|
|
|
|
|
<div className="flex items-center justify-between mb-2">
|
2026-05-13 15:18:07 +08:00
|
|
|
<span className="text-[14px] text-white/60">{effectText.enterBass}</span>
|
2026-03-31 18:52:37 +08:00
|
|
|
<span className="text-[14px] font-bold" style={{ color: "#00FFF6" }}>{localMid.toFixed(1)}</span>
|
|
|
|
|
</div>
|
2026-05-13 15:18:07 +08:00
|
|
|
<CyanSlider
|
|
|
|
|
value={localMid}
|
|
|
|
|
min={-10}
|
|
|
|
|
max={10}
|
2026-03-31 18:52:37 +08:00
|
|
|
onChange={(v) => {
|
|
|
|
|
isDraggingMid.current = true;
|
|
|
|
|
setLocalMid(v);
|
|
|
|
|
}}
|
2026-05-13 15:18:07 +08:00
|
|
|
onRelease={(v) => {
|
2026-03-31 18:52:37 +08:00
|
|
|
isDraggingMid.current = false;
|
2026-05-13 15:18:07 +08:00
|
|
|
updateSetting({ color_mid_gain: Math.round(v * 10) });
|
2026-03-31 18:52:37 +08:00
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 高音 */}
|
|
|
|
|
<div>
|
|
|
|
|
<div className="flex items-center justify-between mb-2">
|
2026-05-13 15:18:07 +08:00
|
|
|
<span className="text-[14px] text-white/60">{effectText.highBass}</span>
|
2026-03-31 18:52:37 +08:00
|
|
|
<span className="text-[14px] font-bold" style={{ color: "#00FFF6" }}>{localTreble.toFixed(1)}</span>
|
|
|
|
|
</div>
|
2026-05-13 15:18:07 +08:00
|
|
|
<CyanSlider
|
|
|
|
|
value={localTreble}
|
|
|
|
|
min={-10}
|
|
|
|
|
max={10}
|
2026-03-31 18:52:37 +08:00
|
|
|
onChange={(v) => {
|
|
|
|
|
isDraggingTreble.current = true;
|
|
|
|
|
setLocalTreble(v);
|
|
|
|
|
}}
|
2026-05-13 15:18:07 +08:00
|
|
|
onRelease={(v) => {
|
2026-03-31 18:52:37 +08:00
|
|
|
isDraggingTreble.current = false;
|
2026-05-13 15:18:07 +08:00
|
|
|
updateSetting({ color_treble_gain: Math.round(v * 10) });
|
2026-03-31 18:52:37 +08:00
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* ── 响度 ── */}
|
|
|
|
|
<div className="ios-list-group p-4">
|
|
|
|
|
<div className="flex items-center justify-between">
|
2026-05-13 15:18:07 +08:00
|
|
|
<span className="text-[16px] font-medium text-white">{effectText.loudness}</span>
|
2026-03-31 18:52:37 +08:00
|
|
|
<IOSToggle checked={loudnessOn} onChange={(v) => updateSetting({ loudness_enable: v ? 1 : 0 })} />
|
|
|
|
|
</div>
|
2026-04-01 16:29:52 +08:00
|
|
|
|
|
|
|
|
{loudnessOn && (
|
|
|
|
|
<div className="space-y-4 mt-4">
|
|
|
|
|
{/* 阈值 */}
|
|
|
|
|
<div>
|
|
|
|
|
<div className="flex items-center justify-between mb-2">
|
2026-05-13 15:18:07 +08:00
|
|
|
<span className="text-[14px] text-white/60">{effectText.threshold}</span>
|
2026-04-01 16:29:52 +08:00
|
|
|
<span className="text-[14px] font-bold" style={{ color: "#00FFF6" }}>{localLoudnessThreshold}</span>
|
|
|
|
|
</div>
|
2026-05-13 15:18:07 +08:00
|
|
|
<CyanSlider
|
|
|
|
|
value={localLoudnessThreshold}
|
|
|
|
|
min={-30}
|
|
|
|
|
max={0}
|
2026-04-01 16:29:52 +08:00
|
|
|
step={1}
|
|
|
|
|
onChange={(v) => {
|
|
|
|
|
isDraggingLoudnessThreshold.current = true;
|
|
|
|
|
setLocalLoudnessThreshold(v);
|
|
|
|
|
}}
|
2026-05-13 15:18:07 +08:00
|
|
|
onRelease={(v) => {
|
2026-04-01 16:29:52 +08:00
|
|
|
isDraggingLoudnessThreshold.current = false;
|
2026-05-13 15:18:07 +08:00
|
|
|
updateSetting({ loudness_threshold_gain: Math.round(v) });
|
2026-04-01 16:29:52 +08:00
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-05-13 15:18:07 +08:00
|
|
|
{/* Loudness bass */}
|
2026-04-01 16:29:52 +08:00
|
|
|
<div>
|
|
|
|
|
<div className="flex items-center justify-between mb-2">
|
2026-05-13 15:18:07 +08:00
|
|
|
<span className="text-[14px] text-white/60">{effectText.lowBass}</span>
|
2026-04-01 16:29:52 +08:00
|
|
|
<span className="text-[14px] font-bold" style={{ color: "#00FFF6" }}>{localLoudnessBass.toFixed(1)}</span>
|
|
|
|
|
</div>
|
2026-05-13 15:18:07 +08:00
|
|
|
<CyanSlider
|
|
|
|
|
value={localLoudnessBass}
|
|
|
|
|
min={0}
|
|
|
|
|
max={10}
|
2026-04-01 16:29:52 +08:00
|
|
|
step={0.1}
|
|
|
|
|
onChange={(v) => {
|
|
|
|
|
isDraggingLoudnessBass.current = true;
|
|
|
|
|
setLocalLoudnessBass(v);
|
|
|
|
|
}}
|
2026-05-13 15:18:07 +08:00
|
|
|
onRelease={(v) => {
|
2026-04-01 16:29:52 +08:00
|
|
|
isDraggingLoudnessBass.current = false;
|
2026-05-13 15:18:07 +08:00
|
|
|
updateSetting({ loudness_bass_gain: Math.round(v * 10) });
|
2026-04-01 16:29:52 +08:00
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-05-13 15:18:07 +08:00
|
|
|
{/* Loudness treble */}
|
2026-04-01 16:29:52 +08:00
|
|
|
<div>
|
|
|
|
|
<div className="flex items-center justify-between mb-2">
|
2026-05-13 15:18:07 +08:00
|
|
|
<span className="text-[14px] text-white/60">{effectText.highBass}</span>
|
2026-04-01 16:29:52 +08:00
|
|
|
<span className="text-[14px] font-bold" style={{ color: "#00FFF6" }}>{localLoudnessTreble.toFixed(1)}</span>
|
|
|
|
|
</div>
|
2026-05-13 15:18:07 +08:00
|
|
|
<CyanSlider
|
|
|
|
|
value={localLoudnessTreble}
|
|
|
|
|
min={0}
|
|
|
|
|
max={10}
|
2026-04-01 16:29:52 +08:00
|
|
|
step={0.1}
|
|
|
|
|
onChange={(v) => {
|
|
|
|
|
isDraggingLoudnessTreble.current = true;
|
|
|
|
|
setLocalLoudnessTreble(v);
|
|
|
|
|
}}
|
2026-05-13 15:18:07 +08:00
|
|
|
onRelease={(v) => {
|
2026-04-01 16:29:52 +08:00
|
|
|
isDraggingLoudnessTreble.current = false;
|
2026-05-13 15:18:07 +08:00
|
|
|
updateSetting({ loudness_treble_gain: Math.round(v * 10) });
|
2026-04-01 16:29:52 +08:00
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-03-31 18:52:37 +08:00
|
|
|
</div>
|
2026-05-26 18:46:41 +08:00
|
|
|
|
|
|
|
|
{/* ── 低音炮输出 ── */}
|
|
|
|
|
<div className="ios-list-group p-4">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<span className="text-[16px] font-medium text-white">{subwooferText?.label ?? "低音炮输出"}</span>
|
|
|
|
|
<IOSToggle checked={subwooferOn} onChange={(v) => updateSetting({ subwoofer_enable: v ? 1 : 0 })} />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{subwooferOn && (
|
|
|
|
|
<div className="space-y-4 mt-4">
|
|
|
|
|
<div>
|
|
|
|
|
<SubwooferLpfChart
|
|
|
|
|
cutoffHz={localSubFreq}
|
|
|
|
|
rateIndex={subwooferRate}
|
|
|
|
|
className="w-full h-[min(42vw,200px)] min-h-[140px] md:h-[260px] lg:h-[300px] mb-3 rounded-lg bg-black/30"
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex items-center justify-between mb-2">
|
|
|
|
|
<span className="text-[14px] font-medium text-white/80">
|
|
|
|
|
{subwooferText?.cutOffFrequency ?? "截止频率"}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="text-[14px] font-bold" style={{ color: "#00FFF6" }}>{localSubFreq}Hz</span>
|
|
|
|
|
</div>
|
|
|
|
|
<CyanSlider
|
|
|
|
|
value={localSubFreq}
|
|
|
|
|
min={40}
|
|
|
|
|
max={300}
|
|
|
|
|
step={1}
|
|
|
|
|
onChange={(v) => {
|
|
|
|
|
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 && (
|
|
|
|
|
<div className="flex flex-wrap justify-center gap-2 mt-3 w-full">
|
|
|
|
|
<FullRangeToggleButton
|
|
|
|
|
label={subwooferText?.subFullRange ?? "低音炮全频"}
|
|
|
|
|
active={subwooferLpfOn}
|
|
|
|
|
onClick={() =>
|
|
|
|
|
updateSetting({ subwoofer_lpf_enable: subwooferLpfOn ? 0 : 1 })
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
<FullRangeToggleButton
|
|
|
|
|
label={subwooferText?.mainFullRange ?? "主音箱全频"}
|
|
|
|
|
active={subwooferHpfOn}
|
|
|
|
|
onClick={() =>
|
|
|
|
|
updateSetting({ subwoofer_hpf_enable: subwooferHpfOn ? 0 : 1 })
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<div className="flex items-center justify-between mb-2">
|
|
|
|
|
<span className="text-[14px] font-medium text-white/80">
|
|
|
|
|
{subwooferText?.attenuationSlope ?? "衰减斜率"}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
className="flex items-center justify-between w-full active:opacity-70 transition-opacity"
|
|
|
|
|
onClick={() =>
|
|
|
|
|
goSelect(
|
|
|
|
|
subwooferText?.selectAttenuationTitle ?? "选择衰减斜率",
|
|
|
|
|
subwooferRateLabels,
|
|
|
|
|
subwooferRate,
|
|
|
|
|
"subwoofer_rate",
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<span className="text-[14px] text-white/45">{subRateDisplay}</span>
|
|
|
|
|
<ChevronRight size={16} className="ios-chevron" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<div className="flex items-center justify-between mb-2">
|
|
|
|
|
<span className="text-[14px] font-medium text-white/80">{subwooferText?.gain ?? "增益"}</span>
|
|
|
|
|
<span className="text-[14px] font-bold" style={{ color: "#00FFF6" }}>
|
|
|
|
|
{localSubGain >= 0 ? `+${localSubGain}` : localSubGain} dB
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<CyanSlider
|
|
|
|
|
value={localSubGain}
|
|
|
|
|
min={-15}
|
|
|
|
|
max={15}
|
|
|
|
|
step={1}
|
|
|
|
|
onChange={(v) => {
|
|
|
|
|
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 });
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<div className="flex items-center justify-between mb-2">
|
|
|
|
|
<span className="text-[14px] font-medium text-white/80">
|
|
|
|
|
{subwooferText?.output?.label ?? "输出方式"}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
className="flex items-center justify-between w-full active:opacity-70 transition-opacity"
|
|
|
|
|
onClick={() =>
|
|
|
|
|
goSelect(
|
|
|
|
|
subwooferText?.selectOutputTitle ?? "选择输出方式",
|
|
|
|
|
subwooferOutputLabels,
|
|
|
|
|
subwooferMixType,
|
|
|
|
|
"subwoofer_mix_type",
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<span className="text-[14px] text-white/45">{subMixDisplay}</span>
|
|
|
|
|
<ChevronRight size={16} className="ios-chevron" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="border-t border-white/[0.06] pt-4 space-y-4">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<span className="text-[14px] font-medium text-white/80">
|
|
|
|
|
{subwooferDelayText?.label ?? "延时"}
|
|
|
|
|
</span>
|
|
|
|
|
<DelayChannelToggle
|
|
|
|
|
channel={delayChannel}
|
|
|
|
|
onChange={setDelayChannel}
|
|
|
|
|
leftLabel={subwooferDelayText?.leftChannel ?? "左声道"}
|
|
|
|
|
rightLabel={subwooferDelayText?.rightChannel ?? "右声道"}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<div className="flex items-center justify-between mb-2">
|
|
|
|
|
<span className="text-[14px] text-white/60">
|
|
|
|
|
{subwooferDelayText?.mainSpeaker ?? "主机箱"}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="text-[14px] font-bold" style={{ color: "#00FFF6" }}>
|
|
|
|
|
{formatSubwooferDelayValue(localSubDelayMain)}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<CyanSlider
|
|
|
|
|
value={localSubDelayMain}
|
|
|
|
|
min={0}
|
|
|
|
|
max={SUBWOOFER_DELAY_MAX}
|
|
|
|
|
step={1}
|
|
|
|
|
onChange={(v) => {
|
|
|
|
|
isDraggingSubDelayMain.current = true;
|
|
|
|
|
setLocalSubDelayMain(clampSubwooferDelayValue(v));
|
|
|
|
|
}}
|
|
|
|
|
onRelease={(v) => {
|
|
|
|
|
isDraggingSubDelayMain.current = false;
|
|
|
|
|
const delayVal = clampSubwooferDelayValue(v);
|
|
|
|
|
setLocalSubDelayMain(delayVal);
|
|
|
|
|
updateSetting({ [subwooferDelayMainField]: delayVal });
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<div className="flex items-center justify-between mb-2">
|
|
|
|
|
<span className="text-[14px] text-white/60">
|
|
|
|
|
{subwooferDelayText?.subwoofer ?? "低音炮"}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="text-[14px] font-bold" style={{ color: "#00FFF6" }}>
|
|
|
|
|
{formatSubwooferDelayValue(localSubDelaySub)}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<CyanSlider
|
|
|
|
|
value={localSubDelaySub}
|
|
|
|
|
min={0}
|
|
|
|
|
max={SUBWOOFER_DELAY_MAX}
|
|
|
|
|
step={1}
|
|
|
|
|
onChange={(v) => {
|
|
|
|
|
isDraggingSubDelaySub.current = true;
|
|
|
|
|
setLocalSubDelaySub(clampSubwooferDelayValue(v));
|
|
|
|
|
}}
|
|
|
|
|
onRelease={(v) => {
|
|
|
|
|
isDraggingSubDelaySub.current = false;
|
|
|
|
|
const delayVal = clampSubwooferDelayValue(v);
|
|
|
|
|
setLocalSubDelaySub(delayVal);
|
|
|
|
|
updateSetting({ [subwooferDelaySubField]: delayVal });
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-05-19 13:33:10 +08:00
|
|
|
</FeatureGate>
|
2026-03-31 18:52:37 +08:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<BottomNav />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|