From ba5f5d074adeef1a21c8a7212c8b6d73c2707cc0 Mon Sep 17 00:00:00 2001 From: yangy Date: Mon, 18 May 2026 13:42:29 +0800 Subject: [PATCH] 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. --- client/src/contexts/DeviceContext.tsx | 25 ++- client/src/lib/luxsinApi.ts | 77 +++++++-- client/src/locales/data-en.json | 26 ++- client/src/locales/data-zh-HK.json | 26 ++- client/src/locales/data-zh.json | 26 ++- client/src/pages/AudioPage.tsx | 83 +++++++++- client/src/pages/EQPage.tsx | 227 ++++++++++++++++++++++++-- client/src/pages/SelectPage.tsx | 32 ++-- 8 files changed, 463 insertions(+), 59 deletions(-) diff --git a/client/src/contexts/DeviceContext.tsx b/client/src/contexts/DeviceContext.tsx index d6ae6c7..1c410bd 100644 --- a/client/src/contexts/DeviceContext.tsx +++ b/client/src/contexts/DeviceContext.tsx @@ -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) => Promise; updatePeq: (filters: PeqFilter[]) => Promise; upgradePeqChange: (payload: PeqChangePayload) => Promise; + upgradePeqApply: (payload: PeqApplyPayload) => Promise; // 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} diff --git a/client/src/lib/luxsinApi.ts b/client/src/lib/luxsinApi.ts index e0cb6b2..25996cc 100644 --- a/client/src/lib/luxsinApi.ts +++ b/client/src/lib/luxsinApi.ts @@ -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,19 +135,27 @@ export interface PeqState { }>; } +/** Shared PEQ preset fields for device POST bodies. */ +export interface PeqPresetBody { + name: string; + filters: PeqFilter[]; + autoPre?: number; + preamp?: number; + canDel?: number; + brand?: string; + model?: string; + target?: string; + form?: 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; - brand?: string; - model?: string; - target?: string; - form?: string; - }; + 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 { + await this.postPeqJson(body); + } + + /** Apply current EQ filters (e.g. A/B curve switch) without a full preset save. */ + async upgradePeqApply(body: PeqApplyPayload): Promise { + await this.postPeqJson(body); + } + + private async postPeqJson(body: PeqChangePayload | PeqApplyPayload): Promise { 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).bootsound; + return normalizeBootSound(raw); +} + // ============================================================ // Mock data for demo/offline mode // ============================================================ diff --git a/client/src/locales/data-en.json b/client/src/locales/data-en.json index 3b5f06f..07a21e0 100644 --- a/client/src/locales/data-en.json +++ b/client/src/locales/data-en.json @@ -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": [ diff --git a/client/src/locales/data-zh-HK.json b/client/src/locales/data-zh-HK.json index d474c28..0f55367 100644 --- a/client/src/locales/data-zh-HK.json +++ b/client/src/locales/data-zh-HK.json @@ -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": [ diff --git a/client/src/locales/data-zh.json b/client/src/locales/data-zh.json index f99f1be..7c56be4 100644 --- a/client/src/locales/data-zh.json +++ b/client/src/locales/data-zh.json @@ -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": [ diff --git a/client/src/pages/AudioPage.tsx b/client/src/pages/AudioPage.tsx index 392ecaf..f4708f4 100644 --- a/client/src/pages/AudioPage.tsx +++ b/client/src/pages/AudioPage.tsx @@ -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() { {/* ── 系统与接口 ── */}
+ + goSelect( + bootVolumeRowLabel(dacText), + BOOT_VOLUME_OPTIONS, + bootSoundSelectIdx, + "bootSound", + ) + } + /> 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({
{/* A/B + DIFF controls */} -
+
+
{/* A/B toggle pill */}
@@ -350,13 +357,25 @@ function FreqChart({ }}> {copyButtonText} +
+
+ +
{/* 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(null); + const skipPeqAutoSyncRef = useRef(false); const headphoneMenuRef = useRef(null); const filterMenuRef = useRef(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: { - name: peq.name, - filters, - autoPre: peq.autoPre ?? 0, - preamp: peq.preamp ?? 0, - canDel: peq.canDel ?? 1, - }, + 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) => { 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() {
+ {isSaveBDialogOpen && ( +
+
e.stopPropagation()} + > +
+

{eqUi.saveBTitle}

+ +
+ + 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 + /> + +
+ + +
+
+
+ )} + {isAddPresetDialogOpen && (
= { 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[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() {