diff --git a/client/src/lib/luxsinApi.ts b/client/src/lib/luxsinApi.ts index cc713d5..f443c8f 100644 --- a/client/src/lib/luxsinApi.ts +++ b/client/src/lib/luxsinApi.ts @@ -142,6 +142,7 @@ export interface PeqState { canDel?: number; brand?: string; model?: string; + form?: string; }>; } diff --git a/client/src/locales/data-en.json b/client/src/locales/data-en.json index ac0c5d2..30ac092 100644 --- a/client/src/locales/data-en.json +++ b/client/src/locales/data-en.json @@ -265,6 +265,7 @@ "addPresetNameExists": "That name already exists. Choose another.", "toastAddPresetOk": "Preset added", "toastAddPresetFail": "Failed to add preset", + "toastPresetLimitReached": "Preset limit reached ({{max}}). Delete some presets before adding more.", "cancel": "Cancel", "save": "Save", "tabBrands": "Brands", diff --git a/client/src/locales/data-zh-HK.json b/client/src/locales/data-zh-HK.json index ad50abd..cb073d4 100644 --- a/client/src/locales/data-zh-HK.json +++ b/client/src/locales/data-zh-HK.json @@ -201,6 +201,7 @@ "addPresetNameExists": "名稱已存在,請修改後再儲存", "toastAddPresetOk": "已新增預設", "toastAddPresetFail": "新增預設失敗", + "toastPresetLimitReached": "預設已達上限({{max}} 個),請先清理後再操作", "cancel": "取消", "save": "儲存", "tabBrands": "品牌", diff --git a/client/src/locales/data-zh.json b/client/src/locales/data-zh.json index 85afefd..a1efaac 100644 --- a/client/src/locales/data-zh.json +++ b/client/src/locales/data-zh.json @@ -201,6 +201,7 @@ "addPresetNameExists": "名称已存在,请修改后再保存", "toastAddPresetOk": "已新增预设", "toastAddPresetFail": "新增预设失败", + "toastPresetLimitReached": "预设已达上限({{max}} 个),请先清理后再操作", "cancel": "取消", "save": "保存", "tabBrands": "品牌", diff --git a/client/src/pages/EQPage.tsx b/client/src/pages/EQPage.tsx index ae144e4..e609c5a 100644 --- a/client/src/pages/EQPage.tsx +++ b/client/src/pages/EQPage.tsx @@ -52,6 +52,7 @@ import { DEFAULT_BANDS, FILTER_TYPES, FLAT_PRESET_FILTERS, + PEQ_PRESET_MAX, } from "./eq/eqConstants"; import { eqInterp, @@ -64,7 +65,13 @@ import { normalizeBandFreqHz, parseBandParamInput, } from "./eq/eqFormatters"; -import { bandToPeqFilter, buildPeqCatalogSyncKey, cloneBands, getUniquePresetName } from "./eq/peqMappers"; +import { + bandToPeqFilter, + buildPeqCatalogSyncKey, + cloneBands, + fetchEqSyncPeq, + getUniquePresetName, +} from "./eq/peqMappers"; import type { BandParamKind, PeqCatalogItem, PeqEqUi, PeqPresetLocalCache } from "./eq/types"; /* ── Main Component ── */ @@ -137,6 +144,7 @@ export default function EQPage() { gain?: number; q?: number; } | null>(null); + const bandSliderDragRef = useRef(null); const [isBatchEditDialogOpen, setIsBatchEditDialogOpen] = useState(false); const [batchEditText, setBatchEditText] = useState(""); type BrandDrawerTab = "brands" | "models" | "target"; @@ -282,7 +290,17 @@ export default function EQPage() { } }, [eqUi]); + 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.`, + ); + return false; + }; + const openAddHeadsetCatalog = () => { + if (!guardPeqPresetCapacity()) return; setIsBrandDrawerOpen(true); setBrandDrawerTab("brands"); setBrandSearchQuery(""); @@ -632,7 +650,7 @@ export default function EQPage() { delete peqPresetCacheRef.current[target.name]; await api.removePeq([target.name]); - const latest = await api.getPeqState(); + const latest = await fetchEqSyncPeq(api, "deleteHeadphone"); applyPeqStateToUI(latest); toast.success(eqUi.toastDeleted); } catch { @@ -641,6 +659,7 @@ export default function EQPage() { }; const openAddPresetDialog = () => { + if (!guardPeqPresetCapacity()) return; const existingNames = headphoneModels; const currentName = headphoneModels[headphoneIdx] ?? "Preset"; setCopyPresetName(getUniquePresetName(currentName, existingNames)); @@ -669,11 +688,13 @@ export default function EQPage() { const copyBrand = currentPeq?.brand?.trim() ?? ""; const copyModel = currentPeq?.model?.trim() ?? ""; + const copyForm = currentPeq?.form?.trim() ?? ""; const copyPayload: PeqChangePayload = { peqChange: { name: nextName, ...(copyBrand ? { brand: copyBrand } : {}), ...(copyModel ? { model: copyModel } : {}), + ...(copyForm ? { form: copyForm } : {}), filters: bands.map(bandToPeqFilter), autoPre: currentPeq?.autoPre ?? 0, preamp: currentPeq?.preamp ?? 0, @@ -694,7 +715,7 @@ export default function EQPage() { try { await upgradePeqChange(addPresetMode === "copy" ? copyPayload : flatPayload); if (api && !isDemoMode) { - const latest = await api.getPeqState(); + 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; if (createdIndex >= 0) { @@ -714,6 +735,7 @@ export default function EQPage() { name: nextName, ...(addPresetMode === "copy" && copyBrand ? { brand: copyBrand } : {}), ...(addPresetMode === "copy" && copyModel ? { model: copyModel } : {}), + ...(addPresetMode === "copy" && copyForm ? { form: copyForm } : {}), filters: localFilters, autoPre: localAutoPre, preamp: localPreamp, @@ -755,7 +777,7 @@ export default function EQPage() { try { await upgradePeqChange(payload); if (api && !isDemoMode) { - const latest = await api.getPeqState(); + 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; if (createdIndex >= 0) { @@ -902,7 +924,7 @@ export default function EQPage() { return; } try { - const loadedPeq = await api.getPeqState(); + const loadedPeq = await fetchEqSyncPeq(api, "loadHeadphones"); if (loadedPeq.peq && loadedPeq.peq.length > 0) { syncPeqCatalog(loadedPeq); setHeadphoneModels(loadedPeq.peq.map((h) => h.name)); @@ -1259,6 +1281,37 @@ export default function EQPage() { [setBands], ); + const clearBandSliderPreviewField = useCallback((kind: BandParamKind) => { + 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; + }); + }, []); + + const beginBandSliderDrag = useCallback((kind: BandParamKind) => { + bandSliderDragRef.current = kind; + }, []); + + const endBandSliderDrag = useCallback( + (kind: BandParamKind) => { + if (bandSliderDragRef.current !== kind) return; + bandSliderDragRef.current = null; + clearBandSliderPreviewField(kind); + }, + [clearBandSliderPreviewField], + ); + + const handleBandSliderChange = useCallback( + (kind: BandParamKind, value: number) => { + if (bandSliderDragRef.current !== kind) return; + setBandSliderPreview((prev) => ({ ...prev, [kind]: value })); + updateBand(selectedBand, { [kind]: value }); + }, + [selectedBand, updateBand], + ); + const bandParamDialogMeta = useMemo(() => { if (!bandParamDialog) return null; const rangeMessages = { @@ -1328,9 +1381,11 @@ export default function EQPage() { 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 }); else updateBand(selectedBand, { q: parsed.value }); + setBandSliderPreview(null); closeBandParamDialog(); }; @@ -1604,11 +1659,9 @@ export default function EQPage() { min={Math.log10(BAND_FREQ_MIN)} max={Math.log10(BAND_FREQ_MAX)} step={0.005} - onChange={(v) => { - const freq = Math.round(Math.pow(10, v)); - setBandSliderPreview((prev) => ({ ...prev, freq })); - updateBand(selectedBand, { freq }); - }} + onInteractionStart={() => beginBandSliderDrag("freq")} + onInteractionEnd={() => endBandSliderDrag("freq")} + onChange={(v) => handleBandSliderChange("freq", Math.round(Math.pow(10, v)))} /> @@ -1630,10 +1683,9 @@ export default function EQPage() { min={BAND_GAIN_MIN} max={BAND_GAIN_MAX} step={0.1} - onChange={(v) => { - setBandSliderPreview((prev) => ({ ...prev, gain: v })); - updateBand(selectedBand, { gain: v }); - }} + onInteractionStart={() => beginBandSliderDrag("gain")} + onInteractionEnd={() => endBandSliderDrag("gain")} + onChange={(v) => handleBandSliderChange("gain", v)} /> @@ -1655,10 +1707,9 @@ export default function EQPage() { min={BAND_Q_MIN} max={BAND_Q_MAX} step={0.01} - onChange={(v) => { - setBandSliderPreview((prev) => ({ ...prev, q: v })); - updateBand(selectedBand, { q: v }); - }} + onInteractionStart={() => beginBandSliderDrag("q")} + onInteractionEnd={() => endBandSliderDrag("q")} + onChange={(v) => handleBandSliderChange("q", v)} /> @@ -2189,7 +2240,7 @@ export default function EQPage() { await upgradePeqChange(postPeq); const createdName = postPeq.peqChange.name; if (api && !isDemoMode) { - const latest = await api.getPeqState(); + const latest = await fetchEqSyncPeq(api, "addCatalogPreset"); applyPeqStateToUI(latest as { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number }); const createdIndex = latest.peq?.findIndex((item) => item.name === createdName) ?? -1; if (createdIndex >= 0) { diff --git a/client/src/pages/eq/components/EqPrimitives.tsx b/client/src/pages/eq/components/EqPrimitives.tsx index 729ca92..4e64aeb 100644 --- a/client/src/pages/eq/components/EqPrimitives.tsx +++ b/client/src/pages/eq/components/EqPrimitives.tsx @@ -15,6 +15,8 @@ export function CyanSlider({ max, step = 1, onChange, + onInteractionStart, + onInteractionEnd, disabled = false, }: { value: number; @@ -22,6 +24,8 @@ export function CyanSlider({ max: number; step?: number; onChange: (v: number) => void; + onInteractionStart?: () => void; + onInteractionEnd?: () => void; disabled?: boolean; }) { const fillPct = Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100)); @@ -43,8 +47,12 @@ export function CyanSlider({ value={value} className="cyan-slider relative z-10" disabled={disabled} + onPointerDown={() => onInteractionStart?.()} + onFocus={() => onInteractionStart?.()} + onPointerUp={() => onInteractionEnd?.()} + onPointerCancel={() => onInteractionEnd?.()} + onBlur={() => onInteractionEnd?.()} onChange={(e) => onChange(Number(e.target.value))} - onInput={(e) => onChange(Number(e.currentTarget.value))} /> ); diff --git a/client/src/pages/eq/eqConstants.ts b/client/src/pages/eq/eqConstants.ts index 7cfb5c5..9f32ff9 100644 --- a/client/src/pages/eq/eqConstants.ts +++ b/client/src/pages/eq/eqConstants.ts @@ -11,6 +11,9 @@ export const BAND_GAIN_MAX = 15; export const BAND_Q_MIN = 0.1; export const BAND_Q_MAX = 10; +/** Maximum headphone PEQ presets stored on device. */ +export const PEQ_PRESET_MAX = 50; + export const BAND_PARAM_VALUE_BOX_STYLE: CSSProperties = { background: "rgba(44,44,46,0.9)", border: "1px solid rgba(255,255,255,0.08)", diff --git a/client/src/pages/eq/peqMappers.ts b/client/src/pages/eq/peqMappers.ts index 4a64d78..f5ae07a 100644 --- a/client/src/pages/eq/peqMappers.ts +++ b/client/src/pages/eq/peqMappers.ts @@ -6,6 +6,21 @@ export function cloneBands(source: EqBand[]) { return source.map((band) => ({ ...band })); } +/** Dev: log decoded syncPeq JSON after EQ page fetches device PEQ state. */ +export function logEqSyncPeqDev(source: string, data: PeqState): void { + if (!import.meta.env.DEV) return; + console.log(`[EQ syncPeq] ${source}`, data); +} + +export async function fetchEqSyncPeq( + api: { getPeqState: () => Promise }, + source: string, +): Promise { + const data = await api.getPeqState(); + logEqSyncPeqDev(source, data); + return data; +} + export function buildPeqCatalogSyncKey( remote: Pick | null | undefined, devicePeqSelect?: number, diff --git a/vite.config.ts b/vite.config.ts index 829a430..02d949b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,6 +8,23 @@ import { vitePluginManusRuntime } from "vite-plugin-manus-runtime"; const PROJECT_ROOT = import.meta.dirname; +/** Dev: / → i.modern.html; /i.html → UA router entry */ +function vitePluginModernDevEntry(): Plugin { + return { + name: "modern-dev-entry", + apply: "serve", + configureServer(server) { + server.middlewares.use((req, _res, next) => { + const url = (req.url ?? "").split("?")[0]; + if (url === "/" || url === "/index.html") { + req.url = "/i.modern.html"; + } + next(); + }); + }, + }; +} + /** 构建后将统一入口 i.html 复制到 dist/ 根目录 */ function vitePluginCopyRouterEntry(): Plugin { const src = path.resolve(import.meta.dirname, "client", "i.html"); @@ -24,6 +41,7 @@ function vitePluginCopyRouterEntry(): Plugin { } const plugins = [ + vitePluginModernDevEntry(), react(), tailwindcss(), jsxLocPlugin(), diff --git a/vite.legacy.config.ts b/vite.legacy.config.ts index 3a0d67a..8462a12 100644 --- a/vite.legacy.config.ts +++ b/vite.legacy.config.ts @@ -79,7 +79,23 @@ export default defineConfig({ input: { i: path.resolve(PROJECT_ROOT, "client", "i.legacy.html"), }, + output: { + manualChunks(id) { + if (!id.includes("node_modules")) return; + // Match modern build: echarts is lazy-loaded on EQ/Effects pages. + if ( + id.includes("/echarts/") || + id.includes("/zrender/") || + id.includes("\\echarts\\") || + id.includes("\\zrender\\") + ) { + return "echarts-vendor"; + } + }, + }, }, + // streamdown pulls @shikijs/langs + mermaid; both lazy-loaded with AIPage. + chunkSizeWarningLimit: 15000, }, server: { port: 5173,