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
+96 -25
View File
@@ -2,9 +2,8 @@
AUDIO PAGE — Audio Settings
Design: Reference luxsin_x8_音频设置.png
Sections:
- 平衡与灵敏度: 左右平衡 slider, VU灵敏度 slider
- 平衡与灵敏度: 左右平衡, VU灵敏度, 模拟输入增益 sliders
- 输出特性: 滤波特性, 耳机增益, 音量幅度
- IIS设置: IIS静音电平, IIS模式
- 系统与接口: 开机音量, XLR极性
All list rows → navigate to /select page
============================================================ */
@@ -70,7 +69,31 @@ function SectionHeader({ title }: { title: string }) {
);
}
function ListRow({ label, value, onClick }: { label: string; value: string; onClick?: () => void }) {
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>
);
}
function ListRow({ label, value, onClick, toggle, checked, onToggle }: {
label: string;
value?: string;
onClick?: () => void;
toggle?: boolean;
checked?: boolean;
onToggle?: (v: boolean) => void;
}) {
if (toggle) {
return (
<div className="ios-list-row">
<span className="flex-1 text-[16px] text-white">{label}</span>
<IOSToggle checked={!!checked} onChange={onToggle ?? (() => {})} />
</div>
);
}
return (
<button className="ios-list-row w-full text-left active:bg-white/5 transition-colors" onClick={onClick}>
<span className="flex-1 text-[16px] text-white">{label}</span>
@@ -84,17 +107,18 @@ type LocaleDac = {
dac?: {
sectionBalanceSensitivity?: string;
sectionOutput?: string;
sectionIisSettings?: string;
sectionSystemInterface?: string;
balance?: string;
sensitivity?: string;
analogInputGain?: string;
autoImpedanceDetection?: string;
filters?: { label?: string; options?: Array<{ index: number; label: string }> };
dacGain?: { label?: string; options?: Array<{ index: number; label: string }> };
dacVolumeDirect?: { label?: string; options?: Array<{ index: number; label: string }> };
dacArc?: { label?: string; options?: Array<{ index: number; label: string }> };
volumeStep?: string;
bootVolume?: string | { label?: string; options?: Array<{ index: number; label: string }> };
xlr?: { label?: string; options?: Array<{ index: number; label: string }> };
mutePolar?: { label?: string; options?: Array<{ index: number; label: string }> };
IISMode?: { label?: string; options?: Array<{ index: number; label: string }> };
};
home?: { audioSet?: string };
};
@@ -166,15 +190,22 @@ export default function AudioPage() {
const balanceVal = ds?.balance !== undefined ? ds.balance / 10 : 0;
// vuSensor 从接口获取的是放大 2 倍的值,需要除以 2 显示(例如:20 → +10dB)
const vuSensVal = vuSensor !== undefined ? vuSensor / 2 : 0;
// analogGain 从接口获取的是放大 2 倍的值,需要除以 2 显示(例如:-6 → -3dB)
const analogGainVal = ds?.analogGain !== undefined ? ds.analogGain / 2 : 0;
const bootSoundFromApi = readBootSoundFromState(ds);
const [localBootSound, setLocalBootSound] = useState(bootSoundFromApi);
const [localBalance, setLocalBalance] = useState(balanceVal);
const [localVuSens, setLocalVuSens] = useState(0);
const [localAnalogGain, setLocalAnalogGain] = useState(analogGainVal);
const isBalanceDragging = useRef(false);
const isVuDragging = useRef(false);
const isAnalogGainDragging = useRef(false);
useEffect(() => { if (!isBalanceDragging.current) setLocalBalance(balanceVal); }, [balanceVal]);
useEffect(() => { if (!isVuDragging.current) setLocalVuSens(vuSensVal); }, [vuSensVal]);
useEffect(() => {
if (!isAnalogGainDragging.current) setLocalAnalogGain(analogGainVal);
}, [analogGainVal]);
useEffect(() => {
setLocalBootSound(readBootSoundFromState(ds));
}, [ds?.bootSound]);
@@ -192,11 +223,12 @@ export default function AudioPage() {
}, [location, isConnected, api, refresh]);
const gainIdx = ds?.dacGain ?? 0;
const autoImpedanceOn = (ds?.dacImpedance ?? 0) === 1;
const dacVolumeDirectIdx = ds?.dacVolumeDirect ?? 0;
const dacArcIdx = ds?.dacArc ?? 0;
const stepIdx = ds?.soundStep ?? 1;
const filterIdx = ds?.pcm ?? 0;
const xlrIdx = ds?.xlr ?? 0;
const mutePolarIdx = (ds as ({ hdmimutepolar?: number } | null))?.hdmimutepolar ?? 0;
const iisModeIdx = (ds as ({ hdmiType?: number } | null))?.hdmiType ?? 0;
const bootSoundVal = normalizeBootSound(localBootSound);
const firmwareVersion = parseFirmwareVersion(ds?.version);
const bootSoundMaxIdx = getBootSoundMaxIndex(firmwareVersion);
@@ -205,9 +237,9 @@ export default function AudioPage() {
const FILTER_OPTIONS = optionLabels(dacText.filters?.options, ["快速滚降", "慢速滚降", "短延迟快速滚降", "短延迟慢速滚降", "去重强调", "非过采样(NOS)"]);
const GAIN_OPTIONS = optionLabels(dacText.dacGain?.options, ["低", "中", "高"]);
const DAC_VOLUME_DIRECT_OPTIONS = optionLabels(dacText.dacVolumeDirect?.options, ["关闭", "0dB", "-12dB"]);
const DAC_ARC_OPTIONS = optionLabels(dacText.dacArc?.options, ["ARC", "EARC"]);
const XLR_OPTIONS = optionLabels(dacText.xlr?.options, ["正常", "反转"]);
const MUTE_POLAR_OPTIONS = optionLabels(dacText.mutePolar?.options, ["低电平", "高电平"]);
const IIS_MODE_OPTIONS = optionLabels(dacText.IISMode?.options, ["模式1", "模式2", "模式3", "模式4", "模式5", "模式6", "模式7", "模式8"]);
const goSelect = (title: string, options: string[], selected: number, key: string) => {
navigateToSelect(title, options, selected, "/audio", key);
@@ -277,6 +309,30 @@ export default function AudioPage() {
}}
/>
</div>
<div className="border-t border-white/[0.06]" />
<div>
<div className="flex items-center justify-between">
<span className="text-[16px] text-white">{dacText.analogInputGain ?? "模拟输入增益"}</span>
<span className="text-[16px] font-semibold" style={{ color: "#00FFF6" }}>
{localAnalogGain >= 0 ? `+${localAnalogGain.toFixed(1)}` : localAnalogGain.toFixed(1)} dB
</span>
</div>
<CyanSlider
value={localAnalogGain}
min={-12}
max={12}
step={0.5}
onChange={(v) => {
isAnalogGainDragging.current = true;
setLocalAnalogGain(v);
}}
onRelease={(v) => {
isAnalogGainDragging.current = false;
setLocalAnalogGain(v);
updateSetting({ analogGain: Math.round(v * 2) });
}}
/>
</div>
</div>
{/* ── 输出特性 ── */}
@@ -292,6 +348,36 @@ export default function AudioPage() {
value={GAIN_OPTIONS[gainIdx] ?? GAIN_OPTIONS[0] ?? "—"}
onClick={() => goSelect(dacText.dacGain?.label ?? "选择耳机增益", GAIN_OPTIONS, gainIdx, "dacGain")}
/>
<ListRow
label={dacText.autoImpedanceDetection ?? "自动检测耳机阻抗"}
toggle
checked={autoImpedanceOn}
onToggle={(v) => updateSetting({ dacImpedance: v ? 1 : 0 })}
/>
<ListRow
label={dacText.dacVolumeDirect?.label ?? "前级输出音量直通模式"}
value={DAC_VOLUME_DIRECT_OPTIONS[dacVolumeDirectIdx] ?? DAC_VOLUME_DIRECT_OPTIONS[0] ?? "—"}
onClick={() =>
goSelect(
dacText.dacVolumeDirect?.label ?? "前级输出音量直通模式",
DAC_VOLUME_DIRECT_OPTIONS,
dacVolumeDirectIdx,
"dacVolumeDirect",
)
}
/>
<ListRow
label={dacText.dacArc?.label ?? "ARC模式"}
value={DAC_ARC_OPTIONS[dacArcIdx] ?? DAC_ARC_OPTIONS[0] ?? "—"}
onClick={() =>
goSelect(
dacText.dacArc?.label ?? "ARC模式",
DAC_ARC_OPTIONS,
dacArcIdx,
"dacArc",
)
}
/>
<ListRow
label={dacText.volumeStep ?? "音量幅度"}
value={STEP_OPTIONS[stepIdx] ?? STEP_OPTIONS[0]}
@@ -299,21 +385,6 @@ export default function AudioPage() {
/>
</div>
{/* ── IIS 设置 ── */}
<SectionHeader title={dacText.sectionIisSettings ?? "IIS设置"} />
<div className="ios-list-group">
<ListRow
label={dacText.mutePolar?.label ?? "IIS 静音电平"}
value={MUTE_POLAR_OPTIONS[mutePolarIdx] ?? MUTE_POLAR_OPTIONS[0] ?? "—"}
onClick={() => goSelect(dacText.mutePolar?.label ?? "选择 IIS 静音电平", MUTE_POLAR_OPTIONS, mutePolarIdx, "mutePolar")}
/>
<ListRow
label={dacText.IISMode?.label ?? "IIS 模式"}
value={IIS_MODE_OPTIONS[iisModeIdx] ?? IIS_MODE_OPTIONS[0] ?? "—"}
onClick={() => goSelect(dacText.IISMode?.label ?? "选择 IIS 模式", IIS_MODE_OPTIONS, iisModeIdx, "IISMode")}
/>
</div>
{/* ── 系统与接口 ── */}
<SectionHeader title={dacText.sectionSystemInterface ?? "系统与接口"} />
<div className="ios-list-group">
+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>
+100 -22
View File
@@ -22,7 +22,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { INPUT_LABELS, OUTPUT_LABELS } from "@/lib/luxsinApi";
import { INPUT_LABELS, isHomeVolumeLockedByPassthrough, OUTPUT_LABELS } from "@/lib/luxsinApi";
import { cn } from "@/lib/utils";
import {
ChevronRight,
@@ -378,6 +378,38 @@ export default function Home() {
};
}, []);
const volumePassthroughActive = isHomeVolumeLockedByPassthrough(
deviceState?.dacVolumeDirect,
deviceState?.output,
);
const volumePassthroughModeLabel =
deviceState?.dacVolumeDirect === 1
? (homeText.volumePassthroughMode0dB ?? "0dB")
: deviceState?.dacVolumeDirect === 2
? (homeText.volumePassthroughMode12dB ?? "-12dB")
: "";
const notifyVolumePassthroughBlocked = useCallback(() => {
const template =
homeText.volumePassthroughBlocked ??
"当前为音量直通模式({mode}),无法调节耳机音量。请在音频设置中关闭直通或改为「关闭」。";
toast.info(template.replace("{mode}", volumePassthroughModeLabel));
}, [homeText, volumePassthroughModeLabel]);
const blockVolumeAdjust = useCallback(() => {
if (!volumePassthroughActive) return false;
notifyVolumePassthroughBlocked();
return true;
}, [volumePassthroughActive, notifyVolumePassthroughBlocked]);
const applyVolume = useCallback(
(v: number) => {
if (blockVolumeAdjust()) return;
setVolume(v);
},
[blockVolumeAdjust, setVolume],
);
if (!isConnected) return <ConnectionPlaceholder />;
const ds = deviceState!;
@@ -413,6 +445,7 @@ export default function Home() {
};
const beginKnobDrag = (e: React.PointerEvent<HTMLDivElement>) => {
if (blockVolumeAdjust()) return;
const el = e.currentTarget;
const rect = el.getBoundingClientRect();
knobDraggingRef.current = true;
@@ -442,7 +475,7 @@ export default function Home() {
knobDraggingRef.current = false;
knobActivePointerIdRef.current = null;
isDragging.current = false;
setVolume(knobLastVolRef.current);
applyVolume(knobLastVolRef.current);
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {
@@ -549,7 +582,7 @@ export default function Home() {
</div>
</div>
{/* Right knob */}
<div className="pr-4">
<div className={cn("pr-4", volumePassthroughActive && "opacity-40 pointer-events-none")}>
<div
className="w-12 h-12 rounded-full flex items-center justify-center touch-none select-none"
style={{
@@ -564,6 +597,7 @@ export default function Home() {
aria-valuemin={0}
aria-valuemax={200}
aria-valuenow={localVol}
aria-disabled={volumePassthroughActive}
onPointerDown={beginKnobDrag}
onPointerMove={moveKnobDrag}
onPointerUp={endKnobDrag}
@@ -583,12 +617,36 @@ export default function Home() {
</div>
{/* ── Volume slider ── */}
<div className="px-4 mb-5">
<div className="flex items-center gap-3">
<div
className={cn("px-4 mb-5", volumePassthroughActive && "opacity-45")}
onPointerDownCapture={(e) => {
if (volumePassthroughActive) {
e.preventDefault();
e.stopPropagation();
notifyVolumePassthroughBlocked();
}
}}
>
{volumePassthroughActive && (
<p className="text-center text-[13px] mb-3 px-2" style={{ color: "rgba(0,255,246,0.85)" }}>
{homeText.volumePassthroughHint ?? "当前为音量直通模式,音量不可调节"}
{volumePassthroughModeLabel ? ` (${volumePassthroughModeLabel})` : ""}
</p>
)}
<div
className={cn(
"flex items-center gap-3",
volumePassthroughActive && "pointer-events-none",
)}
>
<button
type="button"
onClick={() => setVolumeConfirm("min")}
className="text-white/50 active:text-white transition-colors flex-shrink-0"
disabled={volumePassthroughActive}
onClick={() => {
if (blockVolumeAdjust()) return;
setVolumeConfirm("min");
}}
className="text-white/50 active:text-white transition-colors flex-shrink-0 disabled:opacity-40"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-6 h-6">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
@@ -606,25 +664,28 @@ export default function Home() {
}} />
<input
type="range" min={0} max={200} value={localVol}
className="cyan-slider relative z-10"
disabled={volumePassthroughActive}
className="cyan-slider relative z-10 disabled:opacity-50"
onChange={(e) => {
if (volumePassthroughActive) return;
const v = Number(e.target.value);
setLocalVol(v);
}}
onPointerDown={() => {
if (volumePassthroughActive) return;
isDragging.current = true;
}}
onPointerUp={(e) => {
isDragging.current = false;
setVolume(Number(e.currentTarget.value));
applyVolume(Number(e.currentTarget.value));
}}
onPointerCancel={(e) => {
isDragging.current = false;
setVolume(Number(e.currentTarget.value));
applyVolume(Number(e.currentTarget.value));
}}
onBlur={(e) => {
isDragging.current = false;
setVolume(Number(e.currentTarget.value));
applyVolume(Number(e.currentTarget.value));
}}
onKeyUp={(e) => {
const k = e.key;
@@ -638,7 +699,7 @@ export default function Home() {
k === "PageUp" ||
k === "PageDown"
) {
setVolume(Number(e.currentTarget.value));
applyVolume(Number(e.currentTarget.value));
}
}}
/>
@@ -646,8 +707,12 @@ export default function Home() {
<button
type="button"
onClick={() => setVolumeConfirm("max")}
className="text-white/50 active:text-white transition-colors flex-shrink-0"
disabled={volumePassthroughActive}
onClick={() => {
if (blockVolumeAdjust()) return;
setVolumeConfirm("max");
}}
className="text-white/50 active:text-white transition-colors flex-shrink-0 disabled:opacity-40"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-6 h-6">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
@@ -657,11 +722,16 @@ export default function Home() {
</button>
</div>
{/* dB value */}
<div className="mt-[6px] flex items-center justify-center gap-3">
<div
className={cn(
"mt-[6px] flex items-center justify-center gap-3",
volumePassthroughActive && "pointer-events-none",
)}
>
<button
type="button"
aria-label="Decrease volume"
disabled={localVol <= 0}
disabled={volumePassthroughActive || localVol <= 0}
className="w-10 h-10 rounded-full flex items-center justify-center transition-all active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed"
style={{
background: "rgba(44,44,46,0.6)",
@@ -669,22 +739,29 @@ export default function Home() {
color: "rgba(255,255,255,0.55)",
}}
onClick={() => {
if (blockVolumeAdjust()) return;
const next = Math.max(0, Math.min(200, localVol - fineVolumeStep));
setLocalVol(next);
setVolume(next);
applyVolume(next);
}}
>
<span className="text-[20px] leading-none">-</span>
</button>
<span className="text-[22px] font-bold tracking-tight" style={{ color: "#00FFF6" }}>
<span
className={cn(
"text-[22px] font-bold tracking-tight",
volumePassthroughActive ? "text-white/35" : "",
)}
style={volumePassthroughActive ? undefined : { color: "#00FFF6" }}
>
{volToDB(localVol)}
</span>
<button
type="button"
aria-label="Increase volume"
disabled={localVol >= 200}
disabled={volumePassthroughActive || localVol >= 200}
className="w-10 h-10 rounded-full flex items-center justify-center transition-all active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed"
style={{
background: "rgba(44,44,46,0.6)",
@@ -692,9 +769,10 @@ export default function Home() {
color: "rgba(255,255,255,0.55)",
}}
onClick={() => {
if (blockVolumeAdjust()) return;
const next = Math.max(0, Math.min(200, localVol + fineVolumeStep));
setLocalVol(next);
setVolume(next);
applyVolume(next);
}}
>
<span className="text-[20px] leading-none">+</span>
@@ -877,10 +955,10 @@ export default function Home() {
onClick={() => {
if (volumeConfirm === "min") {
setLocalVol(0);
setVolume(0);
applyVolume(0);
} else if (volumeConfirm === "max") {
setLocalVol(200);
setVolume(200);
applyVolume(200);
}
setVolumeConfirm(null);
}}
+429 -26
View File
@@ -1,190 +1,593 @@
/* ============================================================
SELECT PAGE — Universal full-screen option picker
Design: iOS Settings style selection list
Usage: Navigate to /select with state object
State params:
title - Page title shown in header
options - Array of option strings
selected - Currently selected index (number)
back - Route to navigate back to after selection
key - Identifier for which setting this controls
============================================================ */
import { useLocation } from "wouter";
import { ChevronLeft, Check } from "lucide-react";
import { useDevice } from "@/contexts/DeviceContext";
import { cn } from "@/lib/utils";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import localeZh from "@/locales/data-zh.json";
import localeZhHK from "@/locales/data-zh-HK.json";
import localeEn from "@/locales/data-en.json";
const iisModeImageUrl = (index: number) =>
`${import.meta.env.BASE_URL}IIS/iis_app${index + 1}.png`;
/* ── Key → API field mapping ── */
const KEY_TO_SETTING: Record<string, string> = {
effect_value: "effect_value",
crossfeed_value: "crossfeed_value",
language: "language",
analogGain: "analogGain",
soundStep: "soundStep",
bootSound: "bootSound",
filterCharacteristic: "pcm",
mutePolar: "hdmimutepolar",
IISMode: "hdmiType",
xlr: "xlr",
dacGain: "dacGain",
dacImpedance: "dacImpedance",
vu: "vu",
input: "input",
output: "output",
screenLight: "screenLight",
screenOff: "screenOff",
buttonLight: "buttonLight",
autoHome: "autoHome",
sleep: "sleep",
headphone: "peqSelect",
type DacVolumeDirectLocale = {
label?: string;
confirmTitle?: string;
confirm0dB?: string;
confirm12dB?: string;
confirmOk?: string;
confirmCancel?: string;
};
type LocaleRoot = {
dac?: { dacVolumeDirect?: DacVolumeDirectLocale };
};
const LOCALE_BY_LANG: Record<number, LocaleRoot> = {
0: localeEn as LocaleRoot,
1: localeZhHK as LocaleRoot,
2: localeZh as LocaleRoot,
};
/* ── Key → API field mapping ── */
const KEY_TO_SETTING: Record<string, string> = {
effect_value: "effect_value",
crossfeed_value: "crossfeed_value",
subwoofer_rate: "subwoofer_rate",
subwoofer_mix_type: "subwoofer_mix_type",
language: "language",
analogGain: "analogGain",
soundStep: "soundStep",
bootSound: "bootSound",
filterCharacteristic: "pcm",
mutePolar: "hdmimutepolar",
IISMode: "hdmiType",
xlr: "xlr",
dacGain: "dacGain",
dacImpedance: "dacImpedance",
dacVolumeDirect: "dacVolumeDirect",
dacArc: "dacArc",
vu: "vu",
input: "input",
output: "output",
screenLight: "screenLight",
screenOff: "screenOff",
buttonLight: "buttonLight",
autoHome: "autoHome",
sleep: "sleep",
headphone: "peqSelect",
};
// Global state for select page (to avoid URL params)
export let selectPageState: {
title: string;
options: string[];
selected: number;
back: string;
key: string;
} | null = null;
// Last select result for pages that need local state update
export let selectPageResult: { key: string; selected: number } | null = null;
export function consumeSelectPageResult() {
const result = selectPageResult;
selectPageResult = null;
return result;
}
// Helper function to set select page state
export function navigateToSelect(title: string, options: string[], selected: number, back: string, key: string) {
selectPageState = { title, options, selected, back, key };
}
export default function SelectPage() {
const [location, setLocation] = useLocation();
const { updateSetting } = useDevice();
const { updateSetting, deviceState } = useDevice();
const [state, setState] = useState<{
title: string;
options: string[];
selected: number;
back: string;
key: string;
} | null>(() => selectPageState);
const [saving, setSaving] = useState(false);
const [passthroughConfirmIdx, setPassthroughConfirmIdx] = useState<number | null>(null);
const langIdx = deviceState?.language ?? 2;
const dacVolumeDirectText = (LOCALE_BY_LANG[langIdx] ?? localeZh as LocaleRoot).dac?.dacVolumeDirect;
const passthroughConfirmCopy = useMemo(() => {
if (passthroughConfirmIdx === null) return null;
const message =
passthroughConfirmIdx === 1
? dacVolumeDirectText?.confirm0dB
: passthroughConfirmIdx === 2
? dacVolumeDirectText?.confirm12dB
: undefined;
return {
title: dacVolumeDirectText?.confirmTitle ?? "提示",
message:
message ??
(passthroughConfirmIdx === 1
? "直通模式会将音量调到最大,并且不可调节"
: "直通模式会将音量调到-12dB,并且不可调节"),
ok: dacVolumeDirectText?.confirmOk ?? "确认",
cancel: dacVolumeDirectText?.confirmCancel ?? "取消",
};
}, [passthroughConfirmIdx, dacVolumeDirectText]);
// Re-read global state whenever we enter /select (avoids stale key/options after remount)
useEffect(() => {
if (location === "/select" && selectPageState) {
setState({ ...selectPageState });
}
}, [location]);
if (!state) {
return null;
}
const { title, options, selected: selectedIdx, back, key } = state;
const handleSelect = async (idx: number) => {
const applySelect = async (idx: number) => {
if (saving) return;
selectPageResult = { key, selected: idx };
const apiField = KEY_TO_SETTING[key];
if (apiField) {
setSaving(true);
try {
await updateSetting({ [apiField]: idx });
} finally {
setSaving(false);
}
}
selectPageState = null;
setLocation(back);
};
const handleSelect = async (idx: number) => {
if (saving) return;
if (key === "dacVolumeDirect" && (idx === 1 || idx === 2) && idx !== selectedIdx) {
setPassthroughConfirmIdx(idx);
return;
}
await applySelect(idx);
};
const handlePassthroughConfirm = async () => {
if (passthroughConfirmIdx === null) return;
const idx = passthroughConfirmIdx;
setPassthroughConfirmIdx(null);
await applySelect(idx);
};
return (
<div className="min-h-screen bg-black">
{/* ── Header ── */}
<div className="page-header">
<button
onClick={() => setLocation(back)}
className="mr-4 text-white/60 active:text-white transition-colors">
<ChevronLeft size={24} />
</button>
<h1 className="flex-1 text-center text-[17px] font-semibold text-white">{title}</h1>
<div className="w-8" />
</div>
<div className="px-4 pt-4 pb-24">
{key === "IISMode" ? (
<div className="grid w-full grid-cols-2 gap-3">
{options.map((opt, idx) => {
const isSelected = idx === selectedIdx;
return (
<button
key={idx}
type="button"
disabled={saving}
className={cn(
"flex flex-col items-center gap-2 rounded-xl border-2 p-1.5 transition-all active:scale-[0.98] disabled:opacity-60",
isSelected
? "border-[#00FFF6] shadow-[0_0_14px_rgba(0,255,246,0.35)]"
: "border-white/10",
)}
onClick={() => void handleSelect(idx)}
aria-pressed={isSelected}
aria-label={opt}
>
<img
src={iisModeImageUrl(idx)}
alt={opt}
className="block w-full h-auto rounded-lg bg-black/40"
loading="lazy"
/>
<span
className={cn(
"w-6 h-6 rounded-full flex items-center justify-center shrink-0",
isSelected
? "bg-[#00FFF6] text-black shadow-[0_0_10px_rgba(0,255,246,0.45)]"
: "border-2 border-white/20",
)}
>
{isSelected && <Check size={12} strokeWidth={3} />}
</span>
</button>
);
})}
</div>
) : (
<div className="ios-list-group">
{options.map((opt, idx) => {
const isSelected = idx === selectedIdx;
return (
<button
key={idx}
className="ios-list-row w-full text-left active:bg-white/5 transition-colors"
onClick={() => void handleSelect(idx)}
disabled={saving}
>
<span className={`flex-1 text-[16px] ${isSelected ? "text-white font-medium" : "text-white/75"}`}>
{opt}
</span>
{isSelected && (
<Check size={18} style={{ color: "#00FFF6" }} />
)}
</button>
);
})}
</div>
)}
</div>
<AlertDialog
open={passthroughConfirmIdx !== null}
onOpenChange={(open) => {
if (!open) setPassthroughConfirmIdx(null);
}}
>
<AlertDialogContent className="top-[42%] border-white/10 bg-zinc-900 text-white sm:max-w-md">
<AlertDialogHeader>
<AlertDialogTitle className="text-white">
{passthroughConfirmCopy?.title}
</AlertDialogTitle>
<AlertDialogDescription className="text-white/60">
{passthroughConfirmCopy?.message}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="border-white/20 bg-transparent text-white hover:bg-white/10">
{passthroughConfirmCopy?.cancel}
</AlertDialogCancel>
<AlertDialogAction
className="bg-[#00FFF6] text-black hover:bg-[#00FFF6]/90 focus-visible:ring-[#00FFF6]"
disabled={saving}
onClick={() => void handlePassthroughConfirm()}
>
{passthroughConfirmCopy?.ok}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}