Files
controller-v2/client/src/pages/AudioPage.tsx
T
eafonyang 33a5b8a9b0 feat(api): 新增 UAC 版本支持及相关界面改进
- 在音频参数中加入 uac_ver 字段,支持区分 UAC 2.0 和 UAC 1.0
- 在多语言资源中添加 UAC 版本选项及对应标签(中英文、德语、西班牙语、法语、繁体中文等)
- 在 AudioPage 页面新增 UAC 版本选择组件,支持用户选择不同版本
- 在 SelectPage 中加入 uac_ver 的映射,保证数据正确传递
- 为 UAC 版本选项新增“Custom”自定义项,丰富配置可能性
2026-09-08 14:19:26 +08:00

406 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
AUDIO PAGE — Audio Settings
Design: Reference luxsin_x8_音频设置.png
Sections:
- 平衡与灵敏度: 左右平衡 slider, VU灵敏度 slider
- 输出特性: 滤波特性, 耳机增益, 音量幅度
- IIS设置: IIS静音电平, IIS模式
- 系统与接口: 开机音量, XLR极性
All list rows → navigate to /select page
============================================================ */
import { useDevice } from "@/contexts/DeviceContext";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useLocation } from "wouter";
import { useState, useEffect, useRef } from "react";
import BottomNav from "@/components/BottomNav";
import { navigateToSelect } from "./SelectPage";
import { getBootSoundMaxIndex } from "@/config/firmwareFeatures";
import {
normalizeBootSound,
parseFirmwareVersion,
readBootSoundFromState,
} from "@/lib/luxsinApi";
import localeEn from "@/locales/data-en.json";
import { resolveLocalePack } from "@/locales/resolveLocale";
import { usePageTitleWithConnection } from "@/hooks/usePageTitleWithConnection";
function CyanSlider({ value, min, max, step = 1, onChange, onRelease }: {
value: number;
min: number;
max: number;
step?: number;
onChange: (v: number) => void;
/** Commit to device once when the user releases the thumb (pointer / keyboard). */
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 (
<div className="relative flex items-center w-full mt-2">
<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))}
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);
}
}}
/>
</div>
);
}
function SectionHeader({ title }: { title: string }) {
return (
<div className="px-1 pt-5 pb-2">
<span className="text-[14px] font-semibold" style={{ color: "#00FFF6" }}>{title}</span>
</div>
);
}
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 }: { label: string; value: string; onClick?: () => void }) {
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>
<span className="ios-row-value">{value}</span>
<ChevronRight size={16} className="ios-chevron ml-1" />
</button>
);
}
function ToggleRow({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<div className="ios-list-row">
<span className="flex-1 text-[16px] text-white">{label}</span>
<IOSToggle checked={checked} onChange={onChange} />
</div>
);
}
type LocaleDac = {
dac?: {
sectionBalanceSensitivity?: string;
sectionOutput?: string;
sectionIisSettings?: string;
sectionSystemInterface?: string;
balance?: string;
sensitivity?: string;
filters?: { label?: string; options?: Array<{ index: number; label: string }> };
dacGain?: { label?: string; options?: Array<{ index: number; label: string }> };
autoImpedance?: { label?: string; options?: Array<{ index: number; label: string }> };
dreMode?: { label?: string; options?: Array<{ index: number; label: string }> };
dacVolumeDirect?: { label?: string; options?: Array<{ index: number; label: string }> };
uacVer?: { 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 };
};
function optionLabels(items: Array<{ index: number; label: string }> | undefined, fallback: string[]) {
if (!items || items.length === 0) return fallback;
const sorted = [...items].sort((a, b) => a.index - b.index);
return sorted.map((item) => item.label);
}
const STEP_OPTIONS = ["0.5dB", "1dB", "2dB", "3dB"];
const BOOT_VOLUME_FALLBACK = [
"Default",
"-5dB",
"-10dB",
"-15dB",
"-20dB",
"-25dB",
"-30dB",
"-35dB",
"-40dB",
"-45dB",
"-50dB",
];
function bootVolumeLabel(dacText: LocaleDac["dac"], index: number): string {
const boot = dacText?.bootVolume;
if (boot && typeof boot === "object" && boot.options?.length) {
const hit = boot.options.find((o) => o.index === index);
if (hit) return hit.label;
const sorted = [...boot.options].sort((a, b) => a.index - b.index);
if (index >= 0 && index < sorted.length) return sorted[index]?.label ?? BOOT_VOLUME_FALLBACK[index] ?? "—";
}
return BOOT_VOLUME_FALLBACK[index] ?? "—";
}
function bootVolumeOptions(dacText: LocaleDac["dac"], maxIndex: number): string[] {
const labels: string[] = [];
for (let i = 0; i <= maxIndex; i += 1) {
labels.push(bootVolumeLabel(dacText, i));
}
return labels;
}
function bootVolumeRowLabel(dacText: LocaleDac["dac"]): string {
const boot = dacText?.bootVolume;
if (boot && typeof boot === "object" && boot.label) return boot.label;
if (typeof boot === "string") return boot;
return "Boot volume";
}
export default function AudioPage() {
const [location, setLocation] = useLocation();
const { deviceState, updateSetting, refresh, isConnected, api } = useDevice();
const ds = deviceState;
const vuSensor = (ds as ({ vuSensor?: number } | null))?.vuSensor;
const localeData = resolveLocalePack(ds?.language) as LocaleDac;
const dacText = localeData.dac ?? {};
// balance 从接口获取的是乘以 10 后的值,需要除以 10 显示(例如:-130 → -13.0
const balanceVal = ds?.balance !== undefined ? ds.balance / 10 : 0;
// vuSensor 从接口获取的是放大 2 倍的值,需要除以 2 显示(例如:20 → +10dB)
const vuSensVal = vuSensor !== undefined ? vuSensor / 2 : 0;
const bootSoundFromApi = readBootSoundFromState(ds);
const [localBootSound, setLocalBootSound] = useState(bootSoundFromApi);
const [localBalance, setLocalBalance] = useState(balanceVal);
const [localVuSens, setLocalVuSens] = useState(0);
const isBalanceDragging = useRef(false);
const isVuDragging = useRef(false);
useEffect(() => { if (!isBalanceDragging.current) setLocalBalance(balanceVal); }, [balanceVal]);
useEffect(() => { if (!isVuDragging.current) setLocalVuSens(vuSensVal); }, [vuSensVal]);
useEffect(() => {
setLocalBootSound(readBootSoundFromState(ds));
}, [ds?.bootSound]);
useEffect(() => {
if (location !== "/audio") return;
setLocalBootSound(readBootSoundFromState(ds));
}, [location, ds?.bootSound, ds]);
useEffect(() => {
if (location !== "/audio") return;
if (isConnected || api) {
void refresh();
}
}, [location, isConnected, api, refresh]);
const gainIdx = ds?.dacGain ?? 0;
const autoImpedanceOn = (ds?.dacImpedance ?? 0) === 1;
const dreModeOn = (ds?.dreMode ?? 0) === 1;
const dacVolumeDirectIdx = Math.min(Math.max(ds?.dacVolumeDirect ?? 0, 0), 2);
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 uacVerIdx = Math.min(Math.max(ds?.uac_ver ?? 0, 0), 1);
const bootSoundVal = normalizeBootSound(localBootSound);
const firmwareVersion = parseFirmwareVersion(ds?.version);
const bootSoundMaxIdx = getBootSoundMaxIndex(firmwareVersion);
const bootSoundSelectIdx = Math.min(bootSoundVal, bootSoundMaxIdx);
const BOOT_VOLUME_OPTIONS = bootVolumeOptions(dacText, bootSoundMaxIdx);
const FILTER_OPTIONS = optionLabels(dacText.filters?.options, ["快速滚降", "慢速滚降", "短延迟快速滚降", "短延迟慢速滚降", "去重强调", "非过采样(NOS"]);
const GAIN_OPTIONS = optionLabels(dacText.dacGain?.options, ["低", "中", "高"]);
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 DAC_VOLUME_DIRECT_OPTIONS = optionLabels(dacText.dacVolumeDirect?.options, [
"关闭",
"0dB",
"-12dB",
]);
const UAC_VER_OPTIONS = optionLabels(dacText.uacVer?.options, ["UAC 2.0", "UAC 1.0"]);
const pageTitle = usePageTitleWithConnection(localeData.home?.audioSet ?? "Audio");
const goSelect = (title: string, options: string[], selected: number, key: string) => {
navigateToSelect(title, options, selected, "/audio", key);
setLocation("/select");
};
return (
<div className="min-h-screen bg-black">
<div className="page-header">
<button onClick={() => setLocation("/")} 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">
{pageTitle}
</h1>
<div className="w-8" />
</div>
<div className="px-4 pb-32">
{/* ── 平衡与灵敏度 ── */}
<SectionHeader title={dacText.sectionBalanceSensitivity ?? "平衡与灵敏度"} />
<div className="ios-list-group px-4 py-3 space-y-4">
<div>
<div className="flex items-center justify-between">
<span className="text-[16px] text-white">{dacText.balance ?? "左右平衡"}</span>
<span className="text-[16px] font-semibold" style={{ color: "#00FFF6" }}>
{localBalance >= 0 ? `+${localBalance.toFixed(1)}` : localBalance.toFixed(1)} dB
</span>
</div>
<CyanSlider
value={localBalance}
min={-15}
max={15}
step={0.1}
onChange={(v) => {
isBalanceDragging.current = true;
setLocalBalance(v);
}}
onRelease={(v) => {
isBalanceDragging.current = false;
setLocalBalance(v);
updateSetting({ balance: Math.round(v * 10) });
}}
/>
</div>
<div className="border-t border-white/[0.06]" />
<div>
<div className="flex items-center justify-between">
<span className="text-[16px] text-white">{dacText.sensitivity ?? "VU 表灵敏度"}</span>
<span className="text-[16px] font-semibold" style={{ color: "#00FFF6" }}>
{localVuSens >= 0 ? `+${localVuSens}` : localVuSens} dB
</span>
</div>
<CyanSlider
value={localVuSens}
min={-20}
max={20}
step={1}
onChange={(v) => {
isVuDragging.current = true;
setLocalVuSens(v);
}}
onRelease={(v) => {
isVuDragging.current = false;
setLocalVuSens(v);
updateSetting({ vuSensor: Math.round(v * 2) });
}}
/>
</div>
</div>
{/* ── 输出特性 ── */}
<SectionHeader title={dacText.sectionOutput ?? "输出特性"} />
<div className="ios-list-group">
<ListRow
label={dacText.filters?.label ?? "滤波特性"}
value={FILTER_OPTIONS[filterIdx] ?? FILTER_OPTIONS[0] ?? "—"}
onClick={() => goSelect(dacText.filters?.label ?? "选择滤波特性", FILTER_OPTIONS, filterIdx, "filterCharacteristic")}
/>
<ListRow
label={dacText.dacGain?.label ?? "耳机增益"}
value={GAIN_OPTIONS[gainIdx] ?? GAIN_OPTIONS[0] ?? "—"}
onClick={() => goSelect(dacText.dacGain?.label ?? "选择耳机增益", GAIN_OPTIONS, gainIdx, "dacGain")}
/>
<ToggleRow
label={dacText.autoImpedance?.label ?? "自动检测耳机阻抗"}
checked={autoImpedanceOn}
onChange={(v) => updateSetting({ dacImpedance: v ? 1 : 0 })}
/>
<ToggleRow
label={dacText.dreMode?.label ?? "动态范围增强"}
checked={dreModeOn}
onChange={(v) => updateSetting({ dreMode: 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.volumeStep ?? "音量幅度"}
value={STEP_OPTIONS[stepIdx] ?? STEP_OPTIONS[0]}
onClick={() => goSelect(dacText.volumeStep ?? "选择音量幅度", STEP_OPTIONS, stepIdx, "soundStep")}
/>
</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">
<ListRow
label={bootVolumeRowLabel(dacText)}
value={bootVolumeLabel(dacText, bootSoundVal)}
onClick={() =>
goSelect(
bootVolumeRowLabel(dacText),
BOOT_VOLUME_OPTIONS,
bootSoundSelectIdx,
"bootSound",
)
}
/>
<ListRow
label={dacText.xlr?.label ?? "XLR 端口极性"}
value={XLR_OPTIONS[xlrIdx] ?? XLR_OPTIONS[0] ?? "—"}
onClick={() => goSelect(dacText.xlr?.label ?? "选择 XLR 极性", XLR_OPTIONS, xlrIdx, "xlr")}
/>
<ListRow
label={dacText.uacVer?.label ?? "UAC 版本"}
value={UAC_VER_OPTIONS[uacVerIdx] ?? UAC_VER_OPTIONS[0] ?? "—"}
onClick={() => goSelect(dacText.uacVer?.label ?? "选择 UAC 版本", UAC_VER_OPTIONS, uacVerIdx, "uacVer")}
/>
</div>
</div>
<BottomNav />
</div>
);
}