This commit is contained in:
yangy
2026-05-26 18:46:41 +08:00
parent 0c9e42b91c
commit 47ea7d7b56
13 changed files with 1370 additions and 121 deletions
+366
View File
@@ -11,8 +11,10 @@ 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";
@@ -24,6 +26,115 @@ function clampPickIndex(i: number, len: number) {
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 (
<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>
);
}
function IOSToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<label className="ios-toggle" onClick={(e) => e.stopPropagation()}>
@@ -103,6 +214,33 @@ export default function EffectsPage() {
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);
@@ -130,10 +268,37 @@ export default function EffectsPage() {
const [localWidth, setLocalWidth] = useState(() => Math.round(widthVal));
const isDragging = useRef(false);
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);
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");
@@ -143,6 +308,10 @@ export default function EffectsPage() {
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 (
<div className="min-h-screen bg-black">
@@ -389,6 +558,203 @@ export default function EffectsPage() {
</div>
)}
</div>
{/* ── 低音炮输出 ── */}
<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>
</FeatureGate>
</div>