Refactor Vite configuration for improved chunking strategy and caching. Enhance DeviceContext with new upgradePeqChange method for PEQ updates. Update audio page to support localized labels and dynamic volume control. Introduce new filter types and improve EQPage functionality with enhanced frequency response visualization.

This commit is contained in:
yangy
2026-04-17 17:57:39 +08:00
parent 4246e8ce27
commit 0e7670c215
12 changed files with 1103 additions and 153 deletions
-3
View File
@@ -4,9 +4,6 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
<title>Luxsin X8 Controller</title> <title>Luxsin X8 Controller</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+13 -1
View File
@@ -4,6 +4,7 @@ import {
LuxsinAPI, LuxsinAPI,
MOCK_DEVICE_STATE, MOCK_DEVICE_STATE,
MOCK_PEQ_STATE, MOCK_PEQ_STATE,
PeqChangePayload,
PeqFilter, PeqFilter,
PeqState, PeqState,
} from "@/lib/luxsinApi"; } from "@/lib/luxsinApi";
@@ -25,6 +26,7 @@ interface DeviceContextType {
api: LuxsinAPI | null; api: LuxsinAPI | null;
updateSetting: (params: Record<string, string | number>) => Promise<void>; updateSetting: (params: Record<string, string | number>) => Promise<void>;
updatePeq: (filters: PeqFilter[]) => Promise<void>; updatePeq: (filters: PeqFilter[]) => Promise<void>;
upgradePeqChange: (payload: PeqChangePayload) => Promise<void>;
// Optimistic state updaters // Optimistic state updaters
setVolume: (v: number) => void; setVolume: (v: number) => void;
setInput: (v: number) => void; setInput: (v: number) => void;
@@ -149,6 +151,16 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) {
setPeqState({ filters }); setPeqState({ filters });
}, [api, isDemoMode]); }, [api, isDemoMode]);
const upgradePeqChange = useCallback(async (payload: PeqChangePayload) => {
if (isDemoMode) {
setPeqState((prev) => (prev ? { ...prev, filters: payload.peqChange.filters } : prev));
return;
}
if (!api) return;
await api.upgradePeqChange(payload);
setPeqState((prev) => (prev ? { ...prev, filters: payload.peqChange.filters } : prev));
}, [api, isDemoMode]);
// Optimistic setters // Optimistic setters
const setVolume = useCallback((v: number) => { const setVolume = useCallback((v: number) => {
setDeviceState(prev => prev ? { ...prev, volume: v } : prev); setDeviceState(prev => prev ? { ...prev, volume: v } : prev);
@@ -199,7 +211,7 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) {
isConnected, isConnecting, isDemoMode, setDemoMode, isConnected, isConnecting, isDemoMode, setDemoMode,
deviceState, peqState, lastUpdated, error, deviceState, peqState, lastUpdated, error,
connect, disconnect, refresh, api, connect, disconnect, refresh, api,
updateSetting, updatePeq, updateSetting, updatePeq, upgradePeqChange,
setVolume, setInput, setOutput, setBalance, setVolume, setInput, setOutput, setBalance,
}}> }}>
{children} {children}
+51 -2
View File
@@ -37,7 +37,13 @@ export function decodeCustomBase64(encoded: string): string {
} }
export function encodeCustomBase64(data: string): string { export function encodeCustomBase64(data: string): string {
const standard = btoa(data); // btoa only supports Latin1; convert UTF-8 bytes to a binary string first.
const bytes = new TextEncoder().encode(data);
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
const standard = btoa(binary);
let translated = ""; let translated = "";
for (let i = 0; i < standard.length; i++) { for (let i = 0; i < standard.length; i++) {
const char = standard.charAt(i); const char = standard.charAt(i);
@@ -61,6 +67,8 @@ export interface DeviceState {
output: number; output: number;
audioFormat: string; audioFormat: string;
pcm: number; pcm: number;
hdmimutepolar: number;
hdmiType: number;
vu: number; vu: number;
vuSensor: number; vuSensor: number;
vu_count: number; vu_count: number;
@@ -115,12 +123,29 @@ export interface PeqFilter {
export interface PeqState { export interface PeqState {
filters: PeqFilter[]; filters: PeqFilter[];
peqSelect?: number;
peq?: Array<{ peq?: Array<{
name: string; name: string;
filters?: PeqFilter[]; filters?: PeqFilter[] | string;
autoPre?: number;
preamp?: number;
canDel?: number;
brand?: string;
model?: string;
}>; }>;
} }
/** POST body for `/dev/info.cgi` — full peq preset update (custom base64 `json` field). */
export interface PeqChangePayload {
peqChange: {
name: string;
filters: PeqFilter[];
autoPre?: number;
preamp?: number;
canDel?: number;
};
}
// ============================================================ // ============================================================
// Input/Output Labels // Input/Output Labels
// ============================================================ // ============================================================
@@ -183,6 +208,28 @@ export class LuxsinAPI {
}); });
} }
/** Full peq preset: name, filters, autoPre, preamp, canDel — same encoding as legacy `upgradePeq`. */
async upgradePeqChange(body: PeqChangePayload): Promise<void> {
const payload = JSON.stringify(body);
const encoded = encodeCustomBase64(payload);
await fetch(`${this.baseUrl}/dev/info.cgi`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: `json=${encodeURIComponent(encoded)}`,
});
}
/** Remove one or more headphone PEQ profiles. */
async removePeq(names: string[]): Promise<void> {
const payload = JSON.stringify({ peqRemove: names });
const encoded = encodeCustomBase64(payload);
await fetch(`${this.baseUrl}/dev/info.cgi`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
body: `json=${encodeURIComponent(encoded)}`,
});
}
setVolume(volume: number) { return this.setSetting({ volume }); } setVolume(volume: number) { return this.setSetting({ volume }); }
setInput(input: number) { return this.setSetting({ input }); } setInput(input: number) { return this.setSetting({ input }); }
setOutput(output: number) { return this.setSetting({ output }); } setOutput(output: number) { return this.setSetting({ output }); }
@@ -227,6 +274,8 @@ export const MOCK_DEVICE_STATE: DeviceState = {
output: 0, output: 0,
audioFormat: "PCM 44.1 KHz", audioFormat: "PCM 44.1 KHz",
pcm: 1, pcm: 1,
hdmimutepolar: 0,
hdmiType: 0,
vu: 0, vu: 0,
vuSensor: 0, vuSensor: 0,
vu_count: 5, vu_count: 5,
+48 -2
View File
@@ -13,8 +13,11 @@ export const TYPE_BANDPASS = 2;
export const TYPE_NOTCH = 3; export const TYPE_NOTCH = 3;
export const TYPE_ALLPASS = 7; export const TYPE_ALLPASS = 7;
// Filter type mapping // Filter type mapping (device uses numeric type; pass-through when already a number)
export function getFilterType(typeName: string): number { export function getFilterType(typeName: string | number): number {
if (typeof typeName === "number" && Number.isFinite(typeName)) {
return typeName;
}
switch (typeName) { switch (typeName) {
case 'LPF': case 'LPF':
case 'LOW_PASS': case 'LOW_PASS':
@@ -309,10 +312,53 @@ export function visualizeResponse(coeffList: Coeff[], fs: number): [number[], nu
} }
}); });
if (validCoeffList.length === 0) {
const flat = f.map(() => 0);
return [semilogf, flat];
}
const overall = getFreqznList(validCoeffList, fs, f); const overall = getFreqznList(validCoeffList, fs, f);
return [semilogf, overall]; return [semilogf, overall];
} }
/** Same log-spaced grid as visualizeResponse (20 Hz … 20 kHz, 349 points). */
export function getPeqLogSpacedFreqs(): number[] {
const n = 349;
const startF = 20;
const logStep = (Math.log10(20000) - Math.log10(20)) / n;
const step = Math.pow(10, logStep);
const f: number[] = [];
for (let i = 0; i < n; i++) {
f.push(startF * Math.pow(step, i));
}
return f;
}
export type PeqBandForResponse = {
enabled: boolean;
gain: number;
freq: number;
q: number;
type: string;
};
/**
* Combined magnitude response (dB) per band — same pipeline as legacy:
* getSectionsMatrix(...) per filter, then cascade via getFreqznList.
*/
export function computePeqMagnitudeDb(bands: PeqBandForResponse[], fs: number): number[] {
const f = getPeqLogSpacedFreqs();
const list: Coeff[] = [];
bands.forEach((b) => {
if (!b.enabled) return;
list.push(getSectionsMatrix(b.gain, b.freq, b.q, getFilterType(b.type), false, fs));
});
if (list.length === 0) {
return f.map(() => 0);
}
return getFreqznList(list, fs, f);
}
/** /**
* Get ECharts options for frequency response chart * Get ECharts options for frequency response chart
*/ */
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"home": { "home": {
"audioSet": "Audio setting" "audioSet": "Audio setting","power": "power", "input": "input", "output": "output", "vu":"VU meter"
}, },
"source": { "source": {
"label": "Input/Output", "label": "Input/Output",
@@ -227,7 +227,7 @@
} }
] ]
}, },
"headPhoneGain": { "dacGain": {
"label": "Headphone gain", "label": "Headphone gain",
"options": [ "options": [
{ {
+2 -2
View File
@@ -1,5 +1,5 @@
{ {
"home": { "audioSet": "音訊設定" }, "home": { "audioSet": "音訊設定","power": "電源", "input": "輸入源", "output": "輸出端口", "vu":"VU表"},
"source": { "source": {
"label": "輸入/輸出", "label": "輸入/輸出",
"input": { "coaxial": "同軸", "optical": "光纖", "bluetooth": "藍牙", "rca": "類比RCA" }, "input": { "coaxial": "同軸", "optical": "光纖", "bluetooth": "藍牙", "rca": "類比RCA" },
@@ -141,7 +141,7 @@
{ "index": 5, "label": "非過采樣(NOS" } { "index": 5, "label": "非過采樣(NOS" }
] ]
}, },
"headPhoneGain": { "dacGain": {
"label": "耳機增益", "label": "耳機增益",
"options": [ "options": [
{ "index": 0, "label": "低" }, { "index": 0, "label": "低" },
+2 -2
View File
@@ -1,5 +1,5 @@
{ {
"home": { "audioSet": "音频设置" }, "home": { "audioSet": "音频设置", "power": "电源", "input": "输入源", "output": "输出端口", "vu":"VU表" },
"source": { "source": {
"label": "输入输出", "label": "输入输出",
"input": { "coaxial": "同轴", "optical": "光钎", "bluetooth": "蓝牙", "rca": "模拟RCA" }, "input": { "coaxial": "同轴", "optical": "光钎", "bluetooth": "蓝牙", "rca": "模拟RCA" },
@@ -141,7 +141,7 @@
{ "index": 5, "label": "非过采样(NOS" } { "index": 5, "label": "非过采样(NOS" }
] ]
}, },
"headPhoneGain": { "dacGain": {
"label": "耳机增益", "label": "耳机增益",
"options": [ "options": [
{ "index": 0, "label": "低" }, { "index": 0, "label": "低" },
+85 -26
View File
@@ -13,6 +13,9 @@ import { useLocation } from "wouter";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import BottomNav from "@/components/BottomNav"; import BottomNav from "@/components/BottomNav";
import { navigateToSelect } from "./SelectPage"; import { navigateToSelect } from "./SelectPage";
import localeZh from "@/locales/data-zh.json";
import localeZhHK from "@/locales/data-zh-HK.json";
import localeEn from "@/locales/data-en.json";
function CyanSlider({ value, min, max, step = 1, onChange }: { function CyanSlider({ value, min, max, step = 1, onChange }: {
value: number; min: number; max: number; step?: number; onChange: (v: number) => void; value: number; min: number; max: number; step?: number; onChange: (v: number) => void;
@@ -47,18 +50,42 @@ function ListRow({ label, value, onClick }: { label: string; value: string; onCl
); );
} }
const FILTER_OPTIONS = ["短延迟快速滚降", "短延迟慢速滚降", "快速滚降", "慢速滚降", "超慢速滚降"]; type LocaleDac = {
const GAIN_OPTIONS = ["低", "中", "高"]; dac?: {
const STEP_OPTIONS = ["0.5dB", "1dB", "2dB"]; balance?: string;
const XLR_OPTIONS = ["正常", "反转"]; sensitivity?: string;
const DAC_GAIN_OPTIONS = ["0dB", "+3dB", "+6dB"]; filters?: { label?: string; options?: Array<{ index: number; label: string }> };
const DAC_IMP_OPTIONS = ["32Ω", "150Ω", "300Ω"]; 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"];
export default function AudioPage() { export default function AudioPage() {
const [, setLocation] = useLocation(); const [, setLocation] = useLocation();
const { deviceState, updateSetting } = useDevice(); const { deviceState, updateSetting, refresh, isConnected, api } = useDevice();
const ds = deviceState; const ds = deviceState;
const vuSensor = (ds as ({ vuSensor?: number } | null))?.vuSensor; const vuSensor = (ds as ({ vuSensor?: number } | null))?.vuSensor;
const langIdx = ds?.language ?? 2;
const localeData = LOCALE_BY_LANG[langIdx] ?? (localeZh as LocaleDac);
const dacText = localeData.dac ?? {};
// balance 从接口获取的是乘以 10 后的值,需要除以 10 显示(例如:-130 → -13.0 // balance 从接口获取的是乘以 10 后的值,需要除以 10 显示(例如:-130 → -13.0
const balanceVal = ds?.balance !== undefined ? ds.balance / 10 : 0; const balanceVal = ds?.balance !== undefined ? ds.balance / 10 : 0;
@@ -66,18 +93,30 @@ export default function AudioPage() {
const vuSensVal = vuSensor !== undefined ? vuSensor / 2 : 0; const vuSensVal = vuSensor !== undefined ? vuSensor / 2 : 0;
const [localBalance, setLocalBalance] = useState(balanceVal); const [localBalance, setLocalBalance] = useState(balanceVal);
const [localVuSens, setLocalVuSens] = useState(0); const [localVuSens, setLocalVuSens] = useState(0);
const [filterIdx] = useState(0);
const isBalanceDragging = useRef(false); const isBalanceDragging = useRef(false);
const isVuDragging = useRef(false); const isVuDragging = useRef(false);
useEffect(() => { if (!isBalanceDragging.current) setLocalBalance(balanceVal); }, [balanceVal]); useEffect(() => { if (!isBalanceDragging.current) setLocalBalance(balanceVal); }, [balanceVal]);
useEffect(() => { if (!isVuDragging.current) setLocalVuSens(vuSensVal); }, [vuSensVal]); useEffect(() => { if (!isVuDragging.current) setLocalVuSens(vuSensVal); }, [vuSensVal]);
useEffect(() => {
// Ensure we have the latest pcm value from syncData when entering this page
if (isConnected || api) {
refresh();
}
}, [isConnected, api, refresh]);
const gainIdx = ds?.analogGain ?? 0; const gainIdx = ds?.dacGain ?? 0;
const stepIdx = ds?.soundStep ?? 1; const stepIdx = ds?.soundStep ?? 1;
const filterIdx = ds?.pcm ?? 0;
const xlrIdx = ds?.xlr ?? 0; const xlrIdx = ds?.xlr ?? 0;
const dacGainIdx = ds?.dacGain ?? 0; const mutePolarIdx = (ds as ({ hdmimutepolar?: number } | null))?.hdmimutepolar ?? 0;
const dacImpIdx = ds?.dacImpedance ?? 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"]);
const goSelect = (title: string, options: string[], selected: number, key: string) => { const goSelect = (title: string, options: string[], selected: number, key: string) => {
navigateToSelect(title, options, selected, "/audio", key); navigateToSelect(title, options, selected, "/audio", key);
@@ -90,7 +129,9 @@ export default function AudioPage() {
<button onClick={() => setLocation("/")} className="mr-4 text-white/60 active:text-white transition-colors"> <button onClick={() => setLocation("/")} className="mr-4 text-white/60 active:text-white transition-colors">
<ChevronLeft size={24} /> <ChevronLeft size={24} />
</button> </button>
<h1 className="flex-1 text-center text-[17px] font-semibold text-white"></h1> <h1 className="flex-1 text-center text-[17px] font-semibold text-white">
{localeData.home?.audioSet ?? "音频设置"}
</h1>
<div className="w-8" /> <div className="w-8" />
</div> </div>
@@ -100,7 +141,7 @@ export default function AudioPage() {
<div className="ios-list-group px-4 py-3 space-y-4"> <div className="ios-list-group px-4 py-3 space-y-4">
<div> <div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-[16px] text-white"></span> <span className="text-[16px] text-white">{dacText.balance ?? "左右平衡"}</span>
<span className="text-[16px] font-semibold" style={{ color: "#00FFF6" }}> <span className="text-[16px] font-semibold" style={{ color: "#00FFF6" }}>
{localBalance >= 0 ? `+${localBalance.toFixed(1)}` : localBalance.toFixed(1)} dB {localBalance >= 0 ? `+${localBalance.toFixed(1)}` : localBalance.toFixed(1)} dB
</span> </span>
@@ -117,7 +158,7 @@ export default function AudioPage() {
<div className="border-t border-white/[0.06]" /> <div className="border-t border-white/[0.06]" />
<div> <div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-[16px] text-white">VU </span> <span className="text-[16px] text-white">{dacText.sensitivity ?? "VU 表灵敏度"}</span>
<span className="text-[16px] font-semibold" style={{ color: "#00FFF6" }}> <span className="text-[16px] font-semibold" style={{ color: "#00FFF6" }}>
{localVuSens >= 0 ? `+${localVuSens}` : localVuSens} dB {localVuSens >= 0 ? `+${localVuSens}` : localVuSens} dB
</span> </span>
@@ -136,23 +177,41 @@ export default function AudioPage() {
{/* ── 输出特性 ── */} {/* ── 输出特性 ── */}
<SectionHeader title="输出特性" /> <SectionHeader title="输出特性" />
<div className="ios-list-group"> <div className="ios-list-group">
<ListRow label="滤波特性" value={FILTER_OPTIONS[filterIdx]} <ListRow
onClick={() => goSelect("选择滤波特性", FILTER_OPTIONS, filterIdx, "filterCharacteristic")} /> label={dacText.filters?.label ?? "滤波特性"}
<ListRow label="耳机增益" value={GAIN_OPTIONS[gainIdx]} value={FILTER_OPTIONS[filterIdx] ?? FILTER_OPTIONS[0] ?? "—"}
onClick={() => goSelect("选择耳机增益", GAIN_OPTIONS, gainIdx, "analogGain")} /> onClick={() => goSelect(dacText.filters?.label ?? "选择滤波特性", FILTER_OPTIONS, filterIdx, "filterCharacteristic")}
<ListRow label="音量幅度" value={STEP_OPTIONS[stepIdx]} />
onClick={() => goSelect("选择音量幅度", STEP_OPTIONS, stepIdx, "soundStep")} /> <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")}
/>
</div> </div>
{/* ── 系统与接口 ── */} {/* ── 系统与接口 ── */}
<SectionHeader title="系统与接口" /> <SectionHeader title="系统与接口" />
<div className="ios-list-group"> <div className="ios-list-group">
<ListRow label="XLR 端口极性" value={XLR_OPTIONS[xlrIdx]} <ListRow
onClick={() => goSelect("选择 XLR 极性", XLR_OPTIONS, xlrIdx, "xlr")} /> label={dacText.xlr?.label ?? "XLR 端口极性"}
<ListRow label="DAC 增益" value={DAC_GAIN_OPTIONS[dacGainIdx]} value={XLR_OPTIONS[xlrIdx] ?? XLR_OPTIONS[0] ?? "—"}
onClick={() => goSelect("选择 DAC 增益", DAC_GAIN_OPTIONS, dacGainIdx, "dacGain")} /> onClick={() => goSelect(dacText.xlr?.label ?? "选择 XLR 极性", XLR_OPTIONS, xlrIdx, "xlr")}
<ListRow label="DAC 阻抗" value={DAC_IMP_OPTIONS[dacImpIdx]} />
onClick={() => goSelect("选择 DAC 阻抗", DAC_IMP_OPTIONS, dacImpIdx, "dacImpedance")} /> <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> </div>
</div> </div>
File diff suppressed because it is too large Load Diff
+121 -13
View File
@@ -29,6 +29,9 @@ import {
import { useLocation } from "wouter"; import { useLocation } from "wouter";
import { useRef, useState, useEffect, useCallback } from "react"; import { useRef, useState, useEffect, useCallback } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import localeZh from "@/locales/data-zh.json";
import localeZhHK from "@/locales/data-zh-HK.json";
import localeEn from "@/locales/data-en.json";
// ── Volume in dB (0-200 → -100dB to 0dB) ── // ── Volume in dB (0-200 → -100dB to 0dB) ──
function volToDB(v: number) { function volToDB(v: number) {
@@ -88,6 +91,10 @@ export default function Home() {
const [localVol, setLocalVol] = useState(deviceState?.volume ?? 100); const [localVol, setLocalVol] = useState(deviceState?.volume ?? 100);
const isDragging = useRef(false); const isDragging = useRef(false);
const knobDraggingRef = useRef(false);
const knobStartAngleRef = useRef(0);
const knobStartVolRef = useRef(0);
const knobLastVolRef = useRef<number>(localVol);
useEffect(() => { useEffect(() => {
if (!isDragging.current && deviceState?.volume !== undefined) { if (!isDragging.current && deviceState?.volume !== undefined) {
@@ -95,6 +102,10 @@ export default function Home() {
} }
}, [deviceState?.volume]); }, [deviceState?.volume]);
useEffect(() => {
knobLastVolRef.current = localVol;
}, [localVol]);
const handleVolChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { const handleVolChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const v = Number(e.target.value); const v = Number(e.target.value);
setLocalVol(v); setLocalVol(v);
@@ -104,15 +115,67 @@ export default function Home() {
if (!isConnected) return <ConnectScreen />; if (!isConnected) return <ConnectScreen />;
const ds = deviceState!; const ds = deviceState!;
const localeData = ds.language === 0 ? localeEn : ds.language === 1 ? localeZhHK : localeZh;
const homeText = localeData.home ?? {};
const inputLabel = INPUT_LABELS[ds.input] ?? "USB-C"; const inputLabel = INPUT_LABELS[ds.input] ?? "USB-C";
const outputLabel = OUTPUT_LABELS[ds.output] ?? "耳机"; const outputLabel = OUTPUT_LABELS[ds.output] ?? "Headset";
const effectOn = ds.audio_enable === 1; const effectOn = ds.audio_enable === 1;
const audioOn = ds.audio_enable === 1; const audioOn = ds.audio_enable === 1;
const vuLabel = `VU表${(ds.vu ?? 0) + 1}`; const vuLabel = `${homeText.vu ?? "VU表"}${(ds.vu ?? 0) + 1}`;
const bypassOn = (ds.dsp_enable ?? 0) === 0; const bypassOn = (ds.dsp_enable ?? 0) === 0;
// Slider fill % (0-200 → 0-100%) // Slider fill % (0-200 → 0-100%)
const fillPct = (localVol / 200) * 100; const fillPct = (localVol / 200) * 100;
// Knob angle (map 0..200 -> -135..+135 degrees)
const knobAngle = -135 + (localVol / 200) * 270;
const pointAngle = (clientX: number, clientY: number, rect: DOMRect) => {
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
return Math.atan2(clientY - cy, clientX - cx);
};
const normalizeDelta = (delta: number) => {
// Wrap to [-PI, PI] to avoid jumps across the -PI/PI boundary.
const pi2 = Math.PI * 2;
let d = ((delta + Math.PI) % pi2) - Math.PI;
if (d < -Math.PI) d += pi2;
return d;
};
const beginKnobDrag = (e: React.PointerEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const rect = el.getBoundingClientRect();
knobDraggingRef.current = true;
knobStartAngleRef.current = pointAngle(e.clientX, e.clientY, rect);
knobStartVolRef.current = knobLastVolRef.current;
isDragging.current = true;
el.setPointerCapture(e.pointerId);
};
const moveKnobDrag = (e: React.PointerEvent<HTMLDivElement>) => {
if (!knobDraggingRef.current) return;
const el = e.currentTarget;
const rect = el.getBoundingClientRect();
const a = pointAngle(e.clientX, e.clientY, rect);
const delta = normalizeDelta(a - knobStartAngleRef.current);
// 270deg travel -> full scale; 1 rad maps to ~200/(1.5*pi) volume steps
const stepsPerRad = 200 / (Math.PI * 1.5);
const next = Math.max(0, Math.min(200, Math.round(knobStartVolRef.current + delta * stepsPerRad)));
setLocalVol(next);
setVolume(next);
};
const endKnobDrag = (e: React.PointerEvent<HTMLDivElement>) => {
if (!knobDraggingRef.current) return;
knobDraggingRef.current = false;
isDragging.current = false;
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {
// ignore
}
};
return ( return (
<div className="min-h-screen bg-black flex flex-col"> <div className="min-h-screen bg-black flex flex-col">
@@ -135,11 +198,39 @@ export default function Home() {
<div className="absolute inset-x-4 inset-y-3 rounded-xl flex items-center" <div className="absolute inset-x-4 inset-y-3 rounded-xl flex items-center"
style={{ background: "linear-gradient(135deg, #1a1a1c 0%, #2a2a2e 50%, #1a1a1c 100%)", border: "1px solid rgba(255,255,255,0.08)" }}> style={{ background: "linear-gradient(135deg, #1a1a1c 0%, #2a2a2e 50%, #1a1a1c 100%)", border: "1px solid rgba(255,255,255,0.08)" }}>
{/* Left ports */} {/* Left ports */}
<div className="flex flex-col gap-2 px-4"> <div className="px-4">
<div className="w-6 h-6 rounded-full border-2 border-white/20 flex items-center justify-center"> <div className="flex items-center gap-3">
<div className="w-2 h-2 rounded-full bg-white/30" /> {/* Big hole (left) */}
<div
className="w-7 h-7 rounded-full border-2 bg-black/25 flex items-center justify-center"
style={{
borderColor: "rgba(245, 158, 11, 0.75)",
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.35)",
}}
>
<div
className="w-2 h-2 rounded-full"
style={{ background: "rgba(196, 126, 9, 0.9)" }}
/>
</div>
{/* Two small holes (right, stacked) */}
<div className="flex flex-col gap-2">
<div
className="w-5 h-5 rounded-full border-2 bg-black/25"
style={{
borderColor: "rgba(245, 158, 11, 0.65)",
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.35)",
}}
/>
<div
className="w-5 h-5 rounded-full border-2 bg-black/25"
style={{
borderColor: "rgba(245, 158, 11, 0.65)",
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.35)",
}}
/>
</div>
</div> </div>
<div className="w-5 h-5 rounded-full border-2 border-amber-400/40" />
</div> </div>
{/* VU display area */} {/* VU display area */}
<div className="flex-1 mx-3 rounded-lg overflow-hidden" <div className="flex-1 mx-3 rounded-lg overflow-hidden"
@@ -166,8 +257,25 @@ export default function Home() {
</div> </div>
{/* Right knob */} {/* Right knob */}
<div className="pr-4"> <div className="pr-4">
<div className="w-12 h-12 rounded-full flex items-center justify-center" <div
style={{ background: "radial-gradient(circle at 35% 35%, #4a4a4e, #1a1a1c)", border: "2px solid rgba(255,255,255,0.12)", boxShadow: "inset 0 2px 4px rgba(0,0,0,0.5)" }}> className="w-12 h-12 rounded-full flex items-center justify-center touch-none select-none"
style={{
background: "radial-gradient(circle at 35% 35%, #4a4a4e, #1a1a1c)",
border: "2px solid rgba(255,255,255,0.12)",
boxShadow: "inset 0 2px 4px rgba(0,0,0,0.5)",
transform: `rotate(${knobAngle}deg)`,
transition: knobDraggingRef.current ? "none" : "transform 120ms ease-out",
}}
role="slider"
aria-label="Volume"
aria-valuemin={0}
aria-valuemax={200}
aria-valuenow={localVol}
onPointerDown={beginKnobDrag}
onPointerMove={moveKnobDrag}
onPointerUp={endKnobDrag}
onPointerCancel={endKnobDrag}
>
<div className="w-1 h-4 rounded-full bg-white/40" style={{ transform: "rotate(-30deg)", transformOrigin: "bottom center" }} /> <div className="w-1 h-4 rounded-full bg-white/40" style={{ transform: "rotate(-30deg)", transformOrigin: "bottom center" }} />
</div> </div>
</div> </div>
@@ -278,7 +386,7 @@ export default function Home() {
> >
<Power size={22} className="text-white/60" /> <Power size={22} className="text-white/60" />
</button> </button>
<span className="text-[11px] text-white/35"></span> <span className="text-[11px] text-white/35">{homeText.power ?? "电源"}</span>
</div> </div>
{/* EQ */} {/* EQ */}
@@ -320,13 +428,13 @@ export default function Home() {
<div className="ios-list-group"> <div className="ios-list-group">
<ListRow <ListRow
icon={<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-4 h-4"><path d="M9 3H5a2 2 0 0 0-2 2v4m6-6h10a2 2 0 0 1 2 2v4M9 3v18m0 0h10a2 2 0 0 0 2-2V9M9 21H5a2 2 0 0 1-2-2V9m0 0h18"/></svg>} icon={<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-4 h-4"><path d="M9 3H5a2 2 0 0 0-2 2v4m6-6h10a2 2 0 0 1 2 2v4M9 3v18m0 0h10a2 2 0 0 0 2-2V9M9 21H5a2 2 0 0 1-2-2V9m0 0h18"/></svg>}
label="输入源" label={homeText.input ?? "输入源"}
value={inputLabel} value={inputLabel}
onClick={() => setLocation("/io")} onClick={() => setLocation("/io")}
/> />
<ListRow <ListRow
icon={<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-4 h-4"><path d="M15 3H9a2 2 0 0 0-2 2v4m8-6h4a2 2 0 0 1 2 2v4M15 3v18m0 0H9a2 2 0 0 1-2-2V9m8 12h4a2 2 0 0 0 2-2V9M7 9H3"/></svg>} icon={<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-4 h-4"><path d="M15 3H9a2 2 0 0 0-2 2v4m8-6h4a2 2 0 0 1 2 2v4M15 3v18m0 0H9a2 2 0 0 1-2-2V9m8 12h4a2 2 0 0 0 2-2V9M7 9H3"/></svg>}
label="输出端口" label={homeText.output ?? "输出端口"}
value={outputLabel} value={outputLabel}
onClick={() => setLocation("/io")} onClick={() => setLocation("/io")}
/> />
@@ -340,12 +448,12 @@ export default function Home() {
/> />
<ListRow <ListRow
icon={<Settings size={16} />} icon={<Settings size={16} />}
label="音频设置" label={homeText.audioSet ?? "音频设置"}
onClick={() => setLocation("/audio")} onClick={() => setLocation("/audio")}
/> />
<ListRow <ListRow
icon={<Gauge size={16} />} icon={<Gauge size={16} />}
label="VU表" label={homeText.vu ?? "VU表"}
value={vuLabel} value={vuLabel}
onClick={() => setLocation("/vu")} onClick={() => setLocation("/vu")}
/> />
+15
View File
@@ -22,6 +22,9 @@ const KEY_TO_SETTING: Record<string, string> = {
language: "language", language: "language",
analogGain: "analogGain", analogGain: "analogGain",
soundStep: "soundStep", soundStep: "soundStep",
filterCharacteristic: "pcm",
mutePolar: "hdmimutepolar",
IISMode: "hdmiType",
xlr: "xlr", xlr: "xlr",
dacGain: "dacGain", dacGain: "dacGain",
dacImpedance: "dacImpedance", dacImpedance: "dacImpedance",
@@ -45,6 +48,15 @@ export let selectPageState: {
key: string; key: string;
} | null = null; } | 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 // Helper function to set select page state
export function navigateToSelect(title: string, options: string[], selected: number, back: string, key: string) { export function navigateToSelect(title: string, options: string[], selected: number, back: string, key: string) {
selectPageState = { title, options, selected, back, key }; selectPageState = { title, options, selected, back, key };
@@ -75,6 +87,9 @@ export default function SelectPage() {
const { title, options, selected: selectedIdx, back, key } = state; const { title, options, selected: selectedIdx, back, key } = state;
const handleSelect = (idx: number) => { const handleSelect = (idx: number) => {
// Keep selection result for pages that handle non-api local updates (e.g. EQ filter type)
selectPageResult = { key, selected: idx };
// Apply setting if key maps to an API field // Apply setting if key maps to an API field
const apiField = KEY_TO_SETTING[key]; const apiField = KEY_TO_SETTING[key];
if (apiField) { if (apiField) {
+41 -4
View File
@@ -180,11 +180,48 @@ export default defineConfig({
}, },
rollupOptions: { rollupOptions: {
output: { output: {
manualChunks: { manualChunks(id) {
'react-vendor': ['react', 'react-dom'], if (!id.includes("node_modules")) return;
'router-vendor': ['wouter'],
'ui-vendor': ['lucide-react', 'class-variance-authority', 'clsx', 'tailwind-merge'], // Keep core runtime dependencies stable for better long-term caching.
if (id.includes("node_modules/react/") || id.includes("node_modules/react-dom/")) {
return "react-vendor";
}
if (id.includes("node_modules/wouter/")) {
return "router-vendor";
}
// Split large libraries into their own chunks to avoid a single huge entry bundle.
if (id.includes("node_modules/echarts/")) return "echarts-vendor";
if (id.includes("node_modules/recharts/")) return "recharts-vendor";
if (id.includes("node_modules/framer-motion/")) return "motion-vendor";
if (id.includes("node_modules/@radix-ui/")) return "radix-vendor";
// Group frequently used small UI helpers.
if (
id.includes("node_modules/lucide-react/") ||
id.includes("node_modules/class-variance-authority/") ||
id.includes("node_modules/clsx/") ||
id.includes("node_modules/tailwind-merge/")
) {
return "ui-vendor";
}
// Fall back to per-package vendor chunks.
const packageMatch = id.match(
/node_modules[\\/](?:\.pnpm[\\/][^\\/]+[\\/]node_modules[\\/])?(@?[^\\/]+(?:[\\/][^\\/]+)?)/,
);
const packagePath = packageMatch?.[1];
if (!packagePath) return "vendor";
const packageName = packagePath.startsWith("@")
? packagePath.split(/[\\/]/).slice(0, 2).join("_")
: packagePath.split(/[\\/]/)[0];
return `vendor-${packageName}`;
}, },
// Prevent Rollup from merging manual chunks back into larger bundles.
onlyExplicitManualChunks: true,
}, },
}, },
chunkSizeWarningLimit: 500, chunkSizeWarningLimit: 500,