diff --git a/client/src/const.ts b/client/src/const.ts deleted file mode 100644 index 9999063..0000000 --- a/client/src/const.ts +++ /dev/null @@ -1,17 +0,0 @@ -export { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const"; - -// Generate login URL at runtime so redirect URI reflects the current origin. -export const getLoginUrl = () => { - const oauthPortalUrl = import.meta.env.VITE_OAUTH_PORTAL_URL; - const appId = import.meta.env.VITE_APP_ID; - const redirectUri = `${window.location.origin}/api/oauth/callback`; - const state = btoa(redirectUri); - - const url = new URL(`${oauthPortalUrl}/app-auth`); - url.searchParams.set("appId", appId); - url.searchParams.set("redirectUri", redirectUri); - url.searchParams.set("state", state); - url.searchParams.set("type", "signIn"); - - return url.toString(); -}; diff --git a/client/src/locales/data-en.json b/client/src/locales/data-en.json index d6828e8..cf3e0a0 100644 --- a/client/src/locales/data-en.json +++ b/client/src/locales/data-en.json @@ -325,6 +325,12 @@ "toastNewEqOk": "New headphone EQ saved", "toastTargetSelected": "Target selected: {{name}}", "deleteConfirm": "Delete headphone \"{{name}}\"?", + "batchDeleteConfirm": "Delete {{count}} selected preset(s)?", + "deletePresetConfirm": "Delete preset \"{{name}}\"?", + "toastRenamed": "Renamed", + "toastRenameFail": "Rename failed", + "renameEmptyName": "Name cannot be empty", + "renameNameExists": "Name already exists", "toastDeleted": "Headphone removed", "toastDeleteFail": "Failed to delete headphone", "bassBoost": "Bass boost: fc {{fc}}, q {{q}}, gain {{gain}} dB", diff --git a/client/src/locales/data-zh-HK.json b/client/src/locales/data-zh-HK.json index 2931385..1cfe857 100644 --- a/client/src/locales/data-zh-HK.json +++ b/client/src/locales/data-zh-HK.json @@ -261,6 +261,12 @@ "toastNewEqOk": "新耳機 EQ 已上報", "toastTargetSelected": "已選目標:{{name}}", "deleteConfirm": "是否刪除耳機「{{name}}」?", + "batchDeleteConfirm": "是否刪除選中的 {{count}} 個預設?", + "deletePresetConfirm": "是否刪除預設「{{name}}」?", + "toastRenamed": "已重新命名", + "toastRenameFail": "重新命名失敗", + "renameEmptyName": "名稱不能為空", + "renameNameExists": "名稱已存在", "toastDeleted": "已刪除耳機", "toastDeleteFail": "刪除耳機失敗", "bassBoost": "低頻增強:fc {{fc}},q {{q}},gain {{gain}}dB", diff --git a/client/src/locales/data-zh.json b/client/src/locales/data-zh.json index 43472a5..637ac8d 100644 --- a/client/src/locales/data-zh.json +++ b/client/src/locales/data-zh.json @@ -261,6 +261,12 @@ "toastNewEqOk": "新耳机 EQ 已上报", "toastTargetSelected": "已选目标:{{name}}", "deleteConfirm": "是否删除耳机「{{name}}」?", + "batchDeleteConfirm": "是否删除选中的 {{count}} 个预设?", + "deletePresetConfirm": "是否删除预设「{{name}}」?", + "toastRenamed": "已重命名", + "toastRenameFail": "重命名失败", + "renameEmptyName": "名称不能为空", + "renameNameExists": "名称已存在", "toastDeleted": "已删除耳机", "toastDeleteFail": "删除耳机失败", "bassBoost": "低频增强:fc {{fc}},q {{q}},gain {{gain}}dB", diff --git a/client/src/pages/EQPage.tsx b/client/src/pages/EQPage.tsx index 5f5a9ba..ca32872 100644 --- a/client/src/pages/EQPage.tsx +++ b/client/src/pages/EQPage.tsx @@ -11,7 +11,15 @@ 7. Total gain card: 总增益 value + AUTO toggle + slider ============================================================ */ import { useDevice } from "@/contexts/DeviceContext"; -import { ChevronLeft, ChevronDown, Minus, Plus, Edit3, Headphones, Share2 } from "lucide-react"; +import { + ChevronLeft, + ChevronDown, + Minus, + Plus, + Edit3, + Headphones, + Share2, +} from "lucide-react"; import { useLocation } from "wouter"; import { useState, useMemo, useEffect, useRef, useCallback } from "react"; import { cn } from "@/lib/utils"; @@ -20,16 +28,14 @@ import { FeatureGate } from "@/components/FeatureGate"; import { toast } from "sonner"; import { fetchLuxsinAudioCurve, + normalizePeqFiltersForSubmit, type PeqFilter, type PeqApplyPayload, type PeqChangePayload, type PeqState, buildPeqPresetBody, } from "@/lib/luxsinApi"; -import { - getFilterType, - getFilterShortName, -} from "@/lib/peqAudio"; +import { getFilterType, getFilterShortName } from "@/lib/peqAudio"; import localeZh from "@/locales/data-zh.json"; import localeZhHK from "@/locales/data-zh-HK.json"; import localeEn from "@/locales/data-en.json"; @@ -76,6 +82,7 @@ import { BatchEditDialog } from "./eq/components/BatchEditDialog"; import { ShareDialog } from "./eq/components/ShareDialog"; import { BrandDrawer } from "./eq/components/BrandDrawer"; import { PeqOverwriteConfirmDialog } from "./eq/components/PeqOverwriteConfirmDialog"; +import { PeqPresetManageDialog } from "./eq/components/PeqPresetManageDialog"; import { useRawCurve } from "./eq/hooks/useRawCurve"; /* ── Default bands imported from eq/constants ── */ @@ -110,11 +117,15 @@ export default function EQPage() { return pack.peq ?? localeZh.peq; }, [deviceState?.language]); - const [bandsByMode, setBandsByMode] = useState>({ + const [bandsByMode, setBandsByMode] = useState< + Record<"A" | "B", typeof DEFAULT_BANDS> + >({ A: cloneBands(DEFAULT_BANDS), B: cloneBands(DEFAULT_BANDS), }); - const [selectedBandByMode, setSelectedBandByMode] = useState>({ + const [selectedBandByMode, setSelectedBandByMode] = useState< + Record<"A" | "B", number> + >({ A: 0, B: 0, }); @@ -142,7 +153,9 @@ export default function EQPage() { const [flatPresetName, setFlatPresetName] = useState(""); const [isSaveBDialogOpen, setIsSaveBDialogOpen] = useState(false); const [saveBPresetName, setSaveBPresetName] = useState(""); - const [bandParamDialog, setBandParamDialog] = useState(null); + const [bandParamDialog, setBandParamDialog] = useState( + null + ); const [bandParamInput, setBandParamInput] = useState(""); /** 滑块拖动时的实时显示(避免按钮文字滞后于 bands 状态) */ const [bandSliderPreview, setBandSliderPreview] = useState<{ @@ -157,6 +170,8 @@ export default function EQPage() { const [brandDrawerKey, setBrandDrawerKey] = useState(0); const [isShareDialogOpen, setIsShareDialogOpen] = useState(false); const [shareDialogKey, setShareDialogKey] = useState(0); + const [isPresetManageDialogOpen, setIsPresetManageDialogOpen] = + useState(false); const [overwritePresetDialog, setOverwritePresetDialog] = useState<{ name: string; onConfirm: () => void | Promise; @@ -181,34 +196,46 @@ export default function EQPage() { const filterMenuRef = useRef(null); const bands = bandsByMode[abMode]; const selectedBand = selectedBandByMode[abMode]; - const setBandsForBothModes = useCallback((nextBands: typeof DEFAULT_BANDS) => { - const cloned = cloneBands(nextBands); - setBandsByMode({ A: cloneBands(cloned), B: cloneBands(cloned) }); - }, []); + 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) => ({ + ( + 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]) + ? (updater as (prev: typeof DEFAULT_BANDS) => typeof DEFAULT_BANDS)( + prev[abMode] + ) : cloneBands(updater), })); }, - [abMode], + [abMode] ); const setSelectedBand = useCallback( (updater: number | ((prev: number) => number)) => { - setSelectedBandByMode((prev) => ({ + setSelectedBandByMode(prev => ({ ...prev, - [abMode]: typeof updater === "function" ? (updater as (prev: number) => number)(prev[abMode]) : updater, + [abMode]: + typeof updater === "function" + ? (updater as (prev: number) => number)(prev[abMode]) + : updater, })); }, - [abMode], + [abMode] ); const copyModeParams = useCallback((from: "A" | "B", to: "A" | "B") => { - setBandsByMode((prev) => ({ ...prev, [to]: cloneBands(prev[from]) })); - setSelectedBandByMode((prev) => ({ ...prev, [to]: prev[from] })); + setBandsByMode(prev => ({ ...prev, [to]: cloneBands(prev[from]) })); + setSelectedBandByMode(prev => ({ ...prev, [to]: prev[from] })); }, []); const currentPeq = peqItems[headphoneIdx]; @@ -218,8 +245,8 @@ export default function EQPage() { const guardPeqPresetCapacity = () => { if (peqItems.length < PEQ_PRESET_MAX) return true; toast.error( - eqInterp(eqUi.toastPresetLimitReached, { max: PEQ_PRESET_MAX }) - || `Preset limit reached (${PEQ_PRESET_MAX}). Delete some presets first.`, + eqInterp(eqUi.toastPresetLimitReached, { max: PEQ_PRESET_MAX }) || + `Preset limit reached (${PEQ_PRESET_MAX}). Delete some presets first.` ); return false; }; @@ -229,7 +256,7 @@ export default function EQPage() { if (!headphoneModels.includes(name)) { return Promise.resolve(action()).then(() => true); } - return new Promise((resolve) => { + return new Promise(resolve => { setOverwritePresetDialog({ name, onConfirm: async () => { @@ -244,16 +271,18 @@ export default function EQPage() { }); }); }, - [headphoneModels], + [headphoneModels] ); const openAddHeadsetCatalog = () => { if (!guardPeqPresetCapacity()) return; setIsBrandDrawerOpen(true); - setBrandDrawerKey((k) => k + 1); + setBrandDrawerKey(k => k + 1); }; - const normalizeFiltersFromPeq = (peq: { filters?: any[] | string } | undefined) => { + const normalizeFiltersFromPeq = ( + peq: { filters?: any[] | string } | undefined + ) => { if (!peq) return [] as typeof bands; let filters: any[] = []; if (typeof peq.filters === "string") { @@ -277,7 +306,8 @@ export default function EQPage() { }); }; - const toCompactNumber = (value: number) => Number(value.toFixed(3)).toString(); + const toCompactNumber = (value: number) => + Number(value.toFixed(3)).toString(); const buildBatchEditText = useCallback(() => { const preamp = Number(currentPeq?.preamp ?? 0); @@ -297,18 +327,24 @@ export default function EQPage() { setIsBatchEditDialogOpen(true); }; + const openPresetManageDialog = () => { + setIsPresetManageDialogOpen(true); + }; + const parseBatchEditText = useCallback( (raw: string) => { const t = eqUi; const lines = raw .split(/\r?\n/) - .map((line) => line.trim()) + .map(line => line.trim()) .filter(Boolean); if (lines.length < 11) { throw new Error(eqInterp(t.errMinLines, {})); } - const preampMatch = lines[0].match(/^Preamp\s*:\s*([+-]?\d+(?:\.\d+)?)\s*dB$/i); + const preampMatch = lines[0].match( + /^Preamp\s*:\s*([+-]?\d+(?:\.\d+)?)\s*dB$/i + ); if (!preampMatch) { throw new Error(eqInterp(t.errPreampFirstLine, {})); } @@ -336,19 +372,25 @@ export default function EQPage() { const filterNo = Number(match[1]); if (filterNo < 1 || filterNo > 10) { - throw new Error(eqInterp(t.errFilterNoRange, { no: String(filterNo) })); + throw new Error( + eqInterp(t.errFilterNoRange, { no: String(filterNo) }) + ); } const type = normalizeFilterType(match[3].toUpperCase()); if (!FILTER_TYPES.includes(type)) { - throw new Error(eqInterp(t.errFilterType, { no: String(filterNo), type: match[3] })); + throw new Error( + eqInterp(t.errFilterType, { no: String(filterNo), type: match[3] }) + ); } const freq = Number(match[4]); const gain = Number(match[5]); const q = Number(match[6]); if (![freq, gain, q].every(Number.isFinite)) { - throw new Error(eqInterp(t.errFilterNumbers, { no: String(filterNo) })); + throw new Error( + eqInterp(t.errFilterNumbers, { no: String(filterNo) }) + ); } parsedFilters[filterNo - 1] = { @@ -360,7 +402,7 @@ export default function EQPage() { }; } - if (parsedFilters.some((item) => !item)) { + if (parsedFilters.some(item => !item)) { throw new Error(eqInterp(t.errFilterAll, {})); } @@ -369,14 +411,16 @@ export default function EQPage() { filters: parsedFilters as typeof bands, }; }, - [eqUi], + [eqUi] ); const handleSaveBatchEdit = () => { try { const parsed = parseBatchEditText(batchEditText); - let updatedPeq: { name: string; autoPre?: number; preamp?: number; canDel?: number } | undefined; - setPeqItems((prev) => + 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); @@ -386,18 +430,31 @@ export default function EQPage() { preamp: parsed.preamp, filters, }; - rememberPeqPresetCache(item.name, { filters, preamp: parsed.preamp, autoPre: 0 }); + rememberPeqPresetCache(item.name, { + filters, + preamp: parsed.preamp, + autoPre: 0, + }); updatedPeq = merged; return merged; }) ); setBands(parsed.filters); - schedulePeqSync(updatedPeq, parsed.filters, abMode, 0, "byMode", headphoneIdx); + schedulePeqSync( + updatedPeq, + parsed.filters, + abMode, + 0, + "byMode", + headphoneIdx + ); setSelectedBand(0); setIsBatchEditDialogOpen(false); toast.success(eqUi.toastBatchApplied); } catch (error) { - toast.error(error instanceof Error ? error.message : eqUi.toastBatchParseError); + toast.error( + error instanceof Error ? error.message : eqUi.toastBatchParseError + ); } }; @@ -409,7 +466,7 @@ export default function EQPage() { if (next.filters?.length === 0) delete next.filters; peqPresetCacheRef.current[presetName] = next; }, - [], + [] ); const forgetPeqPresetCache = useCallback((presetName: string | undefined) => { @@ -418,7 +475,7 @@ export default function EQPage() { }, []); const mergeRemotePeqCatalog = useCallback((items: PeqCatalogItem[]) => { - return items.map((item) => { + return items.map(item => { const cached = peqPresetCacheRef.current[item.name]; if (!cached) return item; return { @@ -433,39 +490,41 @@ export default function EQPage() { const syncPeqCatalogFromState = useCallback( ( remote: { peq?: PeqCatalogItem[]; peqSelect?: number }, - devicePeqSelect?: number, + devicePeqSelect?: number ) => { const items = mergeRemotePeqCatalog(remote.peq ?? []); setPeqItems(items as typeof peqItems); - setHeadphoneModels(items.map((item) => item.name)); + setHeadphoneModels(items.map(item => item.name)); if (items.length === 0) { setHeadphoneIdx(0); return; } const nextIdx = Math.min( Math.max(devicePeqSelect ?? remote.peqSelect ?? 0, 0), - items.length - 1, + items.length - 1 ); setHeadphoneIdx(nextIdx); }, - [mergeRemotePeqCatalog], + [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[]; + }) => { if (remote.peq !== undefined) { syncPeqCatalog({ filters: remote.filters ?? peqState?.filters ?? [], peq: remote.peq, - peqSelect: remote.peqSelect ?? deviceState?.peqSelect ?? peqState?.peqSelect, + peqSelect: + remote.peqSelect ?? deviceState?.peqSelect ?? peqState?.peqSelect, }); } - lastPeqCatalogSyncKeyRef.current = buildPeqCatalogSyncKey(remote, deviceState?.peqSelect); + lastPeqCatalogSyncKeyRef.current = buildPeqCatalogSyncKey( + remote, + deviceState?.peqSelect + ); syncPeqCatalogFromState(remote, deviceState?.peqSelect); const items = remote.peq ?? []; @@ -477,9 +536,11 @@ export default function EQPage() { const nextIdx = Math.min( Math.max(deviceState?.peqSelect ?? remote.peqSelect ?? 0, 0), - items.length - 1, + items.length - 1 + ); + const nextBands = normalizeFiltersFromPeq( + items[nextIdx] as { filters?: any[] | string } ); - const nextBands = normalizeFiltersFromPeq(items[nextIdx] as { filters?: any[] | string }); if (nextBands.length > 0) { setBandsForBothModes(nextBands); } @@ -490,14 +551,19 @@ export default function EQPage() { const target = peqItems[headphoneIdx]; if (!target?.name) return; - const confirmed = window.confirm(eqInterp(eqUi.deleteConfirm, { name: target.name })); + const confirmed = window.confirm( + eqInterp(eqUi.deleteConfirm, { name: target.name }) + ); if (!confirmed) return; try { if (isDemoMode || !api) { const nextItems = peqItems.filter((_, idx) => idx !== headphoneIdx); forgetPeqPresetCache(target.name); - applyPeqStateToUI({ peq: nextItems, peqSelect: Math.max(0, headphoneIdx - 1) }); + applyPeqStateToUI({ + peq: nextItems, + peqSelect: Math.max(0, headphoneIdx - 1), + }); toast.success(eqUi.toastDeleted); return; } @@ -523,14 +589,19 @@ export default function EQPage() { }; const openSaveBDialog = () => { - const currentName = peqItems[headphoneIdx]?.name ?? headphoneModels[headphoneIdx] ?? "Preset"; - const defaultName = getUniquePresetName(`${currentName}_B`, headphoneModels); + 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(); + const nextName = + addPresetMode === "copy" ? copyPresetName.trim() : flatPresetName.trim(); if (!nextName) { toast.error(eqUi.addPresetNameEmpty); return; @@ -556,41 +627,58 @@ export default function EQPage() { preamp: currentPeq?.preamp, canDel: currentPeq?.canDel, }, - bands.map(bandToPeqFilter), + bands.map(bandToPeqFilter) ), }; const flatPayload: PeqChangePayload = { peqChange: buildPeqPresetBody( { name: nextName, preamp: 0, canDel: 1, autoPre: 0 }, - FLAT_PRESET_FILTERS, + FLAT_PRESET_FILTERS ), }; try { - await upgradePeqChange(addPresetMode === "copy" ? copyPayload : flatPayload); + await upgradePeqChange( + addPresetMode === "copy" ? copyPayload : flatPayload + ); if (api && !isDemoMode) { const latest = await fetchEqSyncPeq(api, "addPreset"); - applyPeqStateToUI(latest as { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number }); - const createdIndex = latest.peq?.findIndex((item) => item.name === nextName) ?? -1; + 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 { - 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; - setHeadphoneModels((prev) => [...prev, nextName]); - setPeqItems((prev) => [ + 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; + setHeadphoneModels(prev => [...prev, nextName]); + setPeqItems(prev => [ ...prev, { name: nextName, - ...(addPresetMode === "copy" && copyBrand ? { brand: copyBrand } : {}), - ...(addPresetMode === "copy" && copyModel ? { model: copyModel } : {}), - ...(addPresetMode === "copy" && copyTarget ? { target: copyTarget } : {}), + ...(addPresetMode === "copy" && copyBrand + ? { brand: copyBrand } + : {}), + ...(addPresetMode === "copy" && copyModel + ? { model: copyModel } + : {}), + ...(addPresetMode === "copy" && copyTarget + ? { target: copyTarget } + : {}), ...(addPresetMode === "copy" && copyForm ? { form: copyForm } : {}), filters: localFilters, autoPre: localAutoPre, @@ -632,7 +720,7 @@ export default function EQPage() { preamp: currentPeq?.preamp, canDel: currentPeq?.canDel, }, - filters, + filters ), }; @@ -640,15 +728,21 @@ export default function EQPage() { await upgradePeqChange(payload); if (api && !isDemoMode) { const latest = await fetchEqSyncPeq(api, "saveBAsPreset"); - applyPeqStateToUI(latest as { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number }); - const createdIndex = latest.peq?.findIndex((item) => item.name === nextName) ?? -1; + 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) => [ + setHeadphoneModels(prev => [...prev, nextName]); + setPeqItems(prev => [ ...prev, { name: nextName, @@ -678,7 +772,7 @@ export default function EQPage() { preamp?: number; autoPre?: number; }, - shareCode: string, + shareCode: string ) => { const nextName = data.name?.trim(); if (!nextName) { @@ -689,7 +783,8 @@ export default function EQPage() { toast.error(eqUi.importCodeNotFound); return; } - if (!guardPeqPresetCapacity() && !headphoneModels.includes(nextName)) return; + if (!guardPeqPresetCapacity() && !headphoneModels.includes(nextName)) + return; const filters = importedFiltersToPeqFilters(data.filters); if (filters.length === 0) { @@ -712,7 +807,7 @@ export default function EQPage() { autoPre: data.autoPre ?? 0, canDel: 1, }, - filters, + filters ), }; @@ -721,14 +816,20 @@ export default function EQPage() { await upgradePeqChange(payload); if (api && !isDemoMode) { const latest = await fetchEqSyncPeq(api, "importSharedEq"); - applyPeqStateToUI(latest as { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number }); - const targetIdx = latest.peq?.findIndex((item) => item.name === nextName) ?? -1; + applyPeqStateToUI( + latest as { + peq?: Array<{ name: string; filters?: any[] | string }>; + peqSelect?: number; + } + ); + const targetIdx = + latest.peq?.findIndex(item => item.name === nextName) ?? -1; if (targetIdx >= 0) { setHeadphoneIdx(targetIdx); updateSetting({ peqSelect: targetIdx }); } } else if (isOverwrite) { - setPeqItems((prev) => + setPeqItems(prev => prev.map((item, i) => i === existingIdx ? { @@ -743,13 +844,13 @@ export default function EQPage() { autoPre: data.autoPre ?? 0, canDel: 1, } - : item, - ), + : item + ) ); setHeadphoneIdx(existingIdx); } else { - setHeadphoneModels((prev) => [...prev, nextName]); - setPeqItems((prev) => [ + setHeadphoneModels(prev => [...prev, nextName]); + setPeqItems(prev => [ ...prev, { name: nextName, @@ -796,52 +897,68 @@ export default function EQPage() { peqHydrationPendingRef.current = true; allowPeqRemoteSyncRef.current = false; try { - if (isDemoMode || !api) { - // 演示模式使用默认列表 - 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: [] }))); - setCurrentRawCurve(null); - return; - } - try { - const loadedPeq = await fetchEqSyncPeq(api, "loadHeadphones"); - 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(deviceState?.peqSelect ?? loadedPeq.peqSelect ?? 0, 0), - mergedPeq.length - 1, - ); - setHeadphoneIdx(nextIdx); - lastPeqCatalogSyncKeyRef.current = buildPeqCatalogSyncKey(loadedPeq, deviceState?.peqSelect); + if (isDemoMode || !api) { + // 演示模式使用默认列表 + 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: [] }))); + setCurrentRawCurve(null); + return; + } + try { + const loadedPeq = await fetchEqSyncPeq(api, "loadHeadphones"); + 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(deviceState?.peqSelect ?? loadedPeq.peqSelect ?? 0, 0), + mergedPeq.length - 1 + ); + setHeadphoneIdx(nextIdx); + lastPeqCatalogSyncKeyRef.current = buildPeqCatalogSyncKey( + loadedPeq, + deviceState?.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 }); + // 更新 bands 状态 + if (fixedFilters && fixedFilters.length > 0) { + setBandsForBothModes(fixedFilters); + setSelectedBandByMode({ A: 0, B: 0 }); + } + + // raw 与图表渲染统一交给 headphoneIdx/peqItems 监听逻辑处理, + // 避免首次进入页面时重复请求 modelCurve。 } - - // raw 与图表渲染统一交给 headphoneIdx/peqItems 监听逻辑处理, - // 避免首次进入页面时重复请求 modelCurve。 - + } else { + // 如果没有数据,使用默认列表 + 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: [] }))); + setCurrentRawCurve(null); } - } else { - // 如果没有数据,使用默认列表 + } catch { + // 出错时使用默认列表 const defaultModels = [ "Sennheiser HD 650", "Sennheiser HD 800", @@ -854,20 +971,6 @@ export default function EQPage() { setPeqItems(defaultModels.map(name => ({ name, filters: [] }))); setCurrentRawCurve(null); } - } catch { - // 出错时使用默认列表 - 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: [] }))); - setCurrentRawCurve(null); - } } finally { allowPeqRemoteSyncRef.current = true; skipPeqAutoSyncRef.current = true; @@ -877,13 +980,24 @@ export default function EQPage() { } } loadHeadphones(); - }, [api, isDemoMode, loadRawCurveForPeq, mergeRemotePeqCatalog, deviceState?.peqSelect]); + }, [ + api, + isDemoMode, + loadRawCurveForPeq, + mergeRemotePeqCatalog, + deviceState?.peqSelect, + ]); /** 切换耳机型号时变;不含 filters,避免编辑写回 peqItems 后反复加载 bands 并触发 peqChange 循环 */ const headphoneSwitchKey = useMemo(() => { const peq = peqItems[headphoneIdx]; if (!peq) return String(headphoneIdx); - return [headphoneIdx, peq.name ?? "", peq.brand ?? "", peq.model ?? ""].join("\u0001"); + return [ + headphoneIdx, + peq.name ?? "", + peq.brand ?? "", + peq.model ?? "", + ].join("\u0001"); }, [headphoneIdx, peqItems]); // 切换耳机型号后,从 peqItems 加载该型号的 filters(暂停一次上报避免错写) @@ -906,7 +1020,9 @@ export default function EQPage() { let cancelled = false; void (async () => { - const raw = await loadRawCurveForPeq(peq as { brand?: string; model?: string }); + const raw = await loadRawCurveForPeq( + peq as { brand?: string; model?: string } + ); if (cancelled) return; setCurrentRawCurve(raw); setRawCurveLoading(false); @@ -929,10 +1045,10 @@ export default function EQPage() { ( catalogIdx: number, filtersSource: typeof DEFAULT_BANDS, - meta?: { preamp?: number; autoPre?: number }, + meta?: { preamp?: number; autoPre?: number } ) => { const filters = filtersSource.map(bandToPeqFilter); - setPeqItems((prev) => + setPeqItems(prev => prev.map((item, i) => { if (i !== catalogIdx) return item; const merged = { @@ -947,10 +1063,10 @@ export default function EQPage() { autoPre: merged.autoPre, }); return merged; - }), + }) ); }, - [rememberPeqPresetCache], + [rememberPeqPresetCache] ); const handleSelectHeadphone = useCallback( @@ -985,26 +1101,28 @@ export default function EQPage() { persistPresetEditsToPeqItem, rememberPeqPresetCache, updateSetting, - ], + ] ); 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, + catalogIdx = headphoneIdx ) => { if (!allowPeqRemoteSyncRef.current) return; if (peqHydrationPendingRef.current) return; @@ -1028,14 +1146,22 @@ export default function EQPage() { }); } const submit = usePeqChange - ? () => upgradePeqChange({ peqChange: body } satisfies PeqChangePayload) + ? () => + upgradePeqChange({ peqChange: body } satisfies PeqChangePayload) : () => upgradePeqApply({ peqApply: body } satisfies PeqApplyPayload); submit().catch(() => { toast.error("EQ 保存失败"); }); }, delay); }, - [api, headphoneIdx, isDemoMode, persistPresetEditsToPeqItem, upgradePeqApply, upgradePeqChange], + [ + api, + headphoneIdx, + isDemoMode, + persistPresetEditsToPeqItem, + upgradePeqApply, + upgradePeqChange, + ] ); /** 点击 A/B 切换试听:固定 peqApply */ @@ -1046,7 +1172,7 @@ export default function EQPage() { setAbMode(m); schedulePeqSync(peqItems[headphoneIdx], bandsByMode[m], m, 0, "peqApply"); }, - [abMode, bandsByMode, headphoneIdx, peqItems, schedulePeqSync], + [abMode, bandsByMode, headphoneIdx, peqItems, schedulePeqSync] ); const handleCopyAndSwitchTo = useCallback( @@ -1058,7 +1184,14 @@ export default function EQPage() { setAbMode(to); schedulePeqSync(peqItems[headphoneIdx], copied, to, 0, "peqApply"); }, - [abMode, bandsByMode, copyModeParams, headphoneIdx, peqItems, schedulePeqSync], + [ + abMode, + bandsByMode, + copyModeParams, + headphoneIdx, + peqItems, + schedulePeqSync, + ] ); // 参数编辑防抖:A→peqChange,B→peqApply @@ -1073,7 +1206,14 @@ export default function EQPage() { skipBandsSyncFromAbToggleRef.current = false; return; } - schedulePeqSync(peqItems[headphoneIdx], bands, abMode, 320, "byMode", headphoneIdx); + schedulePeqSync( + peqItems[headphoneIdx], + bands, + abMode, + 320, + "byMode", + headphoneIdx + ); return () => { if (peqSyncTimerRef.current !== null) { window.clearTimeout(peqSyncTimerRef.current); @@ -1097,7 +1237,8 @@ export default function EQPage() { return () => window.removeEventListener("pointerdown", onPointerDown); }, []); - const band = bands[selectedBand] ?? DEFAULT_BANDS[selectedBand] ?? DEFAULT_BANDS[0]; + const band = + bands[selectedBand] ?? DEFAULT_BANDS[selectedBand] ?? DEFAULT_BANDS[0]; const handleApplyB = useCallback(async () => { const peq = peqItems[headphoneIdx]; @@ -1120,20 +1261,20 @@ 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 })); + 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 })), + setPeqItems(prev => + prev.map((item, i) => (i !== headphoneIdx ? item : { ...item, filters })) ); setAbMode("A"); @@ -1160,18 +1301,24 @@ export default function EQPage() { ]); const updateBand = useCallback( - (idx: number, patch: Partial) => { - setBands((prevBands) => prevBands.map((b, i) => (i === idx ? { ...b, ...patch } : b))); + (idx: number, patch: Partial<(typeof DEFAULT_BANDS)[0]>) => { + setBands(prevBands => + prevBands.map((b, i) => (i === idx ? { ...b, ...patch } : b)) + ); }, - [setBands], + [setBands] ); const clearBandSliderPreviewField = useCallback((kind: BandParamKind) => { - setBandSliderPreview((prev) => { + setBandSliderPreview(prev => { if (prev?.[kind] === undefined) return prev; const next = { ...prev }; delete next[kind]; - return next.freq !== undefined || next.gain !== undefined || next.q !== undefined ? next : null; + return next.freq !== undefined || + next.gain !== undefined || + next.q !== undefined + ? next + : null; }); }, []); @@ -1185,16 +1332,16 @@ export default function EQPage() { bandSliderDragRef.current = null; clearBandSliderPreviewField(kind); }, - [clearBandSliderPreviewField], + [clearBandSliderPreviewField] ); const handleBandSliderChange = useCallback( (kind: BandParamKind, value: number) => { if (bandSliderDragRef.current !== kind) return; - setBandSliderPreview((prev) => ({ ...prev, [kind]: value })); + setBandSliderPreview(prev => ({ ...prev, [kind]: value })); updateBand(selectedBand, { [kind]: value }); }, - [selectedBand, updateBand], + [selectedBand, updateBand] ); const bandParamDialogMeta = useMemo(() => { @@ -1207,15 +1354,18 @@ export default function EQPage() { case "freq": return { title: eqUi.paramInputTitleFreq ?? "设置频率", - hint: eqUi.paramInputHintFreq ?? "范围:20 – 20000 Hz,可使用 k 表示 kHz(如 9.5k)", + hint: + eqUi.paramInputHintFreq ?? + "范围:20 – 20000 Hz,可使用 k 表示 kHz(如 9.5k)", placeholder: "9500", inputMode: "decimal" as const, rangeMessages: { ...rangeMessages, - outOfRange: eqInterp(eqUi.paramInputOutOfRangeFreq, { - min: String(BAND_FREQ_MIN), - max: String(BAND_FREQ_MAX), - }) || `频率需在 ${BAND_FREQ_MIN} – ${BAND_FREQ_MAX} Hz 之间`, + outOfRange: + eqInterp(eqUi.paramInputOutOfRangeFreq, { + min: String(BAND_FREQ_MIN), + max: String(BAND_FREQ_MAX), + }) || `频率需在 ${BAND_FREQ_MIN} – ${BAND_FREQ_MAX} Hz 之间`, }, }; case "gain": @@ -1226,10 +1376,11 @@ export default function EQPage() { inputMode: "decimal" as const, rangeMessages: { ...rangeMessages, - outOfRange: eqInterp(eqUi.paramInputOutOfRangeGain, { - min: BAND_GAIN_MIN.toFixed(1), - max: BAND_GAIN_MAX.toFixed(1), - }) || `增益需在 ${BAND_GAIN_MIN} – ${BAND_GAIN_MAX} dB 之间`, + outOfRange: + eqInterp(eqUi.paramInputOutOfRangeGain, { + min: BAND_GAIN_MIN.toFixed(1), + max: BAND_GAIN_MAX.toFixed(1), + }) || `增益需在 ${BAND_GAIN_MIN} – ${BAND_GAIN_MAX} dB 之间`, }, }; case "q": @@ -1240,10 +1391,11 @@ export default function EQPage() { inputMode: "decimal" as const, rangeMessages: { ...rangeMessages, - outOfRange: eqInterp(eqUi.paramInputOutOfRangeQ, { - min: BAND_Q_MIN.toFixed(2), - max: BAND_Q_MAX.toFixed(2), - }) || `Q 值需在 ${BAND_Q_MIN} – ${BAND_Q_MAX} 之间`, + outOfRange: + eqInterp(eqUi.paramInputOutOfRangeQ, { + min: BAND_Q_MIN.toFixed(2), + max: BAND_Q_MAX.toFixed(2), + }) || `Q 值需在 ${BAND_Q_MIN} – ${BAND_Q_MAX} 之间`, }, }; } @@ -1261,22 +1413,32 @@ export default function EQPage() { const applyBandParamDialog = () => { if (!bandParamDialog || !bandParamDialogMeta) return; - const parsed = parseBandParamInput(bandParamDialog, bandParamInput, bandParamDialogMeta.rangeMessages); + const parsed = parseBandParamInput( + bandParamDialog, + bandParamInput, + bandParamDialogMeta.rangeMessages + ); if (!parsed.ok) { toast.error(parsed.message); return; } bandSliderDragRef.current = null; - if (bandParamDialog === "freq") updateBand(selectedBand, { freq: parsed.value }); - else if (bandParamDialog === "gain") updateBand(selectedBand, { gain: parsed.value }); + if (bandParamDialog === "freq") + updateBand(selectedBand, { freq: parsed.value }); + else if (bandParamDialog === "gain") + updateBand(selectedBand, { gain: parsed.value }); else updateBand(selectedBand, { q: parsed.value }); setBandSliderPreview(null); closeBandParamDialog(); }; - const updateCurrentPeqMeta = (patch: Partial<{ autoPre: number; preamp: number }>) => { - let updatedPeq: { name: string; autoPre?: number; preamp?: number; canDel?: number } | undefined; - setPeqItems((prev) => { + 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 }; @@ -1291,22 +1453,29 @@ export default function EQPage() { return (
-
+
{/* ── Header ── */}
- -

HP-EQ

+

+ HP-EQ +

{ + onChange={v => { if (v) { void updateSetting( - bypassOn ? { peqEnable: 1, dsp_enable: 1 } : { peqEnable: 1 }, + bypassOn ? { peqEnable: 1, dsp_enable: 1 } : { peqEnable: 1 } ); } else { void updateSetting({ peqEnable: 0 }); @@ -1320,332 +1489,416 @@ export default function EQPage() { className="px-4 pb-32 pt-3 space-y-3" labels={{ title: eqUi.enableConfirmTitle ?? "HP-EQ 未开启", - description: eqUi.enableConfirmDesc ?? "当前 HP-EQ 功能已关闭,是否开启?", + description: + eqUi.enableConfirmDesc ?? "当前 HP-EQ 功能已关闭,是否开启?", cancel: eqUi.cancel ?? "取消", confirm: eqUi.enableConfirmOk ?? "开启", }} onEnable={() => { const bypass = (deviceState?.dsp_enable ?? 0) === 0; - return updateSetting(bypass ? { peqEnable: 1, dsp_enable: 1 } : { peqEnable: 1 }); + return updateSetting( + bypass ? { peqEnable: 1, dsp_enable: 1 } : { peqEnable: 1 } + ); }} > - {/* ── Action cards: 批量编辑 / 添加耳机型号 / 分享EQ ── */} -
- - - -
- - {/* ── Headphone model selector ── */} -
-
+ {/* ── Action cards: 批量编辑 / 添加耳机型号 / 分享EQ ── */} +
- - {isHeadphoneMenuOpen && (
- {headphoneModels.map((model, idx) => { - const active = idx === headphoneIdx; - return ( - - ); - })} +
- )} -
-
- +
-
- {/* ── Frequency response chart ── */} - void handleApplyB()} - onSaveB={openSaveBDialog} - onBandDrag={(idx, patch) => setBands((prev) => prev.map((b, i) => i === idx ? { ...b, ...patch } : b))} - onBandSelect={setSelectedBand} - eqUi={eqUi} - /> - - {/* ── Band grid: 2×5 below lg, 1×10 on lg+ (typical desktop) ── */} -
-
- {bands.map((b, i) => ( - - ))} -
-
- - {/* ── Band detail card ── */} -
- {/* Filter type selector */} -
- {peqCardLabels.filters?.label ?? "滤波器"} + {/* ── Headphone model selector ── */} +
- {isFilterMenuOpen && ( + {isHeadphoneMenuOpen && (
- {FILTER_TYPES.map((type) => { - const active = normalizeFilterType(band.type) === type; + {headphoneModels.map((model, idx) => { + const active = idx === headphoneIdx; return ( ); })}
)}
-
- - {/* FREQ */} -
-
- FREQ +
+ +
- beginBandSliderDrag("freq")} - onInteractionEnd={() => endBandSliderDrag("freq")} - onChange={(v) => handleBandSliderChange("freq", Math.round(Math.pow(10, v)))} - />
- {/* GAIN */} -
-
- GAIN - + {/* ── Frequency response chart ── */} + void handleApplyB()} + onSaveB={openSaveBDialog} + onBandDrag={(idx, patch) => + setBands(prev => + prev.map((b, i) => (i === idx ? { ...b, ...patch } : b)) + ) + } + onBandSelect={setSelectedBand} + eqUi={eqUi} + /> + + {/* ── Band grid: 2×5 below lg, 1×10 on lg+ (typical desktop) ── */} +
+
+ {bands.map((b, i) => ( + + ))}
- beginBandSliderDrag("gain")} - onInteractionEnd={() => endBandSliderDrag("gain")} - onChange={(v) => handleBandSliderChange("gain", v)} - />
- {/* Q 值 */} -
-
- {peqCardLabels.q ?? "Q值"} - -
- beginBandSliderDrag("q")} - onInteractionEnd={() => endBandSliderDrag("q")} - onChange={(v) => handleBandSliderChange("q", v)} - /> -
-
- - {/* ── Total gain card ── */} -
-
-
- {peqCardLabels.preamp} - - {preampValue >= 0 ? `+${preampValue.toFixed(1)}` : preampValue.toFixed(1)} + {/* ── Band detail card ── */} +
+ {/* Filter type selector */} +
+ + {peqCardLabels.filters?.label ?? "滤波器"} - dB +
+ + + {isFilterMenuOpen && ( +
+ {FILTER_TYPES.map(type => { + const active = normalizeFilterType(band.type) === type; + return ( + + ); + })} +
+ )} +
-
- AUTO - updateCurrentPeqMeta({ autoPre: v ? 1 : 0 })} + + {/* FREQ */} +
+
+ + FREQ + + +
+ beginBandSliderDrag("freq")} + onInteractionEnd={() => endBandSliderDrag("freq")} + onChange={v => + handleBandSliderChange("freq", Math.round(Math.pow(10, v))) + } + /> +
+ + {/* GAIN */} +
+
+ + GAIN + + +
+ beginBandSliderDrag("gain")} + onInteractionEnd={() => endBandSliderDrag("gain")} + onChange={v => handleBandSliderChange("gain", v)} + /> +
+ + {/* Q 值 */} +
+
+ + {peqCardLabels.q ?? "Q值"} + + +
+ beginBandSliderDrag("q")} + onInteractionEnd={() => endBandSliderDrag("q")} + onChange={v => handleBandSliderChange("q", v)} />
- updateCurrentPeqMeta({ preamp: Number((v - 15).toFixed(1)) })} - /> -
+ + {/* ── Total gain card ── */} +
+
+
+ + {peqCardLabels.preamp} + + + {preampValue >= 0 + ? `+${preampValue.toFixed(1)}` + : preampValue.toFixed(1)} + + dB +
+
+ + AUTO + + updateCurrentPeqMeta({ autoPre: v ? 1 : 0 })} + /> +
+
+ + updateCurrentPeqMeta({ preamp: Number((v - 15).toFixed(1)) }) + } + /> +
@@ -1690,6 +1943,89 @@ export default function EQPage() { eqUi={eqUi} /> + setIsPresetManageDialogOpen(false)} + eqUi={eqUi} + onDeletePresets={async (names) => { + try { + if (isDemoMode || !api) { + const nextItems = peqItems.filter(it => !names.includes(it.name)); + names.forEach(n => forgetPeqPresetCache(n)); + applyPeqStateToUI({ + peq: nextItems, + peqSelect: Math.min(headphoneIdx, Math.max(0, nextItems.length - 1)), + }); + toast.success(eqUi.toastDeleted); + return true; + } + names.forEach(n => forgetPeqPresetCache(n)); + await api.removePeq(names); + const latest = await fetchEqSyncPeq(api, "deletePresets"); + applyPeqStateToUI(latest); + toast.success(eqUi.toastDeleted); + return true; + } catch { + toast.error(eqUi.toastDeleteFail); + return false; + } + }} + onRenamePreset={async (oldName, newName, item) => { + try { + // normalizePeqFiltersForSubmit returns filters with string type values + // (e.g. "LOW_SHELF", "PEAKING") — but the device expects numeric type. + // Must convert with getFilterType() before submitting to info.cgi. + const rawFilters = normalizePeqFiltersForSubmit(item.filters); + const filters = rawFilters.map(f => ({ + ...f, + type: getFilterType(f.type), + })); + const payload: PeqChangePayload = { + peqChange: { + name: newName, + filters, + autoPre: item.autoPre, + preamp: item.preamp, + canDel: item.canDel ?? 1, + brand: item.brand, + model: item.model, + target: item.target, + form: item.form, + }, + }; + + if (isDemoMode || !api) { + const nextItems = peqItems.map(it => + it.name === oldName ? { ...it, name: newName } : it + ); + forgetPeqPresetCache(oldName); + applyPeqStateToUI({ peq: nextItems, peqSelect: headphoneIdx }); + toast.success(eqUi.toastRenamed); + return true; + } + + // Step 1: peqChange — create new preset with new name + same EQ data + // Use api.upgradePeqChange directly (NOT the DeviceContext wrapper) + // to avoid applyPeqFiltersToState side-effect that overwrites current UI bands. + await api.upgradePeqChange(payload); + // Step 2: peqRemove — delete the old preset + forgetPeqPresetCache(oldName); + await api.removePeq([oldName]); + // Step 3: sync latest state from device + const latest = await fetchEqSyncPeq(api, "renamePreset"); + + applyPeqStateToUI(latest); + toast.success(eqUi.toastRenamed); + return true; + } catch { + toast.error(eqUi.toastRenameFail); + return false; + } + }} + /> + ) : null; + const parsedObj = + parsed && typeof parsed === "object" + ? (parsed as Record) + : null; const parametricEqRaw = parsedObj?.parametric_eq; if (!parametricEqRaw || typeof parametricEqRaw !== "object") { throw new Error("parametric_eq missing"); } const parametricEq = parametricEqRaw as { - filters?: Array<{ type: string | number; fc: number; gain: number; q: number }>; + filters?: Array<{ + type: string | number; + fc: number; + gain: number; + q: number; + }>; preamp?: number; }; - const filters = (Array.isArray(parametricEq.filters) ? parametricEq.filters : []) + const filters = ( + Array.isArray(parametricEq.filters) ? parametricEq.filters : [] + ) .slice(0, 10) - .map((item) => ({ + .map(item => ({ type: getFilterType(item.type), fc: Number(Number(item.fc).toFixed(2)), gain: Number(Number(item.gain).toFixed(2)), @@ -1737,7 +2083,7 @@ export default function EQPage() { autoPre: 0, canDel: 1, }, - filters, + filters ), }; @@ -1750,14 +2096,21 @@ export default function EQPage() { await upgradePeqChange(postPeq); if (api && !isDemoMode) { const latest = await fetchEqSyncPeq(api, "addCatalogPreset"); - applyPeqStateToUI(latest as { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number }); - const targetIdx = latest.peq?.findIndex((item) => item.name === createdName) ?? -1; + applyPeqStateToUI( + latest as { + peq?: Array<{ name: string; filters?: any[] | string }>; + peqSelect?: number; + } + ); + const targetIdx = + latest.peq?.findIndex(item => item.name === createdName) ?? + -1; if (targetIdx >= 0) { setHeadphoneIdx(targetIdx); updateSetting({ peqSelect: targetIdx }); } } else if (isOverwrite) { - setPeqItems((prev) => + setPeqItems(prev => prev.map((item, i) => i === existingIdx ? { @@ -1772,13 +2125,13 @@ export default function EQPage() { autoPre: postPeq.peqChange.autoPre ?? 0, canDel: postPeq.peqChange.canDel ?? 1, } - : item, - ), + : item + ) ); setHeadphoneIdx(existingIdx); } else { let nextIndex = 0; - setPeqItems((prev) => [ + setPeqItems(prev => [ ...prev, { name: createdName, @@ -1792,7 +2145,7 @@ export default function EQPage() { canDel: postPeq.peqChange.canDel ?? 1, }, ]); - setHeadphoneModels((prev) => { + setHeadphoneModels(prev => { nextIndex = prev.length; return [...prev, createdName]; }); @@ -1817,11 +2170,15 @@ export default function EQPage() { title={eqUi.overwritePresetTitle ?? "Preset name already exists"} description={ overwritePresetDialog - ? eqInterp(eqUi.overwritePresetDesc, { name: overwritePresetDialog.name }) + ? eqInterp(eqUi.overwritePresetDesc, { + name: overwritePresetDialog.name, + }) : "" } cancelLabel={eqUi.cancel} - confirmLabel={eqUi.overwritePresetConfirm ?? eqUi.confirmButton ?? eqUi.save} + confirmLabel={ + eqUi.overwritePresetConfirm ?? eqUi.confirmButton ?? eqUi.save + } onConfirm={() => { void overwritePresetDialog?.onConfirm(); }} diff --git a/client/src/pages/eq/components/PeqPresetManageDialog.tsx b/client/src/pages/eq/components/PeqPresetManageDialog.tsx new file mode 100644 index 0000000..3c81cfc --- /dev/null +++ b/client/src/pages/eq/components/PeqPresetManageDialog.tsx @@ -0,0 +1,350 @@ +import { useMemo, useState } from "react"; +import { X, Trash2, Pencil, Check, Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import type { PeqEqUi } from "../constants"; + +/* ── Custom checkbox ── */ +function Checkbox({ + checked, + onChange, + className, +}: { + checked: boolean; + onChange: () => void; + className?: string; +}) { + return ( + + ); +} + +type PeqItem = { + name: string; + filters?: any[] | string; + autoPre?: number; + preamp?: number; + canDel?: number; + brand?: string; + model?: string; + target?: string; + form?: string; +}; + +/** + * Preset management dialog. + * + * Behavior: + * - Delete (single / batch): calls onDeletePresets callback with preset names, + * which should call info.cgi with { peqRemove: names } then sync. + * - Rename: calls onRenamePreset(oldName, newName, item) callback which + * should: 1) peqChange with new name + same EQ data 2) peqRemove old name. + */ +export function PeqPresetManageDialog({ + open, + items, + selectedIdx, + onClose, + eqUi, + onDeletePresets, + onRenamePreset, +}: { + open: boolean; + items: PeqItem[]; + selectedIdx: number; + onClose: () => void; + eqUi: PeqEqUi; + /** + * Called with preset names to delete. Should call api.removePeq(names) then + * fetchEqSyncPeq + applyPeqStateToUI. Return true on success, false on failure. + */ + onDeletePresets: (names: string[]) => Promise; + /** + * Called to rename a preset. Implementation should: + * 1) upgradePeqChange({ peqChange: { name: newName, filters, ...rest } }) + * 2) api.removePeq([oldName]) + * 3) fetchEqSyncPeq + applyPeqStateToUI + * Return true on success, false on failure. + */ + onRenamePreset: (oldName: string, newName: string, item: PeqItem) => Promise; +}) { + const [checked, setChecked] = useState>({}); + const [editingIdx, setEditingIdx] = useState(null); + const [editingName, setEditingName] = useState(""); + + const [deleting, setDeleting] = useState(false); + const [renaming, setRenaming] = useState(false); + + const checkedIdxs = useMemo( + () => + Object.entries(checked) + .filter(([, v]) => v) + .map(([k]) => Number(k)), + [checked] + ); + + const allChecked = items.length > 0 && checkedIdxs.length === items.length; + + if (!open) return null; + + const toggleAll = () => { + if (allChecked) { + setChecked({}); + return; + } + const next: Record = {}; + for (let i = 0; i < items.length; i++) next[i] = true; + setChecked(next); + }; + + const toggleOne = (idx: number) => { + setChecked(prev => ({ ...prev, [idx]: !prev[idx] })); + }; + + const beginRename = (idx: number) => { + const name = items[idx]?.name ?? ""; + setEditingIdx(idx); + setEditingName(name); + }; + + const commitRename = async () => { + if (editingIdx === null) return; + const nextName = editingName.trim(); + if (!nextName) { + toast.error(eqUi.renameEmptyName); + return; + } + const oldItem = items[editingIdx]; + if (!oldItem) return; + if (nextName === oldItem.name) { + setEditingIdx(null); + setEditingName(""); + return; + } + if (items.some((it, i) => i !== editingIdx && it.name === nextName)) { + toast.error(eqUi.renameNameExists); + return; + } + + setRenaming(true); + try { + const ok = await onRenamePreset(oldItem.name, nextName, oldItem); + if (ok) { + setEditingIdx(null); + setEditingName(""); + } + } finally { + setRenaming(false); + } + }; + + const confirmAndDelete = async (idxs: number[]) => { + const uniq = Array.from(new Set(idxs)).filter( + i => i >= 0 && i < items.length + ); + if (uniq.length === 0) return; + + const names = uniq.map(i => items[i].name); + + // Confirmation dialog + const confirmMsg = + names.length === 1 + ? eqUi.deletePresetConfirm.replace("{{name}}", names[0]) + : eqUi.batchDeleteConfirm.replace("{{count}}", String(names.length)); + if (!window.confirm(confirmMsg)) return; + + setDeleting(true); + try { + const ok = await onDeletePresets(names); + if (ok) { + setChecked({}); + if (editingIdx !== null && uniq.includes(editingIdx)) { + setEditingIdx(null); + setEditingName(""); + } + } + } finally { + setDeleting(false); + } + }; + + return ( +
+
e.stopPropagation()} + > +
+

+ 管理 EQ 预设 +

+ +
+ +
+ + + +
+ +
+ {items.length === 0 ? ( +
+ 暂无预设 +
+ ) : ( + items.map((item, idx) => { + const active = idx === selectedIdx; + const isEditing = idx === editingIdx; + return ( +
+ toggleOne(idx)} + /> + +
+ {isEditing ? ( +
+ setEditingName(e.target.value)} + className="h-9 min-w-0 flex-1 rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[14px] text-white/90 outline-none placeholder:text-white/30 focus:border-[#00FFF6]/40" + /> + + +
+ ) : ( +
+ {item.name} +
+ )} +
+ + {!isEditing && ( +
+ + +
+ )} +
+ ); + }) + )} +
+
+
+ ); +} diff --git a/package.json b/package.json index 5de858c..1ffd44a 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,6 @@ "build:test": "pnpm build:test:legacy && pnpm build:test:modern", "build:test:modern": "DEPLOY_ENV=test vite build", "build:test:legacy": "DEPLOY_ENV=test vite build --config vite.legacy.config.ts", - "start": "NODE_ENV=production tsx server/index.ts", "preview": "vite preview --host", "check": "tsc --noEmit", "format": "prettier --write ." @@ -51,7 +50,6 @@ "cmdk": "^1.1.1", "echarts": "^6.0.0", "embla-carousel-react": "^8.6.0", - "express": "^4.21.2", "framer-motion": "^12.23.22", "input-otp": "^1.4.2", "lucide-react": "^0.453.0", @@ -75,7 +73,6 @@ "@builder.io/vite-plugin-jsx-loc": "^0.1.1", "@tailwindcss/typography": "^0.5.15", "@tailwindcss/vite": "^4.1.3", - "@types/express": "4.17.21", "@types/google.maps": "^3.58.1", "@types/node": "^24.7.0", "@types/react": "^19.2.1", @@ -90,7 +87,6 @@ "tailwindcss": "^4.1.14", "tailwindcss3": "npm:tailwindcss@^3.4.17", "terser": "^5.46.1", - "tsx": "^4.19.1", "tw-animate-css": "^1.4.0", "typescript": "5.6.3", "vite": "^7.1.7", diff --git a/patches/wouter@3.7.1.patch b/patches/wouter@3.7.1.patch deleted file mode 100644 index 133e386..0000000 --- a/patches/wouter@3.7.1.patch +++ /dev/null @@ -1,28 +0,0 @@ -diff --git a/esm/index.js b/esm/index.js -index c83bc63a2c10431fb62e25b7d490656a3796f301..bcae513cc20a4be6c38dc116e0b8d9bacda62b5b 100644 ---- a/esm/index.js -+++ b/esm/index.js -@@ -338,6 +338,23 @@ const Switch = ({ children, location }) => { - const router = useRouter(); - const [originalLocation] = useLocationFromRouter(router); - -+ // Collect all route paths to window object -+ if (typeof window !== 'undefined') { -+ if (!window.__WOUTER_ROUTES__) { -+ window.__WOUTER_ROUTES__ = []; -+ } -+ -+ const allChildren = flattenChildren(children); -+ allChildren.forEach((element) => { -+ if (isValidElement(element) && element.props.path) { -+ const path = element.props.path; -+ if (!window.__WOUTER_ROUTES__.includes(path)) { -+ window.__WOUTER_ROUTES__.push(path); -+ } -+ } -+ }); -+ } -+ - for (const element of flattenChildren(children)) { - let match = 0; - diff --git a/server/index.ts b/server/index.ts deleted file mode 100644 index 2896f08..0000000 --- a/server/index.ts +++ /dev/null @@ -1,48 +0,0 @@ -import express from "express"; -import { createServer } from "http"; -import path from "path"; -import { fileURLToPath } from "url"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const DEPLOY_PREFIX = "/x9/v2"; - -async function startServer() { - const app = express(); - const server = createServer(app); - - const distRoot = path.resolve(__dirname, "..", "dist"); - const modernPath = path.join(distRoot, "modern"); - const legacyPath = path.join(distRoot, "legacy"); - - app.use(`${DEPLOY_PREFIX}/modern`, express.static(modernPath)); - app.use(`${DEPLOY_PREFIX}/legacy`, express.static(legacyPath)); - app.use(DEPLOY_PREFIX, express.static(distRoot)); - - const sendModernSpa = (_req: express.Request, res: express.Response) => { - res.sendFile(path.join(modernPath, "i.modern.html")); - }; - const sendLegacySpa = (_req: express.Request, res: express.Response) => { - res.sendFile(path.join(legacyPath, "i.legacy.html")); - }; - - app.get(`${DEPLOY_PREFIX}/modern/*`, sendModernSpa); - app.get(`${DEPLOY_PREFIX}/legacy/*`, sendLegacySpa); - - app.get([DEPLOY_PREFIX, `${DEPLOY_PREFIX}/`, `${DEPLOY_PREFIX}/i.html`], (_req, res) => { - res.sendFile(path.join(distRoot, "i.html")); - }); - - app.get("*", (_req, res) => { - res.redirect(302, `${DEPLOY_PREFIX}/i.html`); - }); - - const port = process.env.PORT || 3000; - - server.listen(port, () => { - console.log(`Server running on http://localhost:${port}${DEPLOY_PREFIX}/i.html`); - }); -} - -startServer().catch(console.error); diff --git a/shared/const.ts b/shared/const.ts deleted file mode 100644 index 98b0123..0000000 --- a/shared/const.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const COOKIE_NAME = "app_session_id"; -export const ONE_YEAR_MS = 1000 * 60 * 60 * 24 * 365; diff --git a/tsconfig.json b/tsconfig.json index a0203ee..abee6b9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,5 @@ { - "include": ["client/src/**/*", "shared/**/*", "server/**/*"], + "include": ["client/src/**/*"], "exclude": ["node_modules", "build", "dist", "**/*.test.ts"], "compilerOptions": { "incremental": true, @@ -16,8 +16,7 @@ "baseUrl": ".", "types": ["node", "vite/client"], "paths": { - "@/*": ["./client/src/*"], - "@shared/*": ["./shared/*"] + "@/*": ["./client/src/*"] } } } diff --git a/vite.config.ts b/vite.config.ts index 245158d..8ea127e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -62,7 +62,6 @@ export default defineConfig({ resolve: { alias: { "@": path.resolve(import.meta.dirname, "client", "src"), - "@shared": path.resolve(import.meta.dirname, "shared"), "@assets": path.resolve(import.meta.dirname, "attached_assets"), }, }, diff --git a/vite.legacy.config.ts b/vite.legacy.config.ts index de2968e..7a1ec20 100644 --- a/vite.legacy.config.ts +++ b/vite.legacy.config.ts @@ -59,7 +59,6 @@ export default defineConfig({ resolve: { alias: { "@": path.resolve(PROJECT_ROOT, "client", "src"), - "@shared": path.resolve(PROJECT_ROOT, "shared"), "@assets": path.resolve(PROJECT_ROOT, "attached_assets"), }, dedupe: ["react", "react-dom"],