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:
@@ -4,6 +4,7 @@ import {
|
||||
LuxsinAPI,
|
||||
MOCK_DEVICE_STATE,
|
||||
MOCK_PEQ_STATE,
|
||||
PeqChangePayload,
|
||||
PeqFilter,
|
||||
PeqState,
|
||||
} from "@/lib/luxsinApi";
|
||||
@@ -25,6 +26,7 @@ interface DeviceContextType {
|
||||
api: LuxsinAPI | null;
|
||||
updateSetting: (params: Record<string, string | number>) => Promise<void>;
|
||||
updatePeq: (filters: PeqFilter[]) => Promise<void>;
|
||||
upgradePeqChange: (payload: PeqChangePayload) => Promise<void>;
|
||||
// Optimistic state updaters
|
||||
setVolume: (v: number) => void;
|
||||
setInput: (v: number) => void;
|
||||
@@ -149,6 +151,16 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) {
|
||||
setPeqState({ filters });
|
||||
}, [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
|
||||
const setVolume = useCallback((v: number) => {
|
||||
setDeviceState(prev => prev ? { ...prev, volume: v } : prev);
|
||||
@@ -199,7 +211,7 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) {
|
||||
isConnected, isConnecting, isDemoMode, setDemoMode,
|
||||
deviceState, peqState, lastUpdated, error,
|
||||
connect, disconnect, refresh, api,
|
||||
updateSetting, updatePeq,
|
||||
updateSetting, updatePeq, upgradePeqChange,
|
||||
setVolume, setInput, setOutput, setBalance,
|
||||
}}>
|
||||
{children}
|
||||
|
||||
@@ -37,7 +37,13 @@ export function decodeCustomBase64(encoded: 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 = "";
|
||||
for (let i = 0; i < standard.length; i++) {
|
||||
const char = standard.charAt(i);
|
||||
@@ -61,6 +67,8 @@ export interface DeviceState {
|
||||
output: number;
|
||||
audioFormat: string;
|
||||
pcm: number;
|
||||
hdmimutepolar: number;
|
||||
hdmiType: number;
|
||||
vu: number;
|
||||
vuSensor: number;
|
||||
vu_count: number;
|
||||
@@ -115,12 +123,29 @@ export interface PeqFilter {
|
||||
|
||||
export interface PeqState {
|
||||
filters: PeqFilter[];
|
||||
peqSelect?: number;
|
||||
peq?: Array<{
|
||||
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
|
||||
// ============================================================
|
||||
@@ -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 }); }
|
||||
setInput(input: number) { return this.setSetting({ input }); }
|
||||
setOutput(output: number) { return this.setSetting({ output }); }
|
||||
@@ -227,6 +274,8 @@ export const MOCK_DEVICE_STATE: DeviceState = {
|
||||
output: 0,
|
||||
audioFormat: "PCM 44.1 KHz",
|
||||
pcm: 1,
|
||||
hdmimutepolar: 0,
|
||||
hdmiType: 0,
|
||||
vu: 0,
|
||||
vuSensor: 0,
|
||||
vu_count: 5,
|
||||
|
||||
@@ -13,8 +13,11 @@ export const TYPE_BANDPASS = 2;
|
||||
export const TYPE_NOTCH = 3;
|
||||
export const TYPE_ALLPASS = 7;
|
||||
|
||||
// Filter type mapping
|
||||
export function getFilterType(typeName: string): number {
|
||||
// Filter type mapping (device uses numeric type; pass-through when already a number)
|
||||
export function getFilterType(typeName: string | number): number {
|
||||
if (typeof typeName === "number" && Number.isFinite(typeName)) {
|
||||
return typeName;
|
||||
}
|
||||
switch (typeName) {
|
||||
case 'LPF':
|
||||
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);
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"home": {
|
||||
"audioSet": "Audio setting"
|
||||
"audioSet": "Audio setting","power": "power", "input": "input", "output": "output", "vu":"VU meter"
|
||||
},
|
||||
"source": {
|
||||
"label": "Input/Output",
|
||||
@@ -227,7 +227,7 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"headPhoneGain": {
|
||||
"dacGain": {
|
||||
"label": "Headphone gain",
|
||||
"options": [
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"home": { "audioSet": "音訊設定" },
|
||||
"home": { "audioSet": "音訊設定","power": "電源", "input": "輸入源", "output": "輸出端口", "vu":"VU表"},
|
||||
"source": {
|
||||
"label": "輸入/輸出",
|
||||
"input": { "coaxial": "同軸", "optical": "光纖", "bluetooth": "藍牙", "rca": "類比RCA" },
|
||||
@@ -141,7 +141,7 @@
|
||||
{ "index": 5, "label": "非過采樣(NOS)" }
|
||||
]
|
||||
},
|
||||
"headPhoneGain": {
|
||||
"dacGain": {
|
||||
"label": "耳機增益",
|
||||
"options": [
|
||||
{ "index": 0, "label": "低" },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"home": { "audioSet": "音频设置" },
|
||||
"home": { "audioSet": "音频设置", "power": "电源", "input": "输入源", "output": "输出端口", "vu":"VU表" },
|
||||
"source": {
|
||||
"label": "输入输出",
|
||||
"input": { "coaxial": "同轴", "optical": "光钎", "bluetooth": "蓝牙", "rca": "模拟RCA" },
|
||||
@@ -141,7 +141,7 @@
|
||||
{ "index": 5, "label": "非过采样(NOS)" }
|
||||
]
|
||||
},
|
||||
"headPhoneGain": {
|
||||
"dacGain": {
|
||||
"label": "耳机增益",
|
||||
"options": [
|
||||
{ "index": 0, "label": "低" },
|
||||
|
||||
@@ -13,6 +13,9 @@ import { useLocation } from "wouter";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import BottomNav from "@/components/BottomNav";
|
||||
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 }: {
|
||||
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 = ["短延迟快速滚降", "短延迟慢速滚降", "快速滚降", "慢速滚降", "超慢速滚降"];
|
||||
const GAIN_OPTIONS = ["低", "中", "高"];
|
||||
const STEP_OPTIONS = ["0.5dB", "1dB", "2dB"];
|
||||
const XLR_OPTIONS = ["正常", "反转"];
|
||||
const DAC_GAIN_OPTIONS = ["0dB", "+3dB", "+6dB"];
|
||||
const DAC_IMP_OPTIONS = ["32Ω", "150Ω", "300Ω"];
|
||||
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"];
|
||||
|
||||
export default function AudioPage() {
|
||||
const [, setLocation] = useLocation();
|
||||
const { deviceState, updateSetting } = useDevice();
|
||||
const { deviceState, updateSetting, refresh, isConnected, api } = useDevice();
|
||||
const ds = deviceState;
|
||||
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)
|
||||
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 [localBalance, setLocalBalance] = useState(balanceVal);
|
||||
const [localVuSens, setLocalVuSens] = useState(0);
|
||||
const [filterIdx] = 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(() => {
|
||||
// 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 filterIdx = ds?.pcm ?? 0;
|
||||
const xlrIdx = ds?.xlr ?? 0;
|
||||
const dacGainIdx = ds?.dacGain ?? 0;
|
||||
const dacImpIdx = ds?.dacImpedance ?? 0;
|
||||
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"]);
|
||||
|
||||
const goSelect = (title: string, options: string[], selected: number, key: string) => {
|
||||
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">
|
||||
<ChevronLeft size={24} />
|
||||
</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>
|
||||
|
||||
@@ -100,7 +141,7 @@ export default function AudioPage() {
|
||||
<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">左右平衡</span>
|
||||
<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>
|
||||
@@ -117,7 +158,7 @@ export default function AudioPage() {
|
||||
<div className="border-t border-white/[0.06]" />
|
||||
<div>
|
||||
<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" }}>
|
||||
{localVuSens >= 0 ? `+${localVuSens}` : localVuSens} dB
|
||||
</span>
|
||||
@@ -136,23 +177,41 @@ export default function AudioPage() {
|
||||
{/* ── 输出特性 ── */}
|
||||
<SectionHeader title="输出特性" />
|
||||
<div className="ios-list-group">
|
||||
<ListRow label="滤波特性" value={FILTER_OPTIONS[filterIdx]}
|
||||
onClick={() => goSelect("选择滤波特性", FILTER_OPTIONS, filterIdx, "filterCharacteristic")} />
|
||||
<ListRow label="耳机增益" value={GAIN_OPTIONS[gainIdx]}
|
||||
onClick={() => goSelect("选择耳机增益", GAIN_OPTIONS, gainIdx, "analogGain")} />
|
||||
<ListRow label="音量幅度" value={STEP_OPTIONS[stepIdx]}
|
||||
onClick={() => goSelect("选择音量幅度", STEP_OPTIONS, stepIdx, "soundStep")} />
|
||||
<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")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 系统与接口 ── */}
|
||||
<SectionHeader title="系统与接口" />
|
||||
<div className="ios-list-group">
|
||||
<ListRow label="XLR 端口极性" value={XLR_OPTIONS[xlrIdx]}
|
||||
onClick={() => goSelect("选择 XLR 极性", XLR_OPTIONS, xlrIdx, "xlr")} />
|
||||
<ListRow label="DAC 增益" value={DAC_GAIN_OPTIONS[dacGainIdx]}
|
||||
onClick={() => goSelect("选择 DAC 增益", DAC_GAIN_OPTIONS, dacGainIdx, "dacGain")} />
|
||||
<ListRow label="DAC 阻抗" value={DAC_IMP_OPTIONS[dacImpIdx]}
|
||||
onClick={() => goSelect("选择 DAC 阻抗", DAC_IMP_OPTIONS, dacImpIdx, "dacImpedance")} />
|
||||
<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")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+723
-96
File diff suppressed because it is too large
Load Diff
+121
-13
@@ -29,6 +29,9 @@ import {
|
||||
import { useLocation } from "wouter";
|
||||
import { useRef, useState, useEffect, useCallback } from "react";
|
||||
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) ──
|
||||
function volToDB(v: number) {
|
||||
@@ -88,6 +91,10 @@ export default function Home() {
|
||||
|
||||
const [localVol, setLocalVol] = useState(deviceState?.volume ?? 100);
|
||||
const isDragging = useRef(false);
|
||||
const knobDraggingRef = useRef(false);
|
||||
const knobStartAngleRef = useRef(0);
|
||||
const knobStartVolRef = useRef(0);
|
||||
const knobLastVolRef = useRef<number>(localVol);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging.current && deviceState?.volume !== undefined) {
|
||||
@@ -95,6 +102,10 @@ export default function Home() {
|
||||
}
|
||||
}, [deviceState?.volume]);
|
||||
|
||||
useEffect(() => {
|
||||
knobLastVolRef.current = localVol;
|
||||
}, [localVol]);
|
||||
|
||||
const handleVolChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const v = Number(e.target.value);
|
||||
setLocalVol(v);
|
||||
@@ -104,15 +115,67 @@ export default function Home() {
|
||||
if (!isConnected) return <ConnectScreen />;
|
||||
|
||||
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 outputLabel = OUTPUT_LABELS[ds.output] ?? "耳机";
|
||||
const outputLabel = OUTPUT_LABELS[ds.output] ?? "Headset";
|
||||
const effectOn = 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;
|
||||
|
||||
// Slider fill % (0-200 → 0-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 (
|
||||
<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"
|
||||
style={{ background: "linear-gradient(135deg, #1a1a1c 0%, #2a2a2e 50%, #1a1a1c 100%)", border: "1px solid rgba(255,255,255,0.08)" }}>
|
||||
{/* Left ports */}
|
||||
<div className="flex flex-col gap-2 px-4">
|
||||
<div className="w-6 h-6 rounded-full border-2 border-white/20 flex items-center justify-center">
|
||||
<div className="w-2 h-2 rounded-full bg-white/30" />
|
||||
<div className="px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 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 className="w-5 h-5 rounded-full border-2 border-amber-400/40" />
|
||||
</div>
|
||||
{/* VU display area */}
|
||||
<div className="flex-1 mx-3 rounded-lg overflow-hidden"
|
||||
@@ -166,8 +257,25 @@ export default function Home() {
|
||||
</div>
|
||||
{/* Right knob */}
|
||||
<div className="pr-4">
|
||||
<div className="w-12 h-12 rounded-full flex items-center justify-center"
|
||||
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)" }}>
|
||||
<div
|
||||
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>
|
||||
</div>
|
||||
@@ -278,7 +386,7 @@ export default function Home() {
|
||||
>
|
||||
<Power size={22} className="text-white/60" />
|
||||
</button>
|
||||
<span className="text-[11px] text-white/35">电源</span>
|
||||
<span className="text-[11px] text-white/35">{homeText.power ?? "电源"}</span>
|
||||
</div>
|
||||
|
||||
{/* EQ */}
|
||||
@@ -320,13 +428,13 @@ export default function Home() {
|
||||
<div className="ios-list-group">
|
||||
<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>}
|
||||
label="输入源"
|
||||
label={homeText.input ?? "输入源"}
|
||||
value={inputLabel}
|
||||
onClick={() => setLocation("/io")}
|
||||
/>
|
||||
<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>}
|
||||
label="输出端口"
|
||||
label={homeText.output ?? "输出端口"}
|
||||
value={outputLabel}
|
||||
onClick={() => setLocation("/io")}
|
||||
/>
|
||||
@@ -340,12 +448,12 @@ export default function Home() {
|
||||
/>
|
||||
<ListRow
|
||||
icon={<Settings size={16} />}
|
||||
label="音频设置"
|
||||
label={homeText.audioSet ?? "音频设置"}
|
||||
onClick={() => setLocation("/audio")}
|
||||
/>
|
||||
<ListRow
|
||||
icon={<Gauge size={16} />}
|
||||
label="VU表"
|
||||
label={homeText.vu ?? "VU表"}
|
||||
value={vuLabel}
|
||||
onClick={() => setLocation("/vu")}
|
||||
/>
|
||||
|
||||
@@ -22,6 +22,9 @@ const KEY_TO_SETTING: Record<string, string> = {
|
||||
language: "language",
|
||||
analogGain: "analogGain",
|
||||
soundStep: "soundStep",
|
||||
filterCharacteristic: "pcm",
|
||||
mutePolar: "hdmimutepolar",
|
||||
IISMode: "hdmiType",
|
||||
xlr: "xlr",
|
||||
dacGain: "dacGain",
|
||||
dacImpedance: "dacImpedance",
|
||||
@@ -45,6 +48,15 @@ export let selectPageState: {
|
||||
key: string;
|
||||
} | 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
|
||||
export function navigateToSelect(title: string, options: string[], selected: number, back: string, key: string) {
|
||||
selectPageState = { title, options, selected, back, key };
|
||||
@@ -75,6 +87,9 @@ export default function SelectPage() {
|
||||
const { title, options, selected: selectedIdx, back, key } = state;
|
||||
|
||||
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
|
||||
const apiField = KEY_TO_SETTING[key];
|
||||
if (apiField) {
|
||||
|
||||
Reference in New Issue
Block a user