Enhance DeviceContext and LuxsinAPI to support new PEQ apply functionality; update locale files for English and Chinese to include new UI strings for applying and saving PEQ settings. Refactor AudioPage and EQPage to integrate boot sound management and improve state handling.
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
||||
LuxsinAPI,
|
||||
MOCK_DEVICE_STATE,
|
||||
MOCK_PEQ_STATE,
|
||||
PeqApplyPayload,
|
||||
PeqChangePayload,
|
||||
PeqFilter,
|
||||
PeqState,
|
||||
@@ -27,6 +28,7 @@ interface DeviceContextType {
|
||||
updateSetting: (params: Record<string, string | number>) => Promise<void>;
|
||||
updatePeq: (filters: PeqFilter[]) => Promise<void>;
|
||||
upgradePeqChange: (payload: PeqChangePayload) => Promise<void>;
|
||||
upgradePeqApply: (payload: PeqApplyPayload) => Promise<void>;
|
||||
// Optimistic state updaters
|
||||
setVolume: (v: number) => void;
|
||||
setInput: (v: number) => void;
|
||||
@@ -113,6 +115,7 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (isDemoMode) {
|
||||
setDeviceState({ ...MOCK_DEVICE_STATE });
|
||||
setLastUpdated(new Date());
|
||||
return;
|
||||
}
|
||||
@@ -152,15 +155,29 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) {
|
||||
setPeqState({ filters });
|
||||
}, [api, isDemoMode]);
|
||||
|
||||
const applyPeqFiltersToState = useCallback((filters: PeqFilter[]) => {
|
||||
setPeqState((prev) => (prev ? { ...prev, filters } : prev));
|
||||
}, []);
|
||||
|
||||
const upgradePeqChange = useCallback(async (payload: PeqChangePayload) => {
|
||||
if (isDemoMode) {
|
||||
setPeqState((prev) => (prev ? { ...prev, filters: payload.peqChange.filters } : prev));
|
||||
applyPeqFiltersToState(payload.peqChange.filters);
|
||||
return;
|
||||
}
|
||||
if (!api) return;
|
||||
await api.upgradePeqChange(payload);
|
||||
setPeqState((prev) => (prev ? { ...prev, filters: payload.peqChange.filters } : prev));
|
||||
}, [api, isDemoMode]);
|
||||
applyPeqFiltersToState(payload.peqChange.filters);
|
||||
}, [api, isDemoMode, applyPeqFiltersToState]);
|
||||
|
||||
const upgradePeqApply = useCallback(async (payload: PeqApplyPayload) => {
|
||||
if (isDemoMode) {
|
||||
applyPeqFiltersToState(payload.peqApply.filters);
|
||||
return;
|
||||
}
|
||||
if (!api) return;
|
||||
await api.upgradePeqApply(payload);
|
||||
applyPeqFiltersToState(payload.peqApply.filters);
|
||||
}, [api, isDemoMode, applyPeqFiltersToState]);
|
||||
|
||||
// Optimistic setters
|
||||
const setVolume = useCallback((v: number) => {
|
||||
@@ -212,7 +229,7 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) {
|
||||
isConnected, isConnecting, isDemoMode, setDemoMode,
|
||||
deviceState, peqState, lastUpdated, error,
|
||||
connect, disconnect, refresh, api,
|
||||
updateSetting, updatePeq, upgradePeqChange,
|
||||
updateSetting, updatePeq, upgradePeqChange, upgradePeqApply,
|
||||
setVolume, setInput, setOutput, setBalance,
|
||||
}}>
|
||||
{children}
|
||||
|
||||
@@ -58,7 +58,7 @@ export function encodeCustomBase64(data: string): string {
|
||||
// ============================================================
|
||||
export interface DeviceState {
|
||||
device: string;
|
||||
version: string;
|
||||
version: string | number;
|
||||
mac: string;
|
||||
language: number;
|
||||
volume: number;
|
||||
@@ -135,9 +135,8 @@ export interface PeqState {
|
||||
}>;
|
||||
}
|
||||
|
||||
/** POST body for `/dev/info.cgi` — full peq preset update (custom base64 `json` field). */
|
||||
export interface PeqChangePayload {
|
||||
peqChange: {
|
||||
/** Shared PEQ preset fields for device POST bodies. */
|
||||
export interface PeqPresetBody {
|
||||
name: string;
|
||||
filters: PeqFilter[];
|
||||
autoPre?: number;
|
||||
@@ -147,7 +146,16 @@ export interface PeqChangePayload {
|
||||
model?: string;
|
||||
target?: string;
|
||||
form?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** POST body for `/dev/info.cgi` — full peq preset update (custom base64 `json` field). */
|
||||
export interface PeqChangePayload {
|
||||
peqChange: PeqPresetBody;
|
||||
}
|
||||
|
||||
/** POST body for `/dev/info.cgi` — apply A/B comparison curve without saving preset metadata. */
|
||||
export interface PeqApplyPayload {
|
||||
peqApply: PeqPresetBody;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -248,6 +256,15 @@ export class LuxsinAPI {
|
||||
|
||||
/** Full peq preset: name, filters, autoPre, preamp, canDel — same encoding as legacy `upgradePeq`. */
|
||||
async upgradePeqChange(body: PeqChangePayload): Promise<void> {
|
||||
await this.postPeqJson(body);
|
||||
}
|
||||
|
||||
/** Apply current EQ filters (e.g. A/B curve switch) without a full preset save. */
|
||||
async upgradePeqApply(body: PeqApplyPayload): Promise<void> {
|
||||
await this.postPeqJson(body);
|
||||
}
|
||||
|
||||
private async postPeqJson(body: PeqChangePayload | PeqApplyPayload): Promise<void> {
|
||||
const payload = JSON.stringify(body);
|
||||
const encoded = encodeCustomBase64(payload);
|
||||
await fetch(`${this.baseUrl}/dev/info.cgi`, {
|
||||
@@ -295,11 +312,45 @@ export class LuxsinAPI {
|
||||
setCrossfeedEnable(enable: boolean) { return this.setSetting({ crossfeed_enable: enable ? 1 : 0 }); }
|
||||
setXlrPolarity(reverse: boolean) { return this.setSetting({ xlr: reverse ? 1 : 0 }); }
|
||||
setDacArc(earc: boolean) { return this.setSetting({ dacArc: earc ? 1 : 0 }); }
|
||||
setBootSound(on: boolean) { return this.setSetting({ bootSound: on ? 1 : 0 }); }
|
||||
/** Boot volume level: 0 = default, 1..6 = -5..-30 dB, 7..10 = -35..-50 dB (firmware > 26). */
|
||||
setBootSound(level: number) {
|
||||
return this.setSetting({ bootSound: Math.max(0, Math.min(10, Math.round(level))) });
|
||||
}
|
||||
/** Device power off — `GET .../dev/info.cgi?action=setting&power=0` */
|
||||
powerOff() { return this.setSetting({ power: 0 }); }
|
||||
}
|
||||
|
||||
/** Parse firmware `version` for feature gating (e.g. bootSound extended steps). */
|
||||
export function parseFirmwareVersion(version: unknown): number {
|
||||
if (version === null || version === undefined) return 0;
|
||||
if (typeof version === "number" && Number.isFinite(version)) return version;
|
||||
const text = String(version).trim();
|
||||
if (!text) return 0;
|
||||
const direct = Number(text);
|
||||
if (Number.isFinite(direct)) return direct;
|
||||
const parts = text.match(/\d+/g);
|
||||
if (!parts?.length) return 0;
|
||||
return Number(parts[parts.length - 1]);
|
||||
}
|
||||
|
||||
/** Max `bootSound` index available for the current firmware. */
|
||||
export function getBootSoundMaxIndex(firmwareVersion: number): number {
|
||||
return firmwareVersion > 26 ? 10 : 6;
|
||||
}
|
||||
|
||||
/** Normalize `bootSound` from syncData (0 = default … 10 = -50 dB). */
|
||||
export function normalizeBootSound(value: unknown): number {
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
return Math.max(0, Math.min(10, Math.round(n)));
|
||||
}
|
||||
|
||||
export function readBootSoundFromState(state: DeviceState | null | undefined): number {
|
||||
if (!state) return 0;
|
||||
const raw = state.bootSound ?? (state as Record<string, unknown>).bootsound;
|
||||
return normalizeBootSound(raw);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Mock data for demo/offline mode
|
||||
// ============================================================
|
||||
|
||||
@@ -283,9 +283,14 @@
|
||||
"errFilterNumbers": "Invalid numbers for filter {{no}}",
|
||||
"errFilterAll": "Filters 1–10 must all be present",
|
||||
"chartCopyTo": "Copy to {{mode}}",
|
||||
"chartLoadToDevice": "Load to device",
|
||||
"chartApplyB": "Apply B",
|
||||
"chartSaveB": "Save B As",
|
||||
"saveBTitle": "Save B As",
|
||||
"toastChartCopied": "Copied to {{mode}}",
|
||||
"toastChartLoadedDevice": "Loaded to device"
|
||||
"toastApplyBSuccess": "Curve B applied to A",
|
||||
"toastApplyBFail": "Failed to apply B",
|
||||
"toastSaveBOk": "Preset saved from curve B",
|
||||
"toastSaveBFail": "Failed to save curve B"
|
||||
}
|
||||
},
|
||||
"dac": {
|
||||
@@ -341,7 +346,22 @@
|
||||
]
|
||||
},
|
||||
"volumeStep": "Volume step",
|
||||
"bootVolume": "Boot volume",
|
||||
"bootVolume": {
|
||||
"label": "Boot volume",
|
||||
"options": [
|
||||
{ "index": 0, "label": "Default" },
|
||||
{ "index": 1, "label": "-5dB" },
|
||||
{ "index": 2, "label": "-10dB" },
|
||||
{ "index": 3, "label": "-15dB" },
|
||||
{ "index": 4, "label": "-20dB" },
|
||||
{ "index": 5, "label": "-25dB" },
|
||||
{ "index": 6, "label": "-30dB" },
|
||||
{ "index": 7, "label": "-35dB" },
|
||||
{ "index": 8, "label": "-40dB" },
|
||||
{ "index": 9, "label": "-45dB" },
|
||||
{ "index": 10, "label": "-50dB" }
|
||||
]
|
||||
},
|
||||
"xlr": {
|
||||
"label": "XLR port polarity",
|
||||
"options": [
|
||||
|
||||
@@ -219,9 +219,14 @@
|
||||
"errFilterNumbers": "Filter {{no}} 的數值無效",
|
||||
"errFilterAll": "Filter 1–10 必須全部提供",
|
||||
"chartCopyTo": "複製到 {{mode}}",
|
||||
"chartLoadToDevice": "載入至裝置",
|
||||
"chartApplyB": "套用 B",
|
||||
"chartSaveB": "另存 B",
|
||||
"saveBTitle": "另存 B",
|
||||
"toastChartCopied": "已複製到 {{mode}}",
|
||||
"toastChartLoadedDevice": "已載入至裝置"
|
||||
"toastApplyBSuccess": "已將 B 曲線套用到 A",
|
||||
"toastApplyBFail": "套用 B 失敗",
|
||||
"toastSaveBOk": "已另存 B 預設",
|
||||
"toastSaveBFail": "另存 B 失敗"
|
||||
}
|
||||
},
|
||||
"dac": {
|
||||
@@ -250,7 +255,22 @@
|
||||
]
|
||||
},
|
||||
"volumeStep": "音量級距",
|
||||
"bootVolume": "開機音量",
|
||||
"bootVolume": {
|
||||
"label": "開機音量",
|
||||
"options": [
|
||||
{ "index": 0, "label": "預設" },
|
||||
{ "index": 1, "label": "-5dB" },
|
||||
{ "index": 2, "label": "-10dB" },
|
||||
{ "index": 3, "label": "-15dB" },
|
||||
{ "index": 4, "label": "-20dB" },
|
||||
{ "index": 5, "label": "-25dB" },
|
||||
{ "index": 6, "label": "-30dB" },
|
||||
{ "index": 7, "label": "-35dB" },
|
||||
{ "index": 8, "label": "-40dB" },
|
||||
{ "index": 9, "label": "-45dB" },
|
||||
{ "index": 10, "label": "-50dB" }
|
||||
]
|
||||
},
|
||||
"xlr": {
|
||||
"label": "XLR端子極性",
|
||||
"options": [
|
||||
|
||||
@@ -219,9 +219,14 @@
|
||||
"errFilterNumbers": "Filter {{no}} 的数值无效",
|
||||
"errFilterAll": "Filter 1–10 必须全部提供",
|
||||
"chartCopyTo": "复制到 {{mode}}",
|
||||
"chartLoadToDevice": "加载至设备",
|
||||
"chartApplyB": "应用 B",
|
||||
"chartSaveB": "另存 B",
|
||||
"saveBTitle": "另存 B",
|
||||
"toastChartCopied": "已复制到 {{mode}}",
|
||||
"toastChartLoadedDevice": "已加载至设备"
|
||||
"toastApplyBSuccess": "已将 B 曲线应用到 A",
|
||||
"toastApplyBFail": "应用 B 失败",
|
||||
"toastSaveBOk": "已另存 B 预设",
|
||||
"toastSaveBFail": "另存 B 失败"
|
||||
}
|
||||
},
|
||||
"dac": {
|
||||
@@ -250,7 +255,22 @@
|
||||
]
|
||||
},
|
||||
"volumeStep": "音量幅度",
|
||||
"bootVolume": "开机音量",
|
||||
"bootVolume": {
|
||||
"label": "开机音量",
|
||||
"options": [
|
||||
{ "index": 0, "label": "默认" },
|
||||
{ "index": 1, "label": "-5dB" },
|
||||
{ "index": 2, "label": "-10dB" },
|
||||
{ "index": 3, "label": "-15dB" },
|
||||
{ "index": 4, "label": "-20dB" },
|
||||
{ "index": 5, "label": "-25dB" },
|
||||
{ "index": 6, "label": "-30dB" },
|
||||
{ "index": 7, "label": "-35dB" },
|
||||
{ "index": 8, "label": "-40dB" },
|
||||
{ "index": 9, "label": "-45dB" },
|
||||
{ "index": 10, "label": "-50dB" }
|
||||
]
|
||||
},
|
||||
"xlr": {
|
||||
"label": "XLR端口极性",
|
||||
"options": [
|
||||
|
||||
@@ -13,6 +13,12 @@ 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 localeZh from "@/locales/data-zh.json";
|
||||
import localeZhHK from "@/locales/data-zh-HK.json";
|
||||
import localeEn from "@/locales/data-en.json";
|
||||
@@ -83,6 +89,7 @@ type LocaleDac = {
|
||||
filters?: { label?: string; options?: Array<{ index: number; label: string }> };
|
||||
dacGain?: { 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 }> };
|
||||
@@ -104,8 +111,48 @@ function optionLabels(items: Array<{ index: number; label: string }> | undefined
|
||||
|
||||
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 [, setLocation] = useLocation();
|
||||
const [location, setLocation] = useLocation();
|
||||
const { deviceState, updateSetting, refresh, isConnected, api } = useDevice();
|
||||
const ds = deviceState;
|
||||
const vuSensor = (ds as ({ vuSensor?: number } | null))?.vuSensor;
|
||||
@@ -117,6 +164,8 @@ export default function AudioPage() {
|
||||
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);
|
||||
@@ -125,11 +174,20 @@ export default function AudioPage() {
|
||||
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
|
||||
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) {
|
||||
refresh();
|
||||
void refresh();
|
||||
}
|
||||
}, [isConnected, api, refresh]);
|
||||
}, [location, isConnected, api, refresh]);
|
||||
|
||||
const gainIdx = ds?.dacGain ?? 0;
|
||||
const stepIdx = ds?.soundStep ?? 1;
|
||||
@@ -137,6 +195,11 @@ export default function AudioPage() {
|
||||
const xlrIdx = ds?.xlr ?? 0;
|
||||
const mutePolarIdx = (ds as ({ hdmimutepolar?: number } | null))?.hdmimutepolar ?? 0;
|
||||
const iisModeIdx = (ds as ({ hdmiType?: number } | null))?.hdmiType ?? 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, ["快速滚降", "慢速滚降", "短延迟快速滚降", "短延迟慢速滚降", "去重强调", "非过采样(NOS)"]);
|
||||
const GAIN_OPTIONS = optionLabels(dacText.dacGain?.options, ["低", "中", "高"]);
|
||||
@@ -237,6 +300,18 @@ export default function AudioPage() {
|
||||
{/* ── 系统与接口 ── */}
|
||||
<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] ?? "—"}
|
||||
|
||||
+206
-11
@@ -28,7 +28,9 @@ import {
|
||||
type LuxsinAudioModelListItem,
|
||||
type LuxsinAudioModel,
|
||||
type PeqFilter,
|
||||
type PeqApplyPayload,
|
||||
type PeqChangePayload,
|
||||
type PeqPresetBody,
|
||||
} from "@/lib/luxsinApi";
|
||||
import * as echarts from "echarts";
|
||||
import {
|
||||
@@ -115,6 +117,8 @@ function FreqChart({
|
||||
abMode,
|
||||
onAbToggle,
|
||||
onCopyMode,
|
||||
onApplyB,
|
||||
onSaveB,
|
||||
onBandDrag,
|
||||
onBandSelect,
|
||||
eqUi,
|
||||
@@ -125,6 +129,8 @@ function FreqChart({
|
||||
abMode: "A" | "B";
|
||||
onAbToggle: (m: "A" | "B") => void;
|
||||
onCopyMode: (from: "A" | "B", to: "A" | "B") => void;
|
||||
onApplyB: () => void;
|
||||
onSaveB: () => void;
|
||||
onBandDrag: (idx: number, patch: Partial<{ freq: number; gain: number }>) => void;
|
||||
onBandSelect: (idx: number) => void;
|
||||
eqUi: PeqEqUi;
|
||||
@@ -321,7 +327,8 @@ function FreqChart({
|
||||
</div>
|
||||
|
||||
{/* A/B + DIFF controls */}
|
||||
<div className="flex items-center gap-2 mb-2 px-1">
|
||||
<div className="flex w-full items-center gap-2 mb-2 px-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{/* A/B toggle pill */}
|
||||
<div className="flex items-center rounded-[8px] overflow-hidden"
|
||||
style={{ background: "rgba(44,44,46,0.7)", border: "1px solid rgba(255,255,255,0.08)" }}>
|
||||
@@ -350,13 +357,25 @@ function FreqChart({
|
||||
}}>
|
||||
{copyButtonText}
|
||||
</button>
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="px-3 py-1 rounded-[8px] text-[11px] text-white/50 active:text-white transition-colors"
|
||||
style={{ background: "rgba(44,44,46,0.7)", border: "1px solid rgba(255,255,255,0.08)" }}
|
||||
onClick={() => toast.info(eqUi.toastChartLoadedDevice)}
|
||||
onClick={() => onApplyB()}
|
||||
>
|
||||
{eqUi.chartLoadToDevice}
|
||||
{eqUi.chartApplyB}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="px-3 py-1 rounded-[8px] text-[11px] text-white/50 active:text-white transition-colors"
|
||||
style={{ background: "rgba(44,44,46,0.7)", border: "1px solid rgba(255,255,255,0.08)" }}
|
||||
onClick={() => onSaveB()}
|
||||
>
|
||||
{eqUi.chartSaveB}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SVG chart */}
|
||||
@@ -573,7 +592,7 @@ function bandToPeqFilter(b: { freq: number; gain: number; q: number; type: strin
|
||||
/* ── Main Component ── */
|
||||
export default function EQPage() {
|
||||
const [, setLocation] = useLocation();
|
||||
const { deviceState, updateSetting, api, isDemoMode, upgradePeqChange } = useDevice();
|
||||
const { deviceState, updateSetting, api, isDemoMode, upgradePeqChange, upgradePeqApply } = useDevice();
|
||||
const eqOn = (deviceState?.peqEnable ?? 0) === 1;
|
||||
|
||||
const eqUi = useMemo((): PeqEqUi => {
|
||||
@@ -617,6 +636,8 @@ export default function EQPage() {
|
||||
const [addPresetMode, setAddPresetMode] = useState<"copy" | "flat">("copy");
|
||||
const [copyPresetName, setCopyPresetName] = useState("");
|
||||
const [flatPresetName, setFlatPresetName] = useState("");
|
||||
const [isSaveBDialogOpen, setIsSaveBDialogOpen] = useState(false);
|
||||
const [saveBPresetName, setSaveBPresetName] = useState("");
|
||||
const [isBatchEditDialogOpen, setIsBatchEditDialogOpen] = useState(false);
|
||||
const [batchEditText, setBatchEditText] = useState("");
|
||||
type BrandDrawerTab = "brands" | "models" | "target";
|
||||
@@ -642,6 +663,7 @@ export default function EQPage() {
|
||||
const allowPeqRemoteSyncRef = useRef(false);
|
||||
const syncingHeadphoneRef = useRef(false);
|
||||
const peqSyncTimerRef = useRef<number | null>(null);
|
||||
const skipPeqAutoSyncRef = useRef(false);
|
||||
const headphoneMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const filterMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const bands = bandsByMode[abMode];
|
||||
@@ -675,6 +697,7 @@ export default function EQPage() {
|
||||
setBandsByMode((prev) => ({ ...prev, [to]: cloneBands(prev[from]) }));
|
||||
setSelectedBandByMode((prev) => ({ ...prev, [to]: prev[from] }));
|
||||
}, []);
|
||||
|
||||
const currentPeq = peqItems[headphoneIdx];
|
||||
const preampValue = Number(currentPeq?.preamp ?? 0);
|
||||
const autoPreOn = (currentPeq?.autoPre ?? 0) === 1;
|
||||
@@ -995,6 +1018,13 @@ export default function EQPage() {
|
||||
setIsAddPresetDialogOpen(true);
|
||||
};
|
||||
|
||||
const openSaveBDialog = () => {
|
||||
const currentName = peqItems[headphoneIdx]?.name ?? headphoneModels[headphoneIdx] ?? "Preset";
|
||||
const defaultName = getUniquePresetName(`${currentName}_B`, headphoneModels);
|
||||
setSaveBPresetName(defaultName);
|
||||
setIsSaveBDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveAddPreset = async () => {
|
||||
const nextName = addPresetMode === "copy" ? copyPresetName.trim() : flatPresetName.trim();
|
||||
if (!nextName) {
|
||||
@@ -1062,6 +1092,60 @@ export default function EQPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveBAsPreset = async () => {
|
||||
const nextName = saveBPresetName.trim();
|
||||
if (!nextName) {
|
||||
toast.error(eqUi.addPresetNameEmpty);
|
||||
return;
|
||||
}
|
||||
if (headphoneModels.includes(nextName)) {
|
||||
toast.error(eqUi.addPresetNameExists);
|
||||
return;
|
||||
}
|
||||
|
||||
const bBands = cloneBands(bandsByMode.B);
|
||||
const filters = bBands.map(bandToPeqFilter);
|
||||
const payload: PeqChangePayload = {
|
||||
peqChange: {
|
||||
name: nextName,
|
||||
filters,
|
||||
autoPre: currentPeq?.autoPre ?? 0,
|
||||
preamp: currentPeq?.preamp ?? 0,
|
||||
canDel: currentPeq?.canDel ?? 1,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await upgradePeqChange(payload);
|
||||
if (api && !isDemoMode) {
|
||||
const latest = await api.getPeqState();
|
||||
applyPeqStateToUI(latest as { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number });
|
||||
const createdIndex = latest.peq?.findIndex((item) => item.name === nextName) ?? -1;
|
||||
if (createdIndex >= 0) {
|
||||
setHeadphoneIdx(createdIndex);
|
||||
updateSetting({ peqSelect: createdIndex });
|
||||
}
|
||||
} else {
|
||||
setHeadphoneModels((prev) => [...prev, nextName]);
|
||||
setPeqItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
name: nextName,
|
||||
filters,
|
||||
autoPre: currentPeq?.autoPre ?? 0,
|
||||
preamp: currentPeq?.preamp ?? 0,
|
||||
canDel: currentPeq?.canDel ?? 1,
|
||||
},
|
||||
]);
|
||||
setHeadphoneIdx(headphoneModels.length);
|
||||
}
|
||||
setIsSaveBDialogOpen(false);
|
||||
toast.success(eqUi.toastSaveBOk);
|
||||
} catch {
|
||||
toast.error(eqUi.toastSaveBFail);
|
||||
}
|
||||
};
|
||||
|
||||
// 同步 peqSelect 变化
|
||||
useEffect(() => {
|
||||
if (deviceState?.peqSelect !== undefined) {
|
||||
@@ -1261,7 +1345,7 @@ export default function EQPage() {
|
||||
const schedulePeqSync = (
|
||||
peq: { name: string; autoPre?: number; preamp?: number; canDel?: number } | undefined,
|
||||
filtersSource: typeof bands,
|
||||
delay = 320
|
||||
delay = 320,
|
||||
) => {
|
||||
if (!allowPeqRemoteSyncRef.current) return;
|
||||
if (!isDemoMode && !api) return;
|
||||
@@ -1271,33 +1355,40 @@ export default function EQPage() {
|
||||
window.clearTimeout(peqSyncTimerRef.current);
|
||||
}
|
||||
|
||||
const useApply = abMode === "B";
|
||||
|
||||
peqSyncTimerRef.current = window.setTimeout(() => {
|
||||
const filters = filtersSource.map(bandToPeqFilter);
|
||||
const payload: PeqChangePayload = {
|
||||
peqChange: {
|
||||
const body: PeqPresetBody = {
|
||||
name: peq.name,
|
||||
filters,
|
||||
autoPre: peq.autoPre ?? 0,
|
||||
preamp: peq.preamp ?? 0,
|
||||
canDel: peq.canDel ?? 1,
|
||||
},
|
||||
};
|
||||
upgradePeqChange(payload).catch((err) => {
|
||||
const request = useApply
|
||||
? upgradePeqApply({ peqApply: body } satisfies PeqApplyPayload)
|
||||
: upgradePeqChange({ peqChange: body } satisfies PeqChangePayload);
|
||||
request.catch(() => {
|
||||
toast.error("EQ 保存失败");
|
||||
});
|
||||
}, delay);
|
||||
};
|
||||
|
||||
// 参数变更(含拖动曲线)→ POST peqChange(防抖)
|
||||
// A 模式编辑 → peqChange;B 模式编辑(含切换后展示)→ peqApply(防抖)
|
||||
useEffect(() => {
|
||||
if (syncingHeadphoneRef.current) return;
|
||||
if (skipPeqAutoSyncRef.current) {
|
||||
skipPeqAutoSyncRef.current = false;
|
||||
return;
|
||||
}
|
||||
schedulePeqSync(peqItems[headphoneIdx], bands, 320);
|
||||
return () => {
|
||||
if (peqSyncTimerRef.current !== null) {
|
||||
window.clearTimeout(peqSyncTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [bands, headphoneIdx, peqItems, api, isDemoMode, upgradePeqChange]);
|
||||
}, [bands, abMode, headphoneIdx, peqItems, api, isDemoMode, upgradePeqChange, upgradePeqApply]);
|
||||
|
||||
useEffect(() => {
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
@@ -1395,6 +1486,58 @@ export default function EQPage() {
|
||||
myChart.setOption(ops, true);
|
||||
};
|
||||
|
||||
const handleApplyB = useCallback(async () => {
|
||||
const peq = peqItems[headphoneIdx];
|
||||
if (!peq?.name) {
|
||||
toast.error(eqUi.toastApplyBFail);
|
||||
return;
|
||||
}
|
||||
|
||||
const bBands = cloneBands(bandsByMode.B);
|
||||
const filters = bBands.map(bandToPeqFilter);
|
||||
const payload: PeqChangePayload = {
|
||||
peqChange: {
|
||||
name: peq.name,
|
||||
filters,
|
||||
autoPre: peq.autoPre ?? 0,
|
||||
preamp: peq.preamp ?? 0,
|
||||
canDel: peq.canDel ?? 1,
|
||||
},
|
||||
};
|
||||
|
||||
skipPeqAutoSyncRef.current = true;
|
||||
setBandsByMode((prev) => ({ ...prev, A: cloneBands(bBands) }));
|
||||
setSelectedBandByMode((prev) => ({ ...prev, A: prev.B }));
|
||||
setPeqItems((prev) =>
|
||||
prev.map((item, i) => (i !== headphoneIdx ? item : { ...item, filters })),
|
||||
);
|
||||
setAbMode("A");
|
||||
|
||||
if (peqSyncTimerRef.current !== null) {
|
||||
window.clearTimeout(peqSyncTimerRef.current);
|
||||
peqSyncTimerRef.current = null;
|
||||
}
|
||||
|
||||
try {
|
||||
await upgradePeqChange(payload);
|
||||
if (peqItems.length > 0 && headphoneIdx < peqItems.length) {
|
||||
renderCharts(bBands, currentRawCurve, false);
|
||||
}
|
||||
toast.success(eqUi.toastApplyBSuccess);
|
||||
} catch {
|
||||
skipPeqAutoSyncRef.current = false;
|
||||
toast.error(eqUi.toastApplyBFail);
|
||||
}
|
||||
}, [
|
||||
bandsByMode.B,
|
||||
currentRawCurve,
|
||||
eqUi.toastApplyBFail,
|
||||
eqUi.toastApplyBSuccess,
|
||||
headphoneIdx,
|
||||
peqItems,
|
||||
upgradePeqChange,
|
||||
]);
|
||||
|
||||
const updateBand = (idx: number, patch: Partial<typeof bands[0]>) => {
|
||||
const nextBands = bands.map((b, i) => (i === idx ? { ...b, ...patch } : b));
|
||||
setBands(nextBands);
|
||||
@@ -1540,6 +1683,8 @@ export default function EQPage() {
|
||||
abMode={abMode}
|
||||
onAbToggle={setAbMode}
|
||||
onCopyMode={copyModeParams}
|
||||
onApplyB={() => void handleApplyB()}
|
||||
onSaveB={openSaveBDialog}
|
||||
onBandDrag={(idx, patch) => setBands((prev) => prev.map((b, i) => i === idx ? { ...b, ...patch } : b))}
|
||||
onBandSelect={setSelectedBand}
|
||||
eqUi={eqUi}
|
||||
@@ -1696,6 +1841,56 @@ export default function EQPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSaveBDialogOpen && (
|
||||
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/65 px-4">
|
||||
<div
|
||||
className="w-full max-w-[420px] rounded-[14px] p-5"
|
||||
style={{
|
||||
background: "linear-gradient(180deg, rgba(34,36,40,0.98) 0%, rgba(24,26,30,0.98) 100%)",
|
||||
border: "1px solid rgba(255,255,255,0.12)",
|
||||
boxShadow: "0 18px 48px rgba(0,0,0,0.55)",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-[18px] font-semibold leading-tight text-white/90">{eqUi.saveBTitle}</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-1.5 text-white/45 transition-colors hover:bg-white/10 hover:text-white/80"
|
||||
onClick={() => setIsSaveBDialogOpen(false)}
|
||||
aria-label={eqUi.closeDrawer}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
value={saveBPresetName}
|
||||
onChange={(e) => setSaveBPresetName(e.target.value)}
|
||||
className="h-11 w-full rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[15px] text-white/90 outline-none placeholder:text-white/30 focus:border-white/15"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<div className="mt-6 flex items-center justify-center gap-4 sm:gap-8">
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-[112px] rounded-full px-5 py-2.5 text-[15px] font-medium text-white/85 transition-colors bg-[#3f4349] hover:bg-[#4a4f56] active:scale-[0.98]"
|
||||
onClick={() => setIsSaveBDialogOpen(false)}
|
||||
>
|
||||
{eqUi.cancel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-[112px] rounded-full px-5 py-2.5 text-[15px] font-semibold text-black transition-all bg-[#00FFF6] hover:brightness-95 active:scale-[0.98]"
|
||||
onClick={() => void handleSaveBAsPreset()}
|
||||
>
|
||||
{eqUi.save}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAddPresetDialogOpen && (
|
||||
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/65 px-4">
|
||||
<div
|
||||
|
||||
@@ -22,6 +22,7 @@ const KEY_TO_SETTING: Record<string, string> = {
|
||||
language: "language",
|
||||
analogGain: "analogGain",
|
||||
soundStep: "soundStep",
|
||||
bootSound: "bootSound",
|
||||
filterCharacteristic: "pcm",
|
||||
mutePolar: "hdmimutepolar",
|
||||
IISMode: "hdmiType",
|
||||
@@ -63,7 +64,7 @@ export function navigateToSelect(title: string, options: string[], selected: num
|
||||
}
|
||||
|
||||
export default function SelectPage() {
|
||||
const [, setLocation] = useLocation();
|
||||
const [location, setLocation] = useLocation();
|
||||
const { updateSetting } = useDevice();
|
||||
const [state, setState] = useState<{
|
||||
title: string;
|
||||
@@ -71,14 +72,15 @@ export default function SelectPage() {
|
||||
selected: number;
|
||||
back: string;
|
||||
key: string;
|
||||
} | null>(selectPageState);
|
||||
} | null>(() => selectPageState);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Clear state when component unmounts
|
||||
// Re-read global state whenever we enter /select (avoids stale key/options after remount)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
selectPageState = null;
|
||||
};
|
||||
}, []);
|
||||
if (location === "/select" && selectPageState) {
|
||||
setState({ ...selectPageState });
|
||||
}
|
||||
}, [location]);
|
||||
|
||||
if (!state) {
|
||||
return null;
|
||||
@@ -86,16 +88,20 @@ 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)
|
||||
const handleSelect = async (idx: number) => {
|
||||
if (saving) return;
|
||||
selectPageResult = { key, selected: idx };
|
||||
|
||||
// Apply setting if key maps to an API field
|
||||
const apiField = KEY_TO_SETTING[key];
|
||||
if (apiField) {
|
||||
updateSetting({ [apiField]: idx } as Parameters<typeof updateSetting>[0]);
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateSetting({ [apiField]: idx });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
// Navigate back
|
||||
}
|
||||
selectPageState = null;
|
||||
setLocation(back);
|
||||
};
|
||||
|
||||
@@ -121,7 +127,7 @@ export default function SelectPage() {
|
||||
<button
|
||||
key={idx}
|
||||
className="ios-list-row w-full text-left active:bg-white/5 transition-colors"
|
||||
onClick={() => handleSelect(idx)}>
|
||||
onClick={() => void handleSelect(idx)}>
|
||||
<span className={`flex-1 text-[16px] ${isSelected ? "text-white font-medium" : "text-white/75"}`}>
|
||||
{opt}
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user