diff --git a/client/src/contexts/DeviceContext.tsx b/client/src/contexts/DeviceContext.tsx index b272ae8..c46675c 100644 --- a/client/src/contexts/DeviceContext.tsx +++ b/client/src/contexts/DeviceContext.tsx @@ -196,7 +196,7 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) { applyPeqFiltersToState(payload.peqChange.filters); return; } - if (!api) return; + if (!api) throw new Error("EQ unavailable"); await api.upgradePeqChange(payload); applyPeqFiltersToState(payload.peqChange.filters); }, [api, isDemoMode, applyPeqFiltersToState]); @@ -207,7 +207,7 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) { applyPeqFiltersToState(payload.peqApply.filters); return; } - if (!api) return; + if (!api) throw new Error("EQ unavailable"); await api.upgradePeqApply(payload); applyPeqFiltersToState(payload.peqApply.filters); }, [api, isDemoMode, applyPeqFiltersToState]); diff --git a/client/src/lib/luxsinApi.ts b/client/src/lib/luxsinApi.ts index 0068a75..4508650 100644 --- a/client/src/lib/luxsinApi.ts +++ b/client/src/lib/luxsinApi.ts @@ -292,6 +292,14 @@ function normalizeDeviceHost(input: string): string { // ============================================================ export class LuxsinAPI { private baseUrl: string; + private peqRequestQueue: Promise = Promise.resolve(); + + /** EQ 写入、试听、选择和目录读取按调用顺序执行;单次失败不阻塞后续重试。 */ + private enqueuePeqRequest(request: () => Promise): Promise { + const result = this.peqRequestQueue.then(request); + this.peqRequestQueue = result.then(() => undefined, () => undefined); + return result; + } constructor(ip: string) { const host = normalizeDeviceHost(ip); @@ -336,17 +344,25 @@ export class LuxsinAPI { } async setSetting(params: Record): Promise { - const query = Object.entries(params) - .map(([k, v]) => `${k}=${v}`) - .join("&"); - await fetch(`${this.baseUrl}/dev/info.cgi?action=setting&${query}`); + const request = async () => { + const query = Object.entries(params) + .map(([k, v]) => `${k}=${v}`) + .join("&"); + const response = await fetch(`${this.baseUrl}/dev/info.cgi?action=setting&${query}`); + if (!response.ok) throw new Error(`PEQ request failed: ${response.status}`); + }; + if (params.peqSelect !== undefined) return this.enqueuePeqRequest(request); + return request(); } async getPeqState(): Promise { - const response = await fetch(`${this.baseUrl}/dev/info.cgi?action=syncPeq`); - const text = await response.text(); - const decoded = decodeCustomBase64(text.trim()); - return JSON.parse(decoded) as PeqState; + return this.enqueuePeqRequest(async () => { + const response = await fetch(`${this.baseUrl}/dev/info.cgi?action=syncPeq`); + if (!response.ok) throw new Error(`PEQ read failed: ${response.status}`); + const text = await response.text(); + const decoded = decodeCustomBase64(text.trim()); + return JSON.parse(decoded) as PeqState; + }); } async setPeqFilters(filters: PeqFilter[]): Promise { @@ -365,51 +381,63 @@ export class LuxsinAPI { /** POST `json=` — matches legacy axios `upgradePeq`. */ private async postPeqJson(body: PeqChangePayload | PeqApplyPayload): Promise { - const encoded = encodeCustomBase64(JSON.stringify(body)); - const form = new URLSearchParams(); - form.set("json", encoded); - await fetch(`${this.baseUrl}/dev/info.cgi`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" }, - body: form.toString(), + return this.enqueuePeqRequest(async () => { + const encoded = encodeCustomBase64(JSON.stringify(body)); + const form = new URLSearchParams(); + form.set("json", encoded); + const response = await fetch(`${this.baseUrl}/dev/info.cgi`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" }, + body: form.toString(), + }); + if (!response.ok) throw new Error(`PEQ request failed: ${response.status}`); }); } /** Remove one or more headphone PEQ profiles. */ async removePeq(names: string[]): Promise { - const encoded = encodeCustomBase64(JSON.stringify({ peqRemove: names })); - const form = new URLSearchParams(); - form.set("json", encoded); - await fetch(`${this.baseUrl}/dev/info.cgi`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" }, - body: form.toString(), + return this.enqueuePeqRequest(async () => { + const encoded = encodeCustomBase64(JSON.stringify({ peqRemove: names })); + const form = new URLSearchParams(); + form.set("json", encoded); + const response = await fetch(`${this.baseUrl}/dev/info.cgi`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" }, + body: form.toString(), + }); + if (!response.ok) throw new Error(`PEQ request failed: ${response.status}`); }); } /** Rename a headphone PEQ profile (firmware build 29+). */ async renamePeq(oldName: string, newName: string): Promise { - const payload: PeqRenamePayload = { peqRename: { oldName, newName } }; - const encoded = encodeCustomBase64(JSON.stringify(payload)); - const form = new URLSearchParams(); - form.set("json", encoded); - await fetch(`${this.baseUrl}/dev/info.cgi`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" }, - body: form.toString(), + return this.enqueuePeqRequest(async () => { + const payload: PeqRenamePayload = { peqRename: { oldName, newName } }; + const encoded = encodeCustomBase64(JSON.stringify(payload)); + const form = new URLSearchParams(); + form.set("json", encoded); + const response = await fetch(`${this.baseUrl}/dev/info.cgi`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" }, + body: form.toString(), + }); + if (!response.ok) throw new Error(`PEQ request failed: ${response.status}`); }); } /** Reorder headphone PEQ profiles (firmware build 29+). */ async sortPeq(names: string[], peqSelect: number): Promise { - const payload: PeqSoftPayload = { peqSoft: names, peqSelect }; - const encoded = encodeCustomBase64(JSON.stringify(payload)); - const form = new URLSearchParams(); - form.set("json", encoded); - await fetch(`${this.baseUrl}/dev/info.cgi`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" }, - body: form.toString(), + return this.enqueuePeqRequest(async () => { + const payload: PeqSoftPayload = { peqSoft: names, peqSelect }; + const encoded = encodeCustomBase64(JSON.stringify(payload)); + const form = new URLSearchParams(); + form.set("json", encoded); + const response = await fetch(`${this.baseUrl}/dev/info.cgi`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" }, + body: form.toString(), + }); + if (!response.ok) throw new Error(`PEQ request failed: ${response.status}`); }); } diff --git a/client/src/pages/EQPage.tsx b/client/src/pages/EQPage.tsx index fda6465..56f6377 100644 --- a/client/src/pages/EQPage.tsx +++ b/client/src/pages/EQPage.tsx @@ -165,6 +165,8 @@ export default function EQPage() { const bandSliderDragRef = useRef(null); const [isBatchEditDialogOpen, setIsBatchEditDialogOpen] = useState(false); const [batchEditText, setBatchEditText] = useState(""); + const [batchSaving, setBatchSaving] = useState(false); + const batchSavingRef = useRef(false); const [isBrandDrawerOpen, setIsBrandDrawerOpen] = useState(false); const [brandDrawerKey, setBrandDrawerKey] = useState(0); const [isShareDialogOpen, setIsShareDialogOpen] = useState(false); @@ -194,40 +196,57 @@ export default function EQPage() { commitGesture, undo: popPeqUndo, } = usePeqUndoHistory(); - const allowPeqRemoteSyncRef = useRef(false); - const peqHydrationPendingRef = useRef(true); const lastPeqCatalogSyncKeyRef = useRef(""); - const syncingHeadphoneRef = useRef(false); const peqSyncTimerRef = useRef(null); - const skipPeqAutoSyncRef = useRef(false); - /** A/B 切换已单独下发,避免 bands 变更触发的 effect 用错 peqChange/peqApply */ - const skipBandsSyncFromAbToggleRef = useRef(false); /** 本地编辑过的预设(filters / preamp / autoPre);syncPeq 拉取不会覆盖 */ const peqPresetCacheRef = useRef>({}); + /** 最近一次防抖调度但尚未发送的同步请求;persist=true 表示 peqChange(需可靠送达,可被 flush 补发) */ + const pendingPeqSyncRef = useRef<{ + name: string; + persist: boolean; + run: () => Promise; + } | null>(null); + const inFlightPeqSyncRef = useRef>(Promise.resolve()); /** Anchor UI selection by preset name so poll/catalog reorder won't mis-switch headphones. */ const selectedPeqNameRef = useRef(""); + + /** 取消未发送的防抖同步(内容已被更新的操作整体取代时使用) */ + const cancelPendingPeqSync = useCallback(() => { + pendingPeqSyncRef.current = null; + if (peqSyncTimerRef.current !== null) { + window.clearTimeout(peqSyncTimerRef.current); + peqSyncTimerRef.current = null; + } + }, []); + + /** 立即补发未发送的 peqChange(持久化);peqApply 类试听同步直接丢弃,避免切换后错写新预设 */ + const flushPendingPeqSync = useCallback(() => { + const pending = pendingPeqSyncRef.current; + cancelPendingPeqSync(); + if (pending?.persist) return pending.run(); + return inFlightPeqSyncRef.current; + }, [cancelPendingPeqSync]); + + useEffect(() => () => { + // SPA 离开页面时提交 A 的尾次编辑,取消未发送的 B 试听。 + void flushPendingPeqSync().catch(() => toast.error("EQ 保存失败")); + }, [flushPendingPeqSync]); const devicePeqSelectRef = useRef(deviceState?.peqSelect); devicePeqSelectRef.current = deviceState?.peqSelect; const headphoneMenuRef = useRef(null); const filterMenuRef = useRef(null); const bands = bandsByMode[abMode]; const selectedBand = selectedBandByMode[abMode]; + const bandsByModeRef = useRef(bandsByMode); + bandsByModeRef.current = bandsByMode; + const [bPeqMeta, setBPeqMeta] = useState({ preamp: 0, autoPre: 0 }); + const bPeqMetaRef = useRef(bPeqMeta); + bPeqMetaRef.current = bPeqMeta; const setBandsForBothModes = useCallback((nextBands: typeof DEFAULT_BANDS) => { - const cloned = cloneBands(nextBands); - setBandsByMode({ A: cloneBands(cloned), B: cloneBands(cloned) }); + const next = { A: cloneBands(nextBands), B: cloneBands(nextBands) }; + bandsByModeRef.current = next; + setBandsByMode(next); }, []); - const setBands = useCallback( - (updater: typeof DEFAULT_BANDS | ((prev: typeof DEFAULT_BANDS) => typeof DEFAULT_BANDS)) => { - setBandsByMode((prev) => ({ - ...prev, - [abMode]: - typeof updater === "function" - ? (updater as (prev: typeof DEFAULT_BANDS) => typeof DEFAULT_BANDS)(prev[abMode]) - : cloneBands(updater), - })); - }, - [abMode], - ); const setSelectedBand = useCallback( (updater: number | ((prev: number) => number)) => { setSelectedBandByMode((prev) => ({ @@ -238,28 +257,34 @@ export default function EQPage() { [abMode], ); const copyModeParams = useCallback((from: "A" | "B", to: "A" | "B") => { - setBandsByMode((prev) => ({ ...prev, [to]: cloneBands(prev[from]) })); + const next = { ...bandsByModeRef.current, [to]: cloneBands(bandsByModeRef.current[from]) }; + bandsByModeRef.current = next; + setBandsByMode(next); setSelectedBandByMode((prev) => ({ ...prev, [to]: prev[from] })); }, []); const currentPeq = peqItems[headphoneIdx]; - const currentPeqName = currentPeq?.name ?? ""; const currentPeqPreamp = currentPeq?.preamp ?? 0; const currentPeqAutoPre = currentPeq?.autoPre ?? 0; - const preampValue = Number(currentPeqPreamp); - const autoPreOn = currentPeqAutoPre === 1; + const activePeq = currentPeq && (abMode === "B" ? { ...currentPeq, ...bPeqMeta } : currentPeq); + const activePeqRef = useRef(activePeq); + activePeqRef.current = activePeq; + const preampValue = Number(activePeq?.preamp ?? 0); + const autoPreOn = (activePeq?.autoPre ?? 0) === 1; const undoSnapshotSourceRef = useRef({ abMode, bandsByMode, preamp: preampValue, autoPre: autoPreOn ? 1 : 0, + metaByMode: { A: { preamp: currentPeqPreamp, autoPre: currentPeqAutoPre }, B: { ...bPeqMeta } }, }); undoSnapshotSourceRef.current = { abMode, bandsByMode, preamp: preampValue, autoPre: autoPreOn ? 1 : 0, + metaByMode: { A: { preamp: currentPeqPreamp, autoPre: currentPeqAutoPre }, B: { ...bPeqMeta } }, }; const takeUndoSnapshot = useCallback((): PeqUndoSnapshot => { @@ -272,6 +297,7 @@ export default function EQPage() { }, preamp: src.preamp, autoPre: src.autoPre, + metaByMode: { A: { ...src.metaByMode.A }, B: { ...src.metaByMode.B } }, }; }, []); @@ -341,7 +367,7 @@ export default function EQPage() { const toCompactNumber = (value: number) => Number(value.toFixed(3)).toString(); const buildBatchEditText = useCallback(() => { - const preamp = Number(currentPeq?.preamp ?? 0); + const preamp = preampValue; const source = bands.length > 0 ? bands : DEFAULT_BANDS; const lines = [`Preamp:${toCompactNumber(preamp)}dB`]; for (let i = 0; i < 10; i++) { @@ -351,7 +377,7 @@ export default function EQPage() { ); } return lines.join("\n"); - }, [bands, currentPeq?.preamp]); + }, [bands, preampValue]); const openBatchEditDialog = () => { setBatchEditText(buildBatchEditText()); @@ -437,33 +463,38 @@ export default function EQPage() { [eqUi], ); - const handleSaveBatchEdit = () => { + const handleSaveBatchEdit = async () => { + if (batchSavingRef.current) return; + let parsed: ReturnType; try { - const parsed = parseBatchEditText(batchEditText); - captureBefore(takeUndoSnapshot()); - let updatedPeq: { name: string; autoPre?: number; preamp?: number; canDel?: number } | undefined; - setPeqItems((prev) => - prev.map((item, i) => { - if (i !== headphoneIdx) return item; - const filters = parsed.filters.map(bandToPeqFilter); - const merged = { - ...item, - autoPre: 0, - preamp: parsed.preamp, - filters, - }; - rememberPeqPresetCache(item.name, { filters, preamp: parsed.preamp, autoPre: 0 }); - updatedPeq = merged; - return merged; - }) - ); - setBands(parsed.filters); - schedulePeqSync(updatedPeq, parsed.filters, abMode, 0, "byMode", headphoneIdx); - setSelectedBand(0); - setIsBatchEditDialogOpen(false); - toast.success(eqUi.toastBatchApplied); + parsed = parseBatchEditText(batchEditText); } catch (error) { toast.error(error instanceof Error ? error.message : eqUi.toastBatchParseError); + return; + } + const currentItem = activePeqRef.current; + if (!currentItem?.name) return; + batchSavingRef.current = true; + setBatchSaving(true); + captureBefore(takeUndoSnapshot()); + const merged = { ...currentItem, autoPre: 0, preamp: parsed.preamp }; + if (abMode === "B") { + bPeqMetaRef.current = { autoPre: 0, preamp: parsed.preamp }; + setBPeqMeta(bPeqMetaRef.current); + } + activePeqRef.current = merged; + setBands(parsed.filters, false); + setSelectedBand(0); + try { + // 显式保存立即发送;A 持久化,B 仅试听。失败保留文本以便重试。 + await schedulePeqSync(merged, parsed.filters, abMode, 0); + setIsBatchEditDialogOpen(false); + toast.success(eqUi.toastBatchApplied); + } catch { + toast.error("EQ 保存失败"); + } finally { + batchSavingRef.current = false; + setBatchSaving(false); } }; @@ -530,66 +561,40 @@ export default function EQPage() { /** 将当前 A 曲线与总增益写回 peqItems 与本地缓存,切换耳机时才能恢复编辑结果 */ const persistPresetEditsToPeqItem = useCallback( ( - catalogIdx: number, + name: string, filtersSource: typeof DEFAULT_BANDS, meta?: { preamp?: number; autoPre?: number }, ) => { - const filters = filtersSource.map(bandToPeqFilter); - setPeqItems((prev) => - prev.map((item, i) => { - if (i !== catalogIdx) return item; - const merged = { - ...item, - filters, - ...(meta?.preamp !== undefined ? { preamp: meta.preamp } : {}), - ...(meta?.autoPre !== undefined ? { autoPre: meta.autoPre } : {}), - }; - rememberPeqPresetCache(item.name, { - filters, - preamp: merged.preamp, - autoPre: merged.autoPre, - }); - return merged; - }), - ); + const patch = { + filters: filtersSource.map(bandToPeqFilter), + ...(meta?.preamp !== undefined ? { preamp: meta.preamp } : {}), + ...(meta?.autoPre !== undefined ? { autoPre: meta.autoPre } : {}), + }; + // 在事件中记录快照;不依赖 updater 的执行时机,也不按可能变化的下标回写。 + rememberPeqPresetCache(name, patch); + setPeqItems((prev) => prev.map((item) => (item.name === name ? { ...item, ...patch } : item))); }, [rememberPeqPresetCache], ); const handleSelectHeadphone = useCallback( - (idx: number) => { + async (idx: number) => { if (idx === headphoneIdx) { setIsHeadphoneMenuOpen(false); return; } - const leaving = peqItems[headphoneIdx]; - const leavingFilters = bandsByMode.A.map(bandToPeqFilter); - rememberPeqPresetCache(leaving?.name, { - filters: leavingFilters, - preamp: leaving?.preamp, - autoPre: leaving?.autoPre, - }); - persistPresetEditsToPeqItem(headphoneIdx, bandsByMode.A, { - preamp: leaving?.preamp, - autoPre: leaving?.autoPre, - }); - if (peqSyncTimerRef.current !== null) { - window.clearTimeout(peqSyncTimerRef.current); - peqSyncTimerRef.current = null; + const target = peqItems[idx]; + if (!target) return; + try { + await flushPendingPeqSync(); + await updateSetting({ peqSelect: idx }); + selectHeadphone(idx, target.name); + setIsHeadphoneMenuOpen(false); + } catch { + toast.error("EQ 保存失败"); } - selectHeadphone(idx, peqItems[idx]?.name); - updateSetting({ peqSelect: idx }); - setIsHeadphoneMenuOpen(false); }, - [ - bandsByMode.A, - headphoneIdx, - peqItems, - persistPresetEditsToPeqItem, - rememberPeqPresetCache, - selectHeadphone, - updateSetting, - ], + [flushPendingPeqSync, headphoneIdx, peqItems, selectHeadphone, updateSetting], ); const applyPeqStateToUI = ( @@ -608,7 +613,10 @@ export default function EQPage() { lastPeqCatalogSyncKeyRef.current = buildPeqCatalogSyncKey(remote, resolvedPeqSelect); const catalogIdx = syncPeqCatalogFromState(remote, resolvedPeqSelect); - const items = remote.peq ?? []; + const items = mergeRemotePeqCatalog(remote.peq ?? []); + const meta = { preamp: items[catalogIdx]?.preamp ?? 0, autoPre: items[catalogIdx]?.autoPre ?? 0 }; + bPeqMetaRef.current = meta; + setBPeqMeta(meta); if (items.length === 0) { setBandsForBothModes(FLAT_BANDS); setSelectedBandByMode({ A: 0, B: 0 }); @@ -636,6 +644,7 @@ export default function EQPage() { setDeleteHeadphoneDialog(null); try { + await flushPendingPeqSync(); if (isDemoMode || !api) { const nextItems = peqItems.filter((_, idx) => idx !== target.idx); delete peqPresetCacheRef.current[target.name]; @@ -701,8 +710,8 @@ export default function EQPage() { model: copyModel || undefined, target: copyTarget || undefined, form: copyForm || undefined, - autoPre: currentPeq?.autoPre, - preamp: currentPeq?.preamp, + autoPre: activePeq?.autoPre, + preamp: activePeq?.preamp, canDel: currentPeq?.canDel, }, bands.map(bandToPeqFilter), @@ -717,6 +726,7 @@ export default function EQPage() { }; try { + await flushPendingPeqSync(); await upgradePeqChange(addPresetMode === "copy" ? copyPayload : flatPayload); if (api && !isDemoMode) { const latest = await fetchEqSyncPeq(api, "addPreset"); @@ -730,8 +740,8 @@ export default function EQPage() { const localFilters = addPresetMode === "copy" ? bands.map(bandToPeqFilter) : FLAT_PRESET_FILTERS; - const localAutoPre = addPresetMode === "copy" ? (currentPeq?.autoPre ?? 0) : 0; - const localPreamp = addPresetMode === "copy" ? (currentPeq?.preamp ?? 0) : 0; + const localAutoPre = addPresetMode === "copy" ? (activePeq?.autoPre ?? 0) : 0; + const localPreamp = addPresetMode === "copy" ? (activePeq?.preamp ?? 0) : 0; setHeadphoneModels((prev) => [...prev, nextName]); setPeqItems((prev) => [ ...prev, @@ -781,8 +791,8 @@ export default function EQPage() { model: currentPeq?.model, target: currentPeq?.target, form: currentPeq?.form, - autoPre: currentPeq?.autoPre, - preamp: currentPeq?.preamp, + autoPre: bPeqMeta?.autoPre, + preamp: bPeqMeta?.preamp, canDel: currentPeq?.canDel, }, filters, @@ -790,6 +800,7 @@ export default function EQPage() { }; try { + await flushPendingPeqSync(); await upgradePeqChange(payload); if (api && !isDemoMode) { const latest = await fetchEqSyncPeq(api, "saveBAsPreset"); @@ -806,8 +817,8 @@ export default function EQPage() { { name: nextName, filters, - autoPre: currentPeq?.autoPre ?? 0, - preamp: currentPeq?.preamp ?? 0, + autoPre: bPeqMeta?.autoPre ?? 0, + preamp: bPeqMeta?.preamp ?? 0, canDel: currentPeq?.canDel ?? 1, }, ]); @@ -873,8 +884,8 @@ export default function EQPage() { ), }; - skipPeqAutoSyncRef.current = true; try { + await flushPendingPeqSync(); await upgradePeqChange(payload); if (api && !isDemoMode) { const latest = await fetchEqSyncPeq(api, "importSharedEq"); @@ -925,7 +936,6 @@ export default function EQPage() { toast.success(eqUi.importSuccess); setIsShareDialogOpen(false); } catch { - skipPeqAutoSyncRef.current = false; toast.error(eqUi.importFail ?? eqUi.toastAddPresetFail); } }; @@ -953,86 +963,75 @@ export default function EQPage() { let cancelled = false; async function loadHeadphones() { - peqHydrationPendingRef.current = true; - allowPeqRemoteSyncRef.current = false; + if (isDemoMode || !api) { + if (cancelled) return; + // 演示模式使用默认列表 + const defaultModels = [ + "Sennheiser HD 650", + "Sennheiser HD 800", + "Sony WH-1000XM5", + "AKG K701", + "Beyerdynamic DT 990", + "Audio-Technica ATH-M50x", + ]; + setHeadphoneModels(defaultModels); + setPeqItems(defaultModels.map(name => ({ name, filters: [] }))); + selectHeadphone(0, defaultModels[0]); + setCurrentRawCurve(null); + return; + } try { - if (isDemoMode || !api) { - if (cancelled) return; - // 演示模式使用默认列表 - const defaultModels = [ - "Sennheiser HD 650", - "Sennheiser HD 800", - "Sony WH-1000XM5", - "AKG K701", - "Beyerdynamic DT 990", - "Audio-Technica ATH-M50x", - ]; - setHeadphoneModels(defaultModels); - setPeqItems(defaultModels.map(name => ({ name, filters: [] }))); - selectHeadphone(0, defaultModels[0]); - setCurrentRawCurve(null); - return; - } - try { - const loadedPeq = await fetchEqSyncPeq(api, "loadHeadphones"); - if (cancelled) return; - if (loadedPeq.peq && loadedPeq.peq.length > 0) { - const mergedPeq = mergeRemotePeqCatalog(loadedPeq.peq); - setHeadphoneModels(mergedPeq.map((h) => h.name)); - setPeqItems(mergedPeq); - const nextIdx = Math.min( - Math.max( - devicePeqSelectRef.current ?? loadedPeq.peqSelect ?? 0, - 0, - ), - mergedPeq.length - 1, - ); - selectHeadphone(nextIdx, mergedPeq[nextIdx]?.name); - lastPeqCatalogSyncKeyRef.current = buildPeqCatalogSyncKey( - loadedPeq, - devicePeqSelectRef.current ?? loadedPeq.peqSelect, - ); + const loadedPeq = await fetchEqSyncPeq(api, "loadHeadphones"); + if (cancelled) return; + if (loadedPeq.peq && loadedPeq.peq.length > 0) { + const mergedPeq = mergeRemotePeqCatalog(loadedPeq.peq); + setHeadphoneModels(mergedPeq.map((h) => h.name)); + setPeqItems(mergedPeq); + const nextIdx = Math.min( + Math.max( + devicePeqSelectRef.current ?? loadedPeq.peqSelect ?? 0, + 0, + ), + mergedPeq.length - 1, + ); + selectHeadphone(nextIdx, mergedPeq[nextIdx]?.name); + lastPeqCatalogSyncKeyRef.current = buildPeqCatalogSyncKey( + loadedPeq, + devicePeqSelectRef.current ?? loadedPeq.peqSelect, + ); - // 初始化当前选中的耳机曲线 - const currentPeq = mergedPeq[nextIdx]; - if (currentPeq) { - // 修复 filters 数据(兼容不同的字段名) - const fixedFilters = normalizeFiltersFromPeq(currentPeq); - - // 更新 bands 状态 - if (fixedFilters && fixedFilters.length > 0) { - setBandsForBothModes(fixedFilters); - setSelectedBandByMode({ A: 0, B: 0 }); - } - - // raw 与图表渲染统一交给 headphoneIdx/peqItems 监听逻辑处理, - // 避免首次进入页面时重复请求 modelCurve。 + // 初始化当前选中的耳机曲线 + const currentPeq = mergedPeq[nextIdx]; + if (currentPeq) { + // 修复 filters 数据(兼容不同的字段名) + const fixedFilters = normalizeFiltersFromPeq(currentPeq); + // 更新 bands 状态 + if (fixedFilters && fixedFilters.length > 0) { + setBandsForBothModes(fixedFilters); + setSelectedBandByMode({ A: 0, B: 0 }); } - } else { - // 设备未返回任何 EQ 预设:保持列表为空,避免展示虚假的默认型号 - setHeadphoneModels([]); - setPeqItems([]); - selectHeadphone(0, ""); - setBandsForBothModes(FLAT_BANDS); - setSelectedBandByMode({ A: 0, B: 0 }); - setCurrentRawCurve(null); + + // raw 与图表渲染统一交给 headphoneIdx/peqItems 监听逻辑处理, + // 避免首次进入页面时重复请求 modelCurve。 + } - } catch { - if (cancelled) return; - // 拉取失败:同样保持空列表,不展示虚假的默认型号 + } else { + // 设备未返回任何 EQ 预设:保持列表为空,避免展示虚假的默认型号 setHeadphoneModels([]); setPeqItems([]); selectHeadphone(0, ""); + setBandsForBothModes(FLAT_BANDS); + setSelectedBandByMode({ A: 0, B: 0 }); setCurrentRawCurve(null); } - } finally { + } catch { if (cancelled) return; - allowPeqRemoteSyncRef.current = true; - skipPeqAutoSyncRef.current = true; - queueMicrotask(() => { - if (!cancelled) peqHydrationPendingRef.current = false; - }); + // 拉取失败:同样保持空列表,不展示虚假的默认型号 + setHeadphoneModels([]); + setPeqItems([]); + selectHeadphone(0, ""); + setCurrentRawCurve(null); } } void loadHeadphones(); @@ -1062,6 +1061,9 @@ export default function EQPage() { // 切换耳机型号后,从 peqItems 加载该型号的 filters(暂停一次上报避免错写) useEffect(() => { const peq = peqItems[headphoneIdx]; + const meta = { preamp: peq?.preamp ?? 0, autoPre: peq?.autoPre ?? 0 }; + bPeqMetaRef.current = meta; + setBPeqMeta(meta); if (!peq) { // 列表为空(如设备端删光全部预设):恢复平直曲线并清空 Raw 参考线 setBandsForBothModes(FLAT_BANDS); @@ -1072,8 +1074,6 @@ export default function EQPage() { } const nextBands = normalizeFiltersFromPeq(peq); if (!nextBands.length) return; - syncingHeadphoneRef.current = true; - skipPeqAutoSyncRef.current = true; setBandsForBothModes(nextBands); setSelectedBandByMode({ A: 0, B: 0 }); const shouldFetchModelCurve = !!(peq.brand?.trim() && peq.model?.trim()); @@ -1091,9 +1091,6 @@ export default function EQPage() { setCurrentRawCurve(raw); setRawCurveLoading(false); })(); - requestAnimationFrame(() => { - syncingHeadphoneRef.current = false; - }); return () => { cancelled = true; }; @@ -1103,80 +1100,124 @@ export default function EQPage() { const schedulePeqSync = useCallback( ( - peq: { - name: string; - autoPre?: number; - preamp?: number; - canDel?: number; - brand?: string; - model?: string; - target?: string; - form?: string; - } | undefined, + peq: + | { + name: string; + autoPre?: number; + preamp?: number; + canDel?: number; + brand?: string; + model?: string; + target?: string; + form?: string; + } + | undefined, filtersSource: typeof DEFAULT_BANDS, mode: "A" | "B", delay = 320, topLevel: PeqSubmitTopLevel = "byMode", - catalogIdx = headphoneIdx, - ) => { - if (!allowPeqRemoteSyncRef.current) return; - if (peqHydrationPendingRef.current) return; - if (!isDemoMode && !api) return; - if (!peq?.name) return; - - if (peqSyncTimerRef.current !== null) { - window.clearTimeout(peqSyncTimerRef.current); + ): Promise => { + if (!peq?.name || (!isDemoMode && !api)) return Promise.reject(new Error("EQ unavailable")); + const usePeqChange = topLevel === "peqChange" || (topLevel === "byMode" && mode === "A"); + const pending = pendingPeqSyncRef.current; + if (pending?.persist && (!usePeqChange || pending.name !== peq.name)) { + void flushPendingPeqSync().catch(() => toast.error("EQ 保存失败")); + } else { + cancelPendingPeqSync(); } - - const idxAtSchedule = catalogIdx; + const body = buildPeqPresetBody(peq, filtersSource.map(bandToPeqFilter)); + if (usePeqChange) persistPresetEditsToPeqItem(peq.name, filtersSource, peq); + const runSync = () => { + pendingPeqSyncRef.current = null; + peqSyncTimerRef.current = null; + const request = usePeqChange + ? upgradePeqChange({ peqChange: body }) + : upgradePeqApply({ peqApply: body }); + inFlightPeqSyncRef.current = request; + return request; + }; + if (delay === 0) return runSync(); + pendingPeqSyncRef.current = { name: peq.name, persist: usePeqChange, run: runSync }; peqSyncTimerRef.current = window.setTimeout(() => { - const filters = filtersSource.map(bandToPeqFilter); - const body = buildPeqPresetBody(peq, filters); - const usePeqChange = - topLevel === "peqChange" || (topLevel === "byMode" && mode === "A"); - if (usePeqChange) { - persistPresetEditsToPeqItem(idxAtSchedule, filtersSource, { - preamp: peq.preamp, - autoPre: peq.autoPre, - }); - } - const submit = usePeqChange - ? () => upgradePeqChange({ peqChange: body } satisfies PeqChangePayload) - : () => upgradePeqApply({ peqApply: body } satisfies PeqApplyPayload); - submit().catch(() => { - toast.error("EQ 保存失败"); - }); + void runSync().catch(() => toast.error("EQ 保存失败")); }, delay); + return Promise.resolve(); }, - [api, headphoneIdx, isDemoMode, persistPresetEditsToPeqItem, upgradePeqApply, upgradePeqChange], + [ + api, + cancelPendingPeqSync, + flushPendingPeqSync, + isDemoMode, + persistPresetEditsToPeqItem, + upgradePeqApply, + upgradePeqChange, + ], + ); + + const setBands = useCallback( + ( + updater: typeof DEFAULT_BANDS | ((prev: typeof DEFAULT_BANDS) => typeof DEFAULT_BANDS), + sync = true, + ) => { + const prev = bandsByModeRef.current; + const nextBands = cloneBands(typeof updater === "function" ? updater(prev[abMode]) : updater); + const next = { ...prev, [abMode]: nextBands }; + bandsByModeRef.current = next; + setBandsByMode(next); + // 只由用户编辑触发提交,初始化/轮询/切换回显不再依赖 skip 标记。 + if (sync) + void schedulePeqSync(activePeqRef.current, nextBands, abMode).catch(() => + toast.error("EQ 保存失败"), + ); + }, + [abMode, schedulePeqSync], ); /** 点击 A/B 切换试听:顶层固定 peqApply(A/B 数据结构相同,仅 filters 不同) */ const handleAbToggle = useCallback( (m: "A" | "B") => { if (m === abMode) return; - skipBandsSyncFromAbToggleRef.current = true; + // 切换试听前补发未完成的持久化同步,避免被随后的 peqApply 覆盖丢失 + void flushPendingPeqSync().catch(() => toast.error("EQ 保存失败")); setAbMode(m); - schedulePeqSync(peqItems[headphoneIdx], bandsByMode[m], m, 0, "peqApply"); + const peq = peqItems[headphoneIdx]; + const meta = m === "B" ? bPeqMetaRef.current : peq; + if (peq) + void schedulePeqSync({ ...peq, ...meta }, bandsByModeRef.current[m], m, 0, "peqApply").catch( + () => toast.error("EQ 保存失败"), + ); }, - [abMode, bandsByMode, headphoneIdx, peqItems, schedulePeqSync], + [abMode, bandsByMode, flushPendingPeqSync, headphoneIdx, peqItems, schedulePeqSync], ); const handleCopyAndSwitchTo = useCallback( (to: "A" | "B") => { const from = abMode; captureBefore(takeUndoSnapshot()); - const copied = cloneBands(bandsByMode[from]); + // 切换试听前补发未完成的持久化同步,避免被随后的 peqApply 覆盖丢失 + void flushPendingPeqSync().catch(() => toast.error("EQ 保存失败")); + const copied = cloneBands(bandsByModeRef.current[from]); + const peq = activePeqRef.current; + if (!peq) return; + const meta = { preamp: peq.preamp ?? 0, autoPre: peq.autoPre ?? 0 }; + if (to === "B") { + bPeqMetaRef.current = meta; + setBPeqMeta(meta); + } else { + setPeqItems((prev) => + prev.map((item) => (item.name === peq.name ? { ...item, ...meta } : item)), + ); + } copyModeParams(from, to); - skipBandsSyncFromAbToggleRef.current = true; setAbMode(to); - schedulePeqSync(peqItems[headphoneIdx], copied, to, 0, "peqApply"); + void schedulePeqSync(peq, copied, to, 0, "peqApply").catch(() => toast.error("EQ 保存失败")); }, [ abMode, bandsByMode, captureBefore, copyModeParams, + flushPendingPeqSync, headphoneIdx, peqItems, schedulePeqSync, @@ -1184,26 +1225,6 @@ export default function EQPage() { ], ); - // 参数编辑防抖:A→peqChange,B→peqApply(不因切换 A/B 误触发) - useEffect(() => { - if (peqHydrationPendingRef.current) return; - if (syncingHeadphoneRef.current) return; - if (skipPeqAutoSyncRef.current) { - skipPeqAutoSyncRef.current = false; - return; - } - if (skipBandsSyncFromAbToggleRef.current) { - skipBandsSyncFromAbToggleRef.current = false; - return; - } - schedulePeqSync(peqItems[headphoneIdx], bands, abMode, 320, "byMode", headphoneIdx); - return () => { - if (peqSyncTimerRef.current !== null) { - window.clearTimeout(peqSyncTimerRef.current); - } - }; - }, [bands, abMode, headphoneIdx, currentPeqName, currentPeqPreamp, currentPeqAutoPre, schedulePeqSync]); - useEffect(() => { const onPointerDown = (event: PointerEvent) => { const target = event.target as Node; @@ -1227,7 +1248,8 @@ export default function EQPage() { }, [selectedBand, abMode, headphoneSwitchKey]); const handleApplyB = useCallback(async () => { - const peq = peqItems[headphoneIdx]; + const item = peqItems[headphoneIdx]; + const peq = item && { ...item, ...bPeqMetaRef.current }; if (!peq?.name) { toast.error(eqUi.toastApplyBFail); return; @@ -1253,29 +1275,15 @@ export default function EQPage() { ), }; - skipPeqAutoSyncRef.current = true; - setBandsByMode((prev) => ({ ...prev, A: cloneBands(bBands) })); + const next = { ...bandsByModeRef.current, A: cloneBands(bBands) }; + bandsByModeRef.current = next; + setBandsByMode(next); setSelectedBandByMode((prev) => ({ ...prev, A: prev.B })); - rememberPeqPresetCache(peq.name, { - filters, - preamp: peq.preamp, - autoPre: peq.autoPre, - }); - 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); + await schedulePeqSync(payload.peqChange, bBands, "A", 0, "peqChange"); toast.success(eqUi.toastApplyBSuccess); } catch { - skipPeqAutoSyncRef.current = false; toast.error(eqUi.toastApplyBFail); } }, [ @@ -1285,9 +1293,8 @@ export default function EQPage() { eqUi.toastApplyBSuccess, headphoneIdx, peqItems, - rememberPeqPresetCache, + schedulePeqSync, takeUndoSnapshot, - upgradePeqChange, ]); const updateBand = useCallback( @@ -1412,18 +1419,17 @@ export default function EQPage() { }; const updateCurrentPeqMeta = (patch: Partial<{ autoPre: number; preamp: number }>) => { - let updatedPeq: { name: string; autoPre?: number; preamp?: number; canDel?: number } | undefined; - setPeqItems((prev) => { - const next = prev.map((item, i) => { - if (i !== headphoneIdx) return item; - const merged = { ...item, ...patch }; - updatedPeq = merged; - rememberPeqPresetCache(item.name, patch); - return merged; - }); - return next; - }); - schedulePeqSync(updatedPeq, bands, abMode, 0, "byMode", headphoneIdx); + const item = activePeqRef.current; + if (!item) return; + const merged = { ...item, ...patch }; + activePeqRef.current = merged; + if (abMode === "B") { + bPeqMetaRef.current = { preamp: merged.preamp ?? 0, autoPre: merged.autoPre ?? 0 }; + setBPeqMeta(bPeqMetaRef.current); + } + void schedulePeqSync(merged, bandsByModeRef.current[abMode], abMode).catch(() => + toast.error("EQ 保存失败"), + ); }; const beginPreampSliderDrag = () => { @@ -1444,29 +1450,29 @@ export default function EQPage() { const snap = popPeqUndo(); if (!snap) return; const toastMsg = formatPeqUndoToast(current, snap, eqUi); + const item = peqItems[headphoneIdx]; + if (!item) return; + const next = { A: cloneBands(snap.bandsByMode.A), B: cloneBands(snap.bandsByMode.B) }; + const aMeta = snap.metaByMode?.A ?? { preamp: snap.preamp, autoPre: snap.autoPre }; + const bMeta = snap.metaByMode?.B ?? { preamp: snap.preamp, autoPre: snap.autoPre }; + bandsByModeRef.current = next; + bPeqMetaRef.current = bMeta; + setBandsByMode(next); + setBPeqMeta(bMeta); setAbMode(snap.abMode); - setBandsByMode({ - A: cloneBands(snap.bandsByMode.A as unknown as typeof DEFAULT_BANDS), - B: cloneBands(snap.bandsByMode.B as unknown as typeof DEFAULT_BANDS), - }); - setPeqItems((prev) => - prev.map((item, i) => { - if (i !== headphoneIdx) return item; - const filters = snap.bandsByMode.A.map(bandToPeqFilter); - const merged = { - ...item, - preamp: snap.preamp, - autoPre: snap.autoPre, - filters, - }; - rememberPeqPresetCache(item.name, { - filters, - preamp: snap.preamp, - autoPre: snap.autoPre, - }); - return merged; - }), - ); + const aChanged = + JSON.stringify(current.bandsByMode.A) !== JSON.stringify(next.A) || + JSON.stringify(current.metaByMode?.A) !== JSON.stringify(aMeta); + if (aChanged || snap.abMode === "A") { + void schedulePeqSync({ ...item, ...aMeta }, next.A, "A", 0).catch(() => + toast.error("EQ 保存失败"), + ); + } + if (snap.abMode === "B") { + void schedulePeqSync({ ...item, ...bMeta }, next.B, "B", 0).catch(() => + toast.error("EQ 保存失败"), + ); + } toast.success(toastMsg); }; @@ -1880,7 +1886,8 @@ export default function EQPage() { text={batchEditText} onChangeText={setBatchEditText} onSave={handleSaveBatchEdit} - onClose={() => setIsBatchEditDialogOpen(false)} + saving={batchSaving} + onClose={() => { if (!batchSavingRef.current) setIsBatchEditDialogOpen(false); }} eqUi={eqUi} /> @@ -1939,8 +1946,8 @@ export default function EQPage() { const existingIdx = headphoneModels.indexOf(createdName); const isOverwrite = existingIdx >= 0; if (!isOverwrite && !guardPeqPresetCapacity()) return; - skipPeqAutoSyncRef.current = true; try { + await flushPendingPeqSync(); await upgradePeqChange(postPeq); if (api && !isDemoMode) { const latest = await fetchEqSyncPeq(api, "addCatalogPreset"); @@ -1994,7 +2001,6 @@ export default function EQPage() { } setIsBrandDrawerOpen(false); } catch { - skipPeqAutoSyncRef.current = false; toast.error(eqUi.toastAddPresetFail); } }; @@ -2050,6 +2056,7 @@ export default function EQPage() { eqUi={eqUi} onDeletePresets={async (names) => { try { + await flushPendingPeqSync(); if (isDemoMode || !api) { const nextItems = peqItems.filter(it => !names.includes(it.name)); names.forEach(n => delete peqPresetCacheRef.current[n]); @@ -2070,6 +2077,8 @@ export default function EQPage() { }} onRenamePreset={async (oldName, newName, item) => { try { + await flushPendingPeqSync(); + item = { ...item, ...peqPresetCacheRef.current[oldName] }; if (selectedPeqNameRef.current === oldName) { selectedPeqNameRef.current = newName; } @@ -2129,6 +2138,7 @@ export default function EQPage() { }} onConfirmOrder={async (names, peqSelect) => { try { + await flushPendingPeqSync(); if (isDemoMode || !api) { const byName = new Map(peqItems.map(it => [it.name, it])); const nextItems = names diff --git a/client/src/pages/eq/components/BatchEditDialog.tsx b/client/src/pages/eq/components/BatchEditDialog.tsx index 90e4cfb..f4fd741 100644 --- a/client/src/pages/eq/components/BatchEditDialog.tsx +++ b/client/src/pages/eq/components/BatchEditDialog.tsx @@ -6,6 +6,7 @@ export function BatchEditDialog({ text, onChangeText, onSave, + saving = false, onClose, eqUi, }: { @@ -13,6 +14,7 @@ export function BatchEditDialog({ text: string; onChangeText: (v: string) => void; onSave: () => void; + saving?: boolean; onClose: () => void; eqUi: PeqEqUi; }) { @@ -34,6 +36,7 @@ export function BatchEditDialog({ type="button" className="text-white/45 hover:text-white/75 transition-colors" onClick={onClose} + disabled={saving} > @@ -43,6 +46,7 @@ export function BatchEditDialog({