2026-03-31 18:52:37 +08:00
|
|
|
|
/* ============================================================
|
|
|
|
|
|
AUDIO PAGE — Audio Settings
|
|
|
|
|
|
Design: Reference luxsin_x8_音频设置.png
|
|
|
|
|
|
Sections:
|
|
|
|
|
|
- 平衡与灵敏度: 左右平衡 slider, VU灵敏度 slider
|
|
|
|
|
|
- 输出特性: 滤波特性, 耳机增益, 音量幅度
|
|
|
|
|
|
- 系统与接口: XLR极性, DAC增益, DAC阻抗
|
|
|
|
|
|
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";
|
2026-04-17 17:57:39 +08:00
|
|
|
|
import localeZh from "@/locales/data-zh.json";
|
|
|
|
|
|
import localeZhHK from "@/locales/data-zh-HK.json";
|
|
|
|
|
|
import localeEn from "@/locales/data-en.json";
|
2026-03-31 18:52:37 +08:00
|
|
|
|
|
|
|
|
|
|
function CyanSlider({ value, min, max, step = 1, onChange }: {
|
|
|
|
|
|
value: number; min: number; max: number; step?: number; onChange: (v: number) => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const fillPct = ((value - min) / (max - min)) * 100;
|
|
|
|
|
|
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))} />
|
|
|
|
|
|
</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 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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 17:57:39 +08:00
|
|
|
|
type LocaleDac = {
|
|
|
|
|
|
dac?: {
|
|
|
|
|
|
balance?: string;
|
|
|
|
|
|
sensitivity?: string;
|
|
|
|
|
|
filters?: { label?: string; options?: Array<{ index: number; label: string }> };
|
|
|
|
|
|
dacGain?: { label?: string; options?: Array<{ index: number; label: string }> };
|
|
|
|
|
|
volumeStep?: 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 };
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const LOCALE_BY_LANG: Record<number, LocaleDac> = {
|
|
|
|
|
|
0: localeEn as LocaleDac,
|
|
|
|
|
|
1: localeZhHK as LocaleDac,
|
|
|
|
|
|
2: localeZh as LocaleDac,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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"];
|
2026-03-31 18:52:37 +08:00
|
|
|
|
|
|
|
|
|
|
export default function AudioPage() {
|
|
|
|
|
|
const [, setLocation] = useLocation();
|
2026-04-17 17:57:39 +08:00
|
|
|
|
const { deviceState, updateSetting, refresh, isConnected, api } = useDevice();
|
2026-03-31 18:52:37 +08:00
|
|
|
|
const ds = deviceState;
|
2026-04-14 16:33:52 +08:00
|
|
|
|
const vuSensor = (ds as ({ vuSensor?: number } | null))?.vuSensor;
|
2026-04-17 17:57:39 +08:00
|
|
|
|
const langIdx = ds?.language ?? 2;
|
|
|
|
|
|
const localeData = LOCALE_BY_LANG[langIdx] ?? (localeZh as LocaleDac);
|
|
|
|
|
|
const dacText = localeData.dac ?? {};
|
2026-03-31 18:52:37 +08:00
|
|
|
|
|
2026-04-14 16:33:52 +08:00
|
|
|
|
// 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;
|
2026-03-31 18:52:37 +08:00
|
|
|
|
const [localBalance, setLocalBalance] = useState(balanceVal);
|
2026-04-14 16:33:52 +08:00
|
|
|
|
const [localVuSens, setLocalVuSens] = useState(0);
|
|
|
|
|
|
const isBalanceDragging = useRef(false);
|
|
|
|
|
|
const isVuDragging = useRef(false);
|
2026-03-31 18:52:37 +08:00
|
|
|
|
|
2026-04-14 16:33:52 +08:00
|
|
|
|
useEffect(() => { if (!isBalanceDragging.current) setLocalBalance(balanceVal); }, [balanceVal]);
|
|
|
|
|
|
useEffect(() => { if (!isVuDragging.current) setLocalVuSens(vuSensVal); }, [vuSensVal]);
|
2026-04-17 17:57:39 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
// Ensure we have the latest pcm value from syncData when entering this page
|
|
|
|
|
|
if (isConnected || api) {
|
|
|
|
|
|
refresh();
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [isConnected, api, refresh]);
|
2026-03-31 18:52:37 +08:00
|
|
|
|
|
2026-04-17 17:57:39 +08:00
|
|
|
|
const gainIdx = ds?.dacGain ?? 0;
|
2026-03-31 18:52:37 +08:00
|
|
|
|
const stepIdx = ds?.soundStep ?? 1;
|
2026-04-17 17:57:39 +08:00
|
|
|
|
const filterIdx = ds?.pcm ?? 0;
|
2026-03-31 18:52:37 +08:00
|
|
|
|
const xlrIdx = ds?.xlr ?? 0;
|
2026-04-17 17:57:39 +08:00
|
|
|
|
const mutePolarIdx = (ds as ({ hdmimutepolar?: number } | null))?.hdmimutepolar ?? 0;
|
|
|
|
|
|
const iisModeIdx = (ds as ({ hdmiType?: number } | null))?.hdmiType ?? 0;
|
|
|
|
|
|
|
|
|
|
|
|
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"]);
|
2026-03-31 18:52:37 +08:00
|
|
|
|
|
|
|
|
|
|
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>
|
2026-04-17 17:57:39 +08:00
|
|
|
|
<h1 className="flex-1 text-center text-[17px] font-semibold text-white">
|
|
|
|
|
|
{localeData.home?.audioSet ?? "音频设置"}
|
|
|
|
|
|
</h1>
|
2026-03-31 18:52:37 +08:00
|
|
|
|
<div className="w-8" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="px-4 pb-32">
|
|
|
|
|
|
{/* ── 平衡与灵敏度 ── */}
|
|
|
|
|
|
<SectionHeader title="平衡与灵敏度" />
|
|
|
|
|
|
<div className="ios-list-group px-4 py-3 space-y-4">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
2026-04-17 17:57:39 +08:00
|
|
|
|
<span className="text-[16px] text-white">{dacText.balance ?? "左右平衡"}</span>
|
2026-03-31 18:52:37 +08:00
|
|
|
|
<span className="text-[16px] font-semibold" style={{ color: "#00FFF6" }}>
|
|
|
|
|
|
{localBalance >= 0 ? `+${localBalance.toFixed(1)}` : localBalance.toFixed(1)} dB
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2026-04-14 16:33:52 +08:00
|
|
|
|
<CyanSlider value={localBalance} min={-15} max={15} step={0.1}
|
2026-03-31 18:52:37 +08:00
|
|
|
|
onChange={(v) => {
|
2026-04-14 16:33:52 +08:00
|
|
|
|
isBalanceDragging.current = true;
|
2026-03-31 18:52:37 +08:00
|
|
|
|
setLocalBalance(v);
|
2026-04-14 16:33:52 +08:00
|
|
|
|
// 实时调用接口(例如:-13.0 → -130)
|
|
|
|
|
|
updateSetting({ balance: Math.round(v * 10) });
|
|
|
|
|
|
setTimeout(() => { isBalanceDragging.current = false; }, 500);
|
2026-03-31 18:52:37 +08:00
|
|
|
|
}} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="border-t border-white/[0.06]" />
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
2026-04-17 17:57:39 +08:00
|
|
|
|
<span className="text-[16px] text-white">{dacText.sensitivity ?? "VU 表灵敏度"}</span>
|
2026-03-31 18:52:37 +08:00
|
|
|
|
<span className="text-[16px] font-semibold" style={{ color: "#00FFF6" }}>
|
2026-04-14 16:33:52 +08:00
|
|
|
|
{localVuSens >= 0 ? `+${localVuSens}` : localVuSens} dB
|
2026-03-31 18:52:37 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2026-04-14 16:33:52 +08:00
|
|
|
|
<CyanSlider value={localVuSens} min={-20} max={20}
|
2026-03-31 18:52:37 +08:00
|
|
|
|
onChange={(v) => {
|
2026-04-14 16:33:52 +08:00
|
|
|
|
isVuDragging.current = true;
|
2026-03-31 18:52:37 +08:00
|
|
|
|
setLocalVuSens(v);
|
2026-04-14 16:33:52 +08:00
|
|
|
|
// 接口参数需要将显示值乘以 2(例如:10dB -> vuSensor=20)
|
|
|
|
|
|
updateSetting({ vuSensor: Math.round(v * 2) });
|
|
|
|
|
|
setTimeout(() => { isVuDragging.current = false; }, 500);
|
2026-03-31 18:52:37 +08:00
|
|
|
|
}} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* ── 输出特性 ── */}
|
|
|
|
|
|
<SectionHeader title="输出特性" />
|
|
|
|
|
|
<div className="ios-list-group">
|
2026-04-17 17:57:39 +08:00
|
|
|
|
<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")}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<ListRow
|
|
|
|
|
|
label={dacText.volumeStep ?? "音量幅度"}
|
|
|
|
|
|
value={STEP_OPTIONS[stepIdx] ?? STEP_OPTIONS[0]}
|
|
|
|
|
|
onClick={() => goSelect(dacText.volumeStep ?? "选择音量幅度", STEP_OPTIONS, stepIdx, "soundStep")}
|
|
|
|
|
|
/>
|
2026-03-31 18:52:37 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* ── 系统与接口 ── */}
|
|
|
|
|
|
<SectionHeader title="系统与接口" />
|
|
|
|
|
|
<div className="ios-list-group">
|
2026-04-17 17:57:39 +08:00
|
|
|
|
<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.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")}
|
|
|
|
|
|
/>
|
2026-03-31 18:52:37 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<BottomNav />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|