Files
controller-v2/client/src/pages/AudioPage.tsx
T

410 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:
- 平衡与灵敏度: 左右平衡, VU灵敏度, 模拟输入增益 sliders
- 输出特性: 滤波特性, 耳机增益, 音量幅度
- 系统与接口: 开机音量, 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,
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, 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>
<span className="ios-row-value">{value}</span>
<ChevronRight size={16} className="ios-chevron ml-1" />
</button>
);
}
type LocaleDac = {
dac?: {
sectionBalanceSensitivity?: string;
sectionOutput?: 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 }> };
};
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;
// 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]);
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 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 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, ["快速滚降", "慢速滚降", "短延迟快速滚降", "短延迟慢速滚降", "超慢速滚降", "低离散短延迟"]);
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 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 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>
{/* ── 输出特性 ── */}
<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")}
/>
<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]}
onClick={() => goSelect(dacText.volumeStep ?? "选择音量幅度", STEP_OPTIONS, stepIdx, "soundStep")}
/>
</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")}
/>
</div>
</div>
<BottomNav />
</div>
);
}