diff --git a/client/src/contexts/DeviceContext.tsx b/client/src/contexts/DeviceContext.tsx index 7bead87..527f9cf 100644 --- a/client/src/contexts/DeviceContext.tsx +++ b/client/src/contexts/DeviceContext.tsx @@ -195,7 +195,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]); @@ -205,7 +205,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 156b48f..0d62bd8 100644 --- a/client/src/lib/luxsinApi.ts +++ b/client/src/lib/luxsinApi.ts @@ -295,6 +295,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); @@ -339,17 +347,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 { @@ -368,69 +384,76 @@ export class LuxsinAPI { /** POST `json=` — matches legacy axios `upgradePeq`. */ private async postPeqJson(body: PeqChangePayload | PeqApplyPayload): Promise { - if (import.meta.env.DEV) { - const action = - "peqChange" in body ? "peqChange" : "peqApply" in body ? "peqApply" : "peq"; - console.log(`[dev/info.cgi] POST ${action}`, body); - } - 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(), + return this.enqueuePeqRequest(async () => { + if (import.meta.env.DEV) { + const action = "peqChange" in body ? "peqChange" : "peqApply" in body ? "peqApply" : "peq"; + console.log(`[dev/info.cgi] POST ${action}`, body); + } + 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}`); + } }); - 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); - 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(), + 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 remove failed: ${response.status}`); + } }); - if (!response.ok) { - throw new Error(`PEQ remove failed: ${response.status}`); - } } /** Rename a headphone PEQ profile. */ 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); - 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(), + 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 rename failed: ${response.status}`); + } }); - if (!response.ok) { - throw new Error(`PEQ rename failed: ${response.status}`); - } } /** Reorder headphone PEQ profiles. */ 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); - 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(), + 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 sort failed: ${response.status}`); + } }); - if (!response.ok) { - throw new Error(`PEQ sort failed: ${response.status}`); - } } setVolume(volume: number) { return this.setSetting({ volume }); } diff --git a/client/src/pages/EQPage.tsx b/client/src/pages/EQPage.tsx index d81455c..8f81bcf 100644 --- a/client/src/pages/EQPage.tsx +++ b/client/src/pages/EQPage.tsx @@ -183,6 +183,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); @@ -213,47 +215,51 @@ 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 pendingPeqSyncRef = useRef<{ + name: string; + persist: boolean; + run: () => Promise; + } | null>(null); + const inFlightPeqSyncRef = useRef>(Promise.resolve()); + const cancelPendingPeqSync = useCallback(() => { + pendingPeqSyncRef.current = null; + if (peqSyncTimerRef.current !== null) { + window.clearTimeout(peqSyncTimerRef.current); + peqSyncTimerRef.current = null; + } + }, []); + 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 peqPresetCacheRef = useRef>({}); /** Anchor UI selection by preset name so poll/catalog reorder won't mis-switch headphones. */ const selectedPeqNameRef = useRef(""); const devicePeqSelectRef = useRef(deviceState?.peqSelect); devicePeqSelectRef.current = deviceState?.peqSelect; - const skipPeqAutoSyncRef = useRef(false); - const skipBandsSyncFromAbToggleRef = useRef(false); const headphoneMenuRef = useRef(null); const filterMenuRef = useRef(null); const bands = bandsByMode[abMode]; + const bandsByModeRef = useRef(bandsByMode); + bandsByModeRef.current = bandsByMode; + const [bPeqMeta, setBPeqMeta] = useState({ preamp: 0, autoPre: 0 }); + const bPeqMetaRef = useRef(bPeqMeta); + bPeqMetaRef.current = bPeqMeta; const selectedBand = selectedBandByMode[abMode]; - const setBandsForBothModes = useCallback( - (nextBands: typeof DEFAULT_BANDS) => { - const cloned = cloneBands(nextBands); - setBandsByMode({ A: cloneBands(cloned), B: cloneBands(cloned) }); - }, - [] - ); - 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 setBandsForBothModes = useCallback((nextBands: typeof DEFAULT_BANDS) => { + const next = { A: cloneBands(nextBands), B: cloneBands(nextBands) }; + bandsByModeRef.current = next; + setBandsByMode(next); + }, []); + const setSelectedBand = useCallback( (updater: number | ((prev: number) => number)) => { setSelectedBandByMode(prev => ({ @@ -267,25 +273,34 @@ export default function EQPage() { [abMode] ); const copyModeParams = useCallback((from: "A" | "B", to: "A" | "B") => { - setBandsByMode(prev => ({ ...prev, [to]: cloneBands(prev[from]) })); - setSelectedBandByMode(prev => ({ ...prev, [to]: 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 preampValue = Number(currentPeq?.preamp ?? 0); - const autoPreOn = (currentPeq?.autoPre ?? 0) === 1; + const currentPeqPreamp = currentPeq?.preamp ?? 0; + const currentPeqAutoPre = currentPeq?.autoPre ?? 0; + 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 => { @@ -298,6 +313,7 @@ export default function EQPage() { }, preamp: src.preamp, autoPre: src.autoPre, + metaByMode: { A: { ...src.metaByMode.A }, B: { ...src.metaByMode.B } }, }; }, []); @@ -369,7 +385,7 @@ export default function EQPage() { 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++) { @@ -379,7 +395,7 @@ export default function EQPage() { ); } return lines.join("\n"); - }, [bands, currentPeq?.preamp]); + }, [bands, preampValue]); const openBatchEditDialog = () => { setBatchEditText(buildBatchEditText()); @@ -473,48 +489,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); + 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 (error) { - toast.error( - error instanceof Error ? error.message : eqUi.toastBatchParseError - ); + } catch { + toast.error("EQ 保存失败"); + } finally { + batchSavingRef.current = false; + setBatchSaving(false); } }; @@ -583,11 +589,9 @@ export default function EQPage() { [mergeRemotePeqCatalog] ); - const applyPeqStateToUI = (remote: { - peq?: Array<{ name: string; filters?: any[] | string }>; - peqSelect?: number; - filters?: PeqFilter[]; - }) => { + const applyPeqStateToUI = ( + remote: { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number; filters?: PeqFilter[] }, + ) => { const resolvedPeqSelect = remote.peqSelect ?? deviceState?.peqSelect ?? peqState?.peqSelect ?? 0; @@ -598,13 +602,13 @@ export default function EQPage() { peqSelect: resolvedPeqSelect, }); } - lastPeqCatalogSyncKeyRef.current = buildPeqCatalogSyncKey( - remote, - resolvedPeqSelect - ); + 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 }); @@ -612,7 +616,7 @@ export default function EQPage() { } const nextBands = normalizeFiltersFromPeq( - items[catalogIdx] as { filters?: any[] | string } + items[catalogIdx] as { filters?: any[] | string }, ); if (nextBands.length > 0) { setBandsForBothModes(nextBands); @@ -632,6 +636,7 @@ export default function EQPage() { setDeleteHeadphoneDialog(null); try { + await flushPendingPeqSync(); if (isDemoMode || !api) { const nextItems = peqItems.filter((_, idx) => idx !== target.idx); forgetPeqPresetCache(target.name); @@ -702,8 +707,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) @@ -718,6 +723,7 @@ export default function EQPage() { }; try { + await flushPendingPeqSync(); await upgradePeqChange( addPresetMode === "copy" ? copyPayload : flatPayload ); @@ -741,9 +747,9 @@ export default function EQPage() { ? bands.map(bandToPeqFilter) : FLAT_PRESET_FILTERS; const localAutoPre = - addPresetMode === "copy" ? (currentPeq?.autoPre ?? 0) : 0; + addPresetMode === "copy" ? (activePeq?.autoPre ?? 0) : 0; const localPreamp = - addPresetMode === "copy" ? (currentPeq?.preamp ?? 0) : 0; + addPresetMode === "copy" ? (activePeq?.preamp ?? 0) : 0; setHeadphoneModels(prev => [...prev, nextName]); setPeqItems(prev => [ ...prev, @@ -799,8 +805,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 @@ -808,6 +814,7 @@ export default function EQPage() { }; try { + await flushPendingPeqSync(); await upgradePeqChange(payload); if (api && !isDemoMode) { const latest = await fetchEqSyncPeq(api, "saveBAsPreset"); @@ -830,8 +837,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, }, ]); @@ -898,8 +905,8 @@ export default function EQPage() { ), }; - skipPeqAutoSyncRef.current = true; try { + await flushPendingPeqSync(); await upgradePeqChange(payload); if (api && !isDemoMode) { const latest = await fetchEqSyncPeq(api, "importSharedEq"); @@ -956,7 +963,6 @@ export default function EQPage() { toast.success(eqUi.importSuccess); setIsShareDialogOpen(false); } catch { - skipPeqAutoSyncRef.current = false; toast.error(eqUi.importFail ?? eqUi.toastAddPresetFail); } }; @@ -984,85 +990,74 @@ 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); + // 初始化当前选中的耳机曲线 + 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。 + // 更新 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(); @@ -1103,6 +1098,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); @@ -1113,8 +1111,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()); @@ -1134,9 +1130,6 @@ export default function EQPage() { setCurrentRawCurve(raw); setRawCurveLoading(false); })(); - requestAnimationFrame(() => { - syncingHeadphoneRef.current = false; - }); return () => { cancelled = true; }; @@ -1150,66 +1143,40 @@ export default function EQPage() { const persistPresetEditsToPeqItem = useCallback( ( - catalogIdx: number, + name: string, filtersSource: typeof DEFAULT_BANDS, - meta?: { preamp?: number; autoPre?: number } + 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] + [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 schedulePeqSync = useCallback( @@ -1230,107 +1197,115 @@ export default function EQPage() { 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, + 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 */ 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, takeUndoSnapshot, - ] + ], ); - // 参数编辑防抖:A→peqChange,B→peqApply - 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, schedulePeqSync]); + useEffect(() => { const onPointerDown = (event: PointerEvent) => { @@ -1352,7 +1327,8 @@ export default function EQPage() { bands[selectedBand] ?? DEFAULT_BANDS[selectedBand] ?? DEFAULT_BANDS[0]; 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; @@ -1374,33 +1350,19 @@ export default function EQPage() { preamp: peq.preamp, canDel: peq.canDel, }, - filters + filters, ), }; - skipPeqAutoSyncRef.current = true; - setBandsByMode(prev => ({ ...prev, A: cloneBands(bBands) })); - 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 })) - ); + const next = { ...bandsByModeRef.current, A: cloneBands(bBands) }; + bandsByModeRef.current = next; + setBandsByMode(next); + setSelectedBandByMode((prev) => ({ ...prev, A: prev.B })); 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); } }, [ @@ -1410,9 +1372,8 @@ export default function EQPage() { eqUi.toastApplyBSuccess, headphoneIdx, peqItems, - rememberPeqPresetCache, + schedulePeqSync, takeUndoSnapshot, - upgradePeqChange, ]); const updateBand = useCallback( @@ -1553,23 +1514,18 @@ export default function EQPage() { closeBandParamDialog(); }; - 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 updateCurrentPeqMeta = (patch: Partial<{ autoPre: number; preamp: number }>) => { + 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 = () => { @@ -1590,33 +1546,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); }; @@ -2119,7 +2071,8 @@ export default function EQPage() { text={batchEditText} onChangeText={setBatchEditText} onSave={handleSaveBatchEdit} - onClose={() => setIsBatchEditDialogOpen(false)} + saving={batchSaving} + onClose={() => { if (!batchSavingRef.current) setIsBatchEditDialogOpen(false); }} eqUi={eqUi} /> @@ -2132,6 +2085,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 => forgetPeqPresetCache(n)); @@ -2155,6 +2109,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; } @@ -2212,6 +2168,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 @@ -2300,8 +2257,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"); @@ -2362,7 +2319,6 @@ export default function EQPage() { } setIsBrandDrawerOpen(false); } catch { - skipPeqAutoSyncRef.current = false; toast.error(eqUi.toastAddPresetFail); } }; diff --git a/client/src/pages/eq/components/BatchEditDialog.tsx b/client/src/pages/eq/components/BatchEditDialog.tsx index 8bd18af..901c514 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({