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() {