33a5b8a9b0
- 在音频参数中加入 uac_ver 字段,支持区分 UAC 2.0 和 UAC 1.0 - 在多语言资源中添加 UAC 版本选项及对应标签(中英文、德语、西班牙语、法语、繁体中文等) - 在 AudioPage 页面新增 UAC 版本选择组件,支持用户选择不同版本 - 在 SelectPage 中加入 uac_ver 的映射,保证数据正确传递 - 为 UAC 版本选项新增“Custom”自定义项,丰富配置可能性
406 lines
16 KiB
TypeScript
406 lines
16 KiB
TypeScript
/* ============================================================
|
||
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>
|
||
);
|
||
}
|