From 0e7670c2154e2da2f5d084b8548cc84ae82eb92e Mon Sep 17 00:00:00 2001 From: yangy Date: Fri, 17 Apr 2026 17:57:39 +0800 Subject: [PATCH] Refactor Vite configuration for improved chunking strategy and caching. Enhance DeviceContext with new upgradePeqChange method for PEQ updates. Update audio page to support localized labels and dynamic volume control. Introduce new filter types and improve EQPage functionality with enhanced frequency response visualization. --- client/index.html | 3 - client/src/contexts/DeviceContext.tsx | 14 +- client/src/lib/luxsinApi.ts | 53 +- client/src/lib/peqAudio.ts | 50 +- client/src/locales/data-en.json | 4 +- client/src/locales/data-zh-HK.json | 4 +- client/src/locales/data-zh.json | 4 +- client/src/pages/AudioPage.tsx | 111 +++- client/src/pages/EQPage.tsx | 819 +++++++++++++++++++++++--- client/src/pages/Home.tsx | 134 ++++- client/src/pages/SelectPage.tsx | 15 + vite.config.ts | 45 +- 12 files changed, 1103 insertions(+), 153 deletions(-) diff --git a/client/index.html b/client/index.html index d34059e..ed884c3 100644 --- a/client/index.html +++ b/client/index.html @@ -4,9 +4,6 @@ Luxsin X8 Controller - - -
diff --git a/client/src/contexts/DeviceContext.tsx b/client/src/contexts/DeviceContext.tsx index 5027511..f8eac7d 100644 --- a/client/src/contexts/DeviceContext.tsx +++ b/client/src/contexts/DeviceContext.tsx @@ -4,6 +4,7 @@ import { LuxsinAPI, MOCK_DEVICE_STATE, MOCK_PEQ_STATE, + PeqChangePayload, PeqFilter, PeqState, } from "@/lib/luxsinApi"; @@ -25,6 +26,7 @@ interface DeviceContextType { api: LuxsinAPI | null; updateSetting: (params: Record) => Promise; updatePeq: (filters: PeqFilter[]) => Promise; + upgradePeqChange: (payload: PeqChangePayload) => Promise; // Optimistic state updaters setVolume: (v: number) => void; setInput: (v: number) => void; @@ -149,6 +151,16 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) { setPeqState({ filters }); }, [api, isDemoMode]); + const upgradePeqChange = useCallback(async (payload: PeqChangePayload) => { + if (isDemoMode) { + setPeqState((prev) => (prev ? { ...prev, filters: payload.peqChange.filters } : prev)); + return; + } + if (!api) return; + await api.upgradePeqChange(payload); + setPeqState((prev) => (prev ? { ...prev, filters: payload.peqChange.filters } : prev)); + }, [api, isDemoMode]); + // Optimistic setters const setVolume = useCallback((v: number) => { setDeviceState(prev => prev ? { ...prev, volume: v } : prev); @@ -199,7 +211,7 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) { isConnected, isConnecting, isDemoMode, setDemoMode, deviceState, peqState, lastUpdated, error, connect, disconnect, refresh, api, - updateSetting, updatePeq, + updateSetting, updatePeq, upgradePeqChange, setVolume, setInput, setOutput, setBalance, }}> {children} diff --git a/client/src/lib/luxsinApi.ts b/client/src/lib/luxsinApi.ts index c3e14d8..5704052 100644 --- a/client/src/lib/luxsinApi.ts +++ b/client/src/lib/luxsinApi.ts @@ -37,7 +37,13 @@ export function decodeCustomBase64(encoded: string): string { } export function encodeCustomBase64(data: string): string { - const standard = btoa(data); + // btoa only supports Latin1; convert UTF-8 bytes to a binary string first. + const bytes = new TextEncoder().encode(data); + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + const standard = btoa(binary); let translated = ""; for (let i = 0; i < standard.length; i++) { const char = standard.charAt(i); @@ -61,6 +67,8 @@ export interface DeviceState { output: number; audioFormat: string; pcm: number; + hdmimutepolar: number; + hdmiType: number; vu: number; vuSensor: number; vu_count: number; @@ -115,12 +123,29 @@ export interface PeqFilter { export interface PeqState { filters: PeqFilter[]; + peqSelect?: number; peq?: Array<{ name: string; - filters?: PeqFilter[]; + filters?: PeqFilter[] | string; + autoPre?: number; + preamp?: number; + canDel?: number; + brand?: string; + model?: string; }>; } +/** POST body for `/dev/info.cgi` — full peq preset update (custom base64 `json` field). */ +export interface PeqChangePayload { + peqChange: { + name: string; + filters: PeqFilter[]; + autoPre?: number; + preamp?: number; + canDel?: number; + }; +} + // ============================================================ // Input/Output Labels // ============================================================ @@ -183,6 +208,28 @@ export class LuxsinAPI { }); } + /** Full peq preset: name, filters, autoPre, preamp, canDel — same encoding as legacy `upgradePeq`. */ + async upgradePeqChange(body: PeqChangePayload): Promise { + const payload = JSON.stringify(body); + const encoded = encodeCustomBase64(payload); + await fetch(`${this.baseUrl}/dev/info.cgi`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: `json=${encodeURIComponent(encoded)}`, + }); + } + + /** Remove one or more headphone PEQ profiles. */ + async removePeq(names: string[]): Promise { + const payload = JSON.stringify({ peqRemove: names }); + const encoded = encodeCustomBase64(payload); + await fetch(`${this.baseUrl}/dev/info.cgi`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" }, + body: `json=${encodeURIComponent(encoded)}`, + }); + } + setVolume(volume: number) { return this.setSetting({ volume }); } setInput(input: number) { return this.setSetting({ input }); } setOutput(output: number) { return this.setSetting({ output }); } @@ -227,6 +274,8 @@ export const MOCK_DEVICE_STATE: DeviceState = { output: 0, audioFormat: "PCM 44.1 KHz", pcm: 1, + hdmimutepolar: 0, + hdmiType: 0, vu: 0, vuSensor: 0, vu_count: 5, diff --git a/client/src/lib/peqAudio.ts b/client/src/lib/peqAudio.ts index 2101636..5c3b1e3 100644 --- a/client/src/lib/peqAudio.ts +++ b/client/src/lib/peqAudio.ts @@ -13,8 +13,11 @@ export const TYPE_BANDPASS = 2; export const TYPE_NOTCH = 3; export const TYPE_ALLPASS = 7; -// Filter type mapping -export function getFilterType(typeName: string): number { +// Filter type mapping (device uses numeric type; pass-through when already a number) +export function getFilterType(typeName: string | number): number { + if (typeof typeName === "number" && Number.isFinite(typeName)) { + return typeName; + } switch (typeName) { case 'LPF': case 'LOW_PASS': @@ -309,10 +312,53 @@ export function visualizeResponse(coeffList: Coeff[], fs: number): [number[], nu } }); + if (validCoeffList.length === 0) { + const flat = f.map(() => 0); + return [semilogf, flat]; + } + const overall = getFreqznList(validCoeffList, fs, f); return [semilogf, overall]; } +/** Same log-spaced grid as visualizeResponse (20 Hz … 20 kHz, 349 points). */ +export function getPeqLogSpacedFreqs(): number[] { + const n = 349; + const startF = 20; + const logStep = (Math.log10(20000) - Math.log10(20)) / n; + const step = Math.pow(10, logStep); + const f: number[] = []; + for (let i = 0; i < n; i++) { + f.push(startF * Math.pow(step, i)); + } + return f; +} + +export type PeqBandForResponse = { + enabled: boolean; + gain: number; + freq: number; + q: number; + type: string; +}; + +/** + * Combined magnitude response (dB) per band — same pipeline as legacy: + * getSectionsMatrix(...) per filter, then cascade via getFreqznList. + */ +export function computePeqMagnitudeDb(bands: PeqBandForResponse[], fs: number): number[] { + const f = getPeqLogSpacedFreqs(); + const list: Coeff[] = []; + bands.forEach((b) => { + if (!b.enabled) return; + list.push(getSectionsMatrix(b.gain, b.freq, b.q, getFilterType(b.type), false, fs)); + }); + if (list.length === 0) { + return f.map(() => 0); + } + return getFreqznList(list, fs, f); +} + /** * Get ECharts options for frequency response chart */ diff --git a/client/src/locales/data-en.json b/client/src/locales/data-en.json index 9eb528b..90c3201 100644 --- a/client/src/locales/data-en.json +++ b/client/src/locales/data-en.json @@ -1,6 +1,6 @@ { "home": { - "audioSet": "Audio setting" + "audioSet": "Audio setting","power": "power", "input": "input", "output": "output", "vu":"VU meter" }, "source": { "label": "Input/Output", @@ -227,7 +227,7 @@ } ] }, - "headPhoneGain": { + "dacGain": { "label": "Headphone gain", "options": [ { diff --git a/client/src/locales/data-zh-HK.json b/client/src/locales/data-zh-HK.json index 872e167..05367f1 100644 --- a/client/src/locales/data-zh-HK.json +++ b/client/src/locales/data-zh-HK.json @@ -1,5 +1,5 @@ { - "home": { "audioSet": "音訊設定" }, + "home": { "audioSet": "音訊設定","power": "電源", "input": "輸入源", "output": "輸出端口", "vu":"VU表"}, "source": { "label": "輸入/輸出", "input": { "coaxial": "同軸", "optical": "光纖", "bluetooth": "藍牙", "rca": "類比RCA" }, @@ -141,7 +141,7 @@ { "index": 5, "label": "非過采樣(NOS)" } ] }, - "headPhoneGain": { + "dacGain": { "label": "耳機增益", "options": [ { "index": 0, "label": "低" }, diff --git a/client/src/locales/data-zh.json b/client/src/locales/data-zh.json index 07b9949..ecfa5df 100644 --- a/client/src/locales/data-zh.json +++ b/client/src/locales/data-zh.json @@ -1,5 +1,5 @@ { - "home": { "audioSet": "音频设置" }, + "home": { "audioSet": "音频设置", "power": "电源", "input": "输入源", "output": "输出端口", "vu":"VU表" }, "source": { "label": "输入输出", "input": { "coaxial": "同轴", "optical": "光钎", "bluetooth": "蓝牙", "rca": "模拟RCA" }, @@ -141,7 +141,7 @@ { "index": 5, "label": "非过采样(NOS)" } ] }, - "headPhoneGain": { + "dacGain": { "label": "耳机增益", "options": [ { "index": 0, "label": "低" }, diff --git a/client/src/pages/AudioPage.tsx b/client/src/pages/AudioPage.tsx index 99686bd..bca50a6 100644 --- a/client/src/pages/AudioPage.tsx +++ b/client/src/pages/AudioPage.tsx @@ -13,6 +13,9 @@ import { useLocation } from "wouter"; import { useState, useEffect, useRef } from "react"; import BottomNav from "@/components/BottomNav"; import { navigateToSelect } from "./SelectPage"; +import localeZh from "@/locales/data-zh.json"; +import localeZhHK from "@/locales/data-zh-HK.json"; +import localeEn from "@/locales/data-en.json"; function CyanSlider({ value, min, max, step = 1, onChange }: { value: number; min: number; max: number; step?: number; onChange: (v: number) => void; @@ -47,18 +50,42 @@ function ListRow({ label, value, onClick }: { label: string; value: string; onCl ); } -const FILTER_OPTIONS = ["短延迟快速滚降", "短延迟慢速滚降", "快速滚降", "慢速滚降", "超慢速滚降"]; -const GAIN_OPTIONS = ["低", "中", "高"]; -const STEP_OPTIONS = ["0.5dB", "1dB", "2dB"]; -const XLR_OPTIONS = ["正常", "反转"]; -const DAC_GAIN_OPTIONS = ["0dB", "+3dB", "+6dB"]; -const DAC_IMP_OPTIONS = ["32Ω", "150Ω", "300Ω"]; +type LocaleDac = { + dac?: { + balance?: string; + sensitivity?: string; + filters?: { label?: string; options?: Array<{ index: number; label: string }> }; + dacGain?: { label?: string; options?: Array<{ index: number; label: string }> }; + volumeStep?: string; + xlr?: { label?: string; options?: Array<{ index: number; label: string }> }; + mutePolar?: { label?: string; options?: Array<{ index: number; label: string }> }; + IISMode?: { label?: string; options?: Array<{ index: number; label: string }> }; + }; + home?: { audioSet?: string }; +}; + +const LOCALE_BY_LANG: Record = { + 0: localeEn as LocaleDac, + 1: localeZhHK as LocaleDac, + 2: localeZh as LocaleDac, +}; + +function optionLabels(items: Array<{ index: number; label: string }> | undefined, fallback: string[]) { + if (!items || items.length === 0) return fallback; + const sorted = [...items].sort((a, b) => a.index - b.index); + return sorted.map((item) => item.label); +} + +const STEP_OPTIONS = ["0.5dB", "1dB", "2dB", "3dB"]; export default function AudioPage() { const [, setLocation] = useLocation(); - const { deviceState, updateSetting } = useDevice(); + const { deviceState, updateSetting, refresh, isConnected, api } = useDevice(); const ds = deviceState; const vuSensor = (ds as ({ vuSensor?: number } | null))?.vuSensor; + const langIdx = ds?.language ?? 2; + const localeData = LOCALE_BY_LANG[langIdx] ?? (localeZh as LocaleDac); + const dacText = localeData.dac ?? {}; // balance 从接口获取的是乘以 10 后的值,需要除以 10 显示(例如:-130 → -13.0) const balanceVal = ds?.balance !== undefined ? ds.balance / 10 : 0; @@ -66,18 +93,30 @@ export default function AudioPage() { const vuSensVal = vuSensor !== undefined ? vuSensor / 2 : 0; const [localBalance, setLocalBalance] = useState(balanceVal); const [localVuSens, setLocalVuSens] = useState(0); - const [filterIdx] = useState(0); const isBalanceDragging = useRef(false); const isVuDragging = useRef(false); useEffect(() => { if (!isBalanceDragging.current) setLocalBalance(balanceVal); }, [balanceVal]); useEffect(() => { if (!isVuDragging.current) setLocalVuSens(vuSensVal); }, [vuSensVal]); + useEffect(() => { + // Ensure we have the latest pcm value from syncData when entering this page + if (isConnected || api) { + refresh(); + } + }, [isConnected, api, refresh]); - const gainIdx = ds?.analogGain ?? 0; + const gainIdx = ds?.dacGain ?? 0; const stepIdx = ds?.soundStep ?? 1; + const filterIdx = ds?.pcm ?? 0; const xlrIdx = ds?.xlr ?? 0; - const dacGainIdx = ds?.dacGain ?? 0; - const dacImpIdx = ds?.dacImpedance ?? 0; + const mutePolarIdx = (ds as ({ hdmimutepolar?: number } | null))?.hdmimutepolar ?? 0; + const iisModeIdx = (ds as ({ hdmiType?: number } | null))?.hdmiType ?? 0; + + const FILTER_OPTIONS = optionLabels(dacText.filters?.options, ["快速滚降", "慢速滚降", "短延迟快速滚降", "短延迟慢速滚降", "去重强调", "非过采样(NOS)"]); + const GAIN_OPTIONS = optionLabels(dacText.dacGain?.options, ["低", "中", "高"]); + const XLR_OPTIONS = optionLabels(dacText.xlr?.options, ["正常", "反转"]); + const MUTE_POLAR_OPTIONS = optionLabels(dacText.mutePolar?.options, ["低电平", "高电平"]); + const IIS_MODE_OPTIONS = optionLabels(dacText.IISMode?.options, ["模式1", "模式2", "模式3", "模式4", "模式5", "模式6", "模式7", "模式8"]); const goSelect = (title: string, options: string[], selected: number, key: string) => { navigateToSelect(title, options, selected, "/audio", key); @@ -90,7 +129,9 @@ export default function AudioPage() { -

音频设置

+

+ {localeData.home?.audioSet ?? "音频设置"} +

@@ -100,7 +141,7 @@ export default function AudioPage() {
- 左右平衡 + {dacText.balance ?? "左右平衡"} {localBalance >= 0 ? `+${localBalance.toFixed(1)}` : localBalance.toFixed(1)} dB @@ -117,7 +158,7 @@ export default function AudioPage() {
- VU 表灵敏度 + {dacText.sensitivity ?? "VU 表灵敏度"} {localVuSens >= 0 ? `+${localVuSens}` : localVuSens} dB @@ -136,23 +177,41 @@ export default function AudioPage() { {/* ── 输出特性 ── */}
- goSelect("选择滤波特性", FILTER_OPTIONS, filterIdx, "filterCharacteristic")} /> - goSelect("选择耳机增益", GAIN_OPTIONS, gainIdx, "analogGain")} /> - goSelect("选择音量幅度", STEP_OPTIONS, stepIdx, "soundStep")} /> + goSelect(dacText.filters?.label ?? "选择滤波特性", FILTER_OPTIONS, filterIdx, "filterCharacteristic")} + /> + goSelect(dacText.dacGain?.label ?? "选择耳机增益", GAIN_OPTIONS, gainIdx, "dacGain")} + /> + goSelect(dacText.volumeStep ?? "选择音量幅度", STEP_OPTIONS, stepIdx, "soundStep")} + />
{/* ── 系统与接口 ── */}
- goSelect("选择 XLR 极性", XLR_OPTIONS, xlrIdx, "xlr")} /> - goSelect("选择 DAC 增益", DAC_GAIN_OPTIONS, dacGainIdx, "dacGain")} /> - goSelect("选择 DAC 阻抗", DAC_IMP_OPTIONS, dacImpIdx, "dacImpedance")} /> + goSelect(dacText.xlr?.label ?? "选择 XLR 极性", XLR_OPTIONS, xlrIdx, "xlr")} + /> + goSelect(dacText.mutePolar?.label ?? "选择 IIS 静音电平", MUTE_POLAR_OPTIONS, mutePolarIdx, "mutePolar")} + /> + goSelect(dacText.IISMode?.label ?? "选择 IIS 模式", IIS_MODE_OPTIONS, iisModeIdx, "IISMode")} + />
diff --git a/client/src/pages/EQPage.tsx b/client/src/pages/EQPage.tsx index 0dd798d..e7ed90a 100644 --- a/client/src/pages/EQPage.tsx +++ b/client/src/pages/EQPage.tsx @@ -11,20 +11,22 @@ 7. Total gain card: 总增益 value + AUTO toggle + slider ============================================================ */ import { useDevice } from "@/contexts/DeviceContext"; -import { ChevronLeft, ChevronDown, Minus, Plus, Edit3, Headphones } from "lucide-react"; +import { ChevronLeft, ChevronDown, Minus, Plus, Edit3, Headphones, X } from "lucide-react"; import { useLocation } from "wouter"; import { useState, useMemo, useEffect, useRef } from "react"; import { cn } from "@/lib/utils"; import BottomNav from "@/components/BottomNav"; import { toast } from "sonner"; -import { navigateToSelect } from "./SelectPage"; -import { decodeCustomBase64 } from "@/lib/luxsinApi"; +import { decodeCustomBase64, type PeqFilter, type PeqChangePayload } from "@/lib/luxsinApi"; import * as echarts from "echarts"; import { getSectionsMatrix, visualizeResponse, getChartOps, getFilterType, + getFilterShortName, + computePeqMagnitudeDb, + getPeqLogSpacedFreqs, } from "@/lib/peqAudio"; /* ── iOS Toggle ── */ @@ -38,59 +40,183 @@ function IOSToggle({ checked, onChange }: { checked: boolean; onChange: (v: bool } /* ── Cyan Slider ── */ -function CyanSlider({ value, min, max, step = 1, onChange }: { - value: number; min: number; max: number; step?: number; onChange: (v: number) => void; +function CyanSlider({ value, min, max, step = 1, onChange, disabled = false }: { + value: number; min: number; max: number; step?: number; onChange: (v: number) => void; disabled?: boolean; }) { const fillPct = Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100)); return ( -
+
+ style={{ + width: `${fillPct}%`, + background: disabled ? "rgba(148,163,184,0.7)" : "#00FFF6", + boxShadow: disabled ? "none" : "0 0 6px rgba(0,255,246,0.45)", + }} /> onChange(Number(e.target.value))} />
); } /* ── Constants ── */ -const FILTER_TYPES = ["PEAK", "LSHELF", "HSHELF", "NOTCH", "LPF", "HPF"]; +const FILTER_TYPES = ["LPF", "HPF", "BPF", "NOTCH", "PEAK", "LSHELF", "HSHELF", "APF"]; + +function normalizeFilterType(type: string | number | undefined): string { + if (type === undefined || type === null) return "PEAK"; + if (typeof type === "number") { + switch (type) { + case 0: return "LPF"; + case 1: return "HPF"; + case 2: return "BPF"; + case 3: return "NOTCH"; + case 4: return "PEAK"; + case 5: return "LSHELF"; + case 6: return "HSHELF"; + case 7: return "APF"; + default: return "PEAK"; + } + } + return getFilterShortName(type); +} /* ── Frequency Response Chart ── */ function FreqChart({ - bands, abMode, onAbToggle, + bands, selectedBand, abMode, onAbToggle, onBandDrag, onBandSelect, }: { bands: Array<{ freq: number; gain: number; q: number; type: string; enabled: boolean }>; + selectedBand: number; abMode: "A" | "B"; onAbToggle: (m: "A" | "B") => void; + onBandDrag: (idx: number, patch: Partial<{ freq: number; gain: number }>) => void; + onBandSelect: (idx: number) => void; }) { - const W = 340, H = 140; + const H = 140; + const chartContainerRef = useRef(null); + const svgRef = useRef(null); + const draggingBandRef = useRef(null); + const curvePathRef = useRef(null); + const [chartWidth, setChartWidth] = useState(340); + const W = chartWidth; + const [isBandDragging, setIsBandDragging] = useState(false); + + useEffect(() => { + const node = chartContainerRef.current; + if (!node) return; + + const updateWidth = () => { + const nextWidth = Math.round(node.clientWidth); + if (nextWidth > 0) setChartWidth(nextWidth); + }; + + updateWidth(); + const observer = new ResizeObserver(updateWidth); + observer.observe(node); + return () => observer.disconnect(); + }, []); + + const Y_DB_MAX = 20; const freqToX = (f: number) => { const logMin = Math.log10(20), logMax = Math.log10(20000); return ((Math.log10(Math.max(20, Math.min(20000, f))) - logMin) / (logMax - logMin)) * W; }; - const gainToY = (g: number) => H / 2 - (g / 15) * (H / 2 - 10); + const gainToY = (g: number) => H / 2 - (g / Y_DB_MAX) * (H / 2 - 10); + const xToFreq = (x: number) => { + const logMin = Math.log10(20), logMax = Math.log10(20000); + const clampedX = Math.max(0, Math.min(W, x)); + return Math.pow(10, logMin + (clampedX / W) * (logMax - logMin)); + }; + const yToGain = (y: number) => { + const clampedY = Math.max(10, Math.min(H - 10, y)); + return ((H / 2 - clampedY) / (H / 2 - 10)) * Y_DB_MAX; + }; + + const updateBandFromPointer = (idx: number, clientX: number, clientY: number) => { + const svg = svgRef.current; + if (!svg || W <= 0) return; + const rect = svg.getBoundingClientRect(); + if (!rect.width || !rect.height) return; + + const x = ((clientX - rect.left) / rect.width) * W; + const y = ((clientY - rect.top) / rect.height) * H; + const freq = Math.round(Math.max(20, Math.min(20000, xToFreq(x)))); + const gain = Number(Math.max(-Y_DB_MAX, Math.min(Y_DB_MAX, yToGain(y))).toFixed(1)); + onBandDrag(idx, { freq, gain }); + }; + + const handleBandPointerDown = (idx: number, e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + draggingBandRef.current = idx; + setIsBandDragging(true); + onBandSelect(idx); + updateBandFromPointer(idx, e.clientX, e.clientY); + }; + + const handleSvgPointerMove = (e: React.PointerEvent) => { + const idx = draggingBandRef.current; + if (idx === null) return; + e.preventDefault(); + updateBandFromPointer(idx, e.clientX, e.clientY); + }; + + const stopDragging = () => { + draggingBandRef.current = null; + setIsBandDragging(false); + }; + + const FS = 48000; const curve = useMemo(() => { - const freqs = Array.from({ length: 120 }, (_, i) => 20 * Math.pow(1000, i / 119)); - return freqs.map((f) => { - let g = 0; - bands.forEach((b) => { - if (!b.enabled) return; - const ratio = f / b.freq; - const lr = Math.log10(ratio); - g += b.gain * Math.exp(-(lr * lr) * b.q * 2); - }); + const freqs = getPeqLogSpacedFreqs(); + const db = computePeqMagnitudeDb(bands, FS); + return freqs.map((f, i) => { + const g = db[i] ?? 0; return { x: freqToX(f), y: gainToY(g), g }; }); - }, [bands]); + }, [bands, W]); const pathD = curve.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(" "); const fillD = `${pathD} L ${W} ${H / 2} L 0 ${H / 2} Z`; + // Left-to-right stroke draw animation (skip while dragging a band) + useEffect(() => { + const path = curvePathRef.current; + if (!path || !pathD) return; + + if (isBandDragging) { + path.style.strokeDasharray = ""; + path.style.strokeDashoffset = ""; + path.style.transition = ""; + return; + } + + const length = path.getTotalLength(); + if (!Number.isFinite(length) || length <= 0) return; + + path.style.strokeDasharray = `${length}`; + path.style.strokeDashoffset = `${length}`; + path.style.transition = "none"; + + let cancelled = false; + const id1 = requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (cancelled) return; + path.style.transition = "stroke-dashoffset 1.35s cubic-bezier(0.33, 1, 0.68, 1)"; + path.style.strokeDashoffset = "0"; + }); + }); + + return () => { + cancelled = true; + cancelAnimationFrame(id1); + }; + }, [pathD, W, isBandDragging]); + const freqLabels = [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000]; - const gainLabels = [15, 9, 3, 0, -3, -9, -15]; + const gainLabels = [20, 15, 10, 5, 0, -5, -10, -15, -20]; return (
@@ -105,7 +231,7 @@ function FreqChart({ Raw
-
+
Equalized
@@ -141,8 +267,21 @@ function FreqChart({
{/* SVG chart */} -
- +
+ {/* dB grid */} {gainLabels.map((g) => ( @@ -161,20 +300,62 @@ function FreqChart({ {/* Zero line */} {/* Fill */} - - {/* Equalized curve */} - + + {/* Equalized curve (stroke-dash anim via useEffect) */} + {/* Raw curve (flat) */} {/* Band nodes with index */} {bands.map((band, i) => ( - - - - {i + 1} - + handleBandPointerDown(i, e)} + > + {(() => { + const isSelected = i === selectedBand; + const outerR = isSelected ? 8.8 : 7; + const fillColor = isSelected ? "#FFED00" : "rgba(0,0,0,0.5)"; + const strokeColor = isSelected ? "#FFED00" : "#FFED00"; + const textColor = isSelected ? "#000000" : "#FFED00"; + return ( + <> + + + + {i + 1} + + + ); + })()} ))} {/* Freq axis labels */} @@ -208,19 +389,223 @@ function freqLabel(f: number) { return f >= 1000 ? `${(f / 1000).toFixed(1)}K` : `${f}`; } +function getUniquePresetName(base: string, existingNames: string[]) { + if (!existingNames.includes(base)) return base; + let index = 1; + while (existingNames.includes(`${base}_${index}`)) { + index += 1; + } + return `${base}_${index}`; +} + +const FLAT_PRESET_FILTERS: PeqFilter[] = [ + { type: 4, fc: 80, gain: 0, q: 0.1 }, + { type: 4, fc: 150, gain: 0, q: 0.1 }, + { type: 4, fc: 350, gain: 0, q: 0.1 }, + { type: 4, fc: 750, gain: 0, q: 0.1 }, + { type: 4, fc: 1500, gain: 0, q: 0.1 }, + { type: 4, fc: 3000, gain: 0, q: 0.1 }, + { type: 4, fc: 6000, gain: 0, q: 0.1 }, + { type: 4, fc: 10000, gain: 0, q: 0.1 }, + { type: 4, fc: 14000, gain: 0, q: 0.1 }, + { type: 4, fc: 18000, gain: 0, q: 0.1 }, +]; + +/** UI band row → device `PeqFilter` (fc + numeric type), same as legacy `getFilterVal` mapping via `getFilterType`. */ +function bandToPeqFilter(b: { freq: number; gain: number; q: number; type: string | number }): PeqFilter { + return { + fc: b.freq, + gain: b.gain, + q: b.q, + type: getFilterType(b.type), + }; +} + /* ── Main Component ── */ export default function EQPage() { const [, setLocation] = useLocation(); - const { deviceState, updateSetting, api, isDemoMode } = useDevice(); + const { deviceState, updateSetting, api, isDemoMode, upgradePeqChange } = useDevice(); const eqOn = (deviceState?.peqEnable ?? 0) === 1; const [bands, setBands] = useState(DEFAULT_BANDS); - const [selectedBand, setSelectedBand] = useState(3); + const [selectedBand, setSelectedBand] = useState(0); const [headphoneIdx, setHeadphoneIdx] = useState(deviceState?.peqSelect ?? 0); const [headphoneModels, setHeadphoneModels] = useState([]); - const [peqItems, setPeqItems] = useState>([]); + const [peqItems, setPeqItems] = useState< + Array<{ + name: string; + brand?: string; + model?: string; + filters?: any[] | string; + autoPre?: number; + preamp?: number; + canDel?: number; + }> + >([]); const [abMode, setAbMode] = useState<"A" | "B">("A"); - const [autoGain, setAutoGain] = useState(false); + const [isHeadphoneMenuOpen, setIsHeadphoneMenuOpen] = useState(false); + const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false); + const [isAddPresetDialogOpen, setIsAddPresetDialogOpen] = useState(false); + const [addPresetMode, setAddPresetMode] = useState<"copy" | "flat">("copy"); + const [copyPresetName, setCopyPresetName] = useState(""); + const [flatPresetName, setFlatPresetName] = useState(""); + const allowPeqRemoteSyncRef = useRef(false); + const syncingHeadphoneRef = useRef(false); + const peqSyncTimerRef = useRef(null); + const headphoneMenuRef = useRef(null); + const filterMenuRef = useRef(null); + const currentPeq = peqItems[headphoneIdx]; + const preampValue = Number(currentPeq?.preamp ?? 0); + const autoPreOn = (currentPeq?.autoPre ?? 0) === 1; + + const normalizeFiltersFromPeq = (peq: { filters?: any[] | string } | undefined) => { + if (!peq) return [] as typeof bands; + let filters: any[] = []; + if (typeof peq.filters === "string") { + try { + filters = JSON.parse(peq.filters); + } catch { + filters = []; + } + } else if (Array.isArray(peq.filters)) { + filters = peq.filters; + } + return filters.map((f: any) => { + const freq = f.fc || f.freq || f.frequency || 1000; + return { + freq: Number(freq), + gain: Number(f.gain || 0), + q: Number(f.q || 1), + type: normalizeFilterType(f.type), + enabled: f.enabled !== undefined ? f.enabled : true, + }; + }); + }; + + const applyPeqStateToUI = ( + peqState: { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number }, + ) => { + const items = peqState.peq ?? []; + setPeqItems(items as typeof peqItems); + setHeadphoneModels(items.map((item) => item.name)); + + if (items.length === 0) { + setHeadphoneIdx(0); + setBands(DEFAULT_BANDS); + setSelectedBand(0); + return; + } + + const nextIdx = Math.min(Math.max(peqState.peqSelect ?? 0, 0), items.length - 1); + setHeadphoneIdx(nextIdx); + const nextBands = normalizeFiltersFromPeq(items[nextIdx] as { filters?: any[] | string }); + if (nextBands.length > 0) { + setBands(nextBands); + } + setSelectedBand(0); + }; + + const handleDeleteHeadphone = async () => { + const target = peqItems[headphoneIdx]; + if (!target?.name) return; + + const confirmed = window.confirm(`是否删除耳机「${target.name}」?`); + if (!confirmed) return; + + try { + if (isDemoMode || !api) { + const nextItems = peqItems.filter((_, idx) => idx !== headphoneIdx); + applyPeqStateToUI({ peq: nextItems, peqSelect: Math.max(0, headphoneIdx - 1) }); + toast.success("已删除耳机"); + return; + } + + await api.removePeq([target.name]); + const latest = await api.getPeqState(); + applyPeqStateToUI(latest as { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number }); + toast.success("已删除耳机"); + } catch (error) { + console.error("removePeq", error); + toast.error("删除耳机失败"); + } + }; + + const openAddPresetDialog = () => { + const existingNames = headphoneModels; + const currentName = headphoneModels[headphoneIdx] ?? "Preset"; + setCopyPresetName(getUniquePresetName(currentName, existingNames)); + setFlatPresetName(getUniquePresetName("Flat", existingNames)); + setAddPresetMode("copy"); + setIsAddPresetDialogOpen(true); + }; + + const handleSaveAddPreset = async () => { + const nextName = addPresetMode === "copy" ? copyPresetName.trim() : flatPresetName.trim(); + if (!nextName) { + toast.error("名称不能为空"); + return; + } + if (headphoneModels.includes(nextName)) { + toast.error("名称已存在,请修改后再保存"); + return; + } + + const copyPayload: PeqChangePayload = { + peqChange: { + name: nextName, + filters: bands.map(bandToPeqFilter), + autoPre: currentPeq?.autoPre ?? 0, + preamp: currentPeq?.preamp ?? 0, + canDel: currentPeq?.canDel ?? 1, + }, + }; + + const flatPayload: PeqChangePayload = { + peqChange: { + name: nextName, + preamp: 0, + canDel: 1, + autoPre: 0, + filters: FLAT_PRESET_FILTERS, + }, + }; + + try { + await upgradePeqChange(addPresetMode === "copy" ? copyPayload : flatPayload); + if (api && !isDemoMode) { + const latest = await api.getPeqState(); + applyPeqStateToUI(latest as { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number }); + const createdIndex = latest.peq?.findIndex((item) => item.name === nextName) ?? -1; + if (createdIndex >= 0) { + setHeadphoneIdx(createdIndex); + updateSetting({ peqSelect: createdIndex }); + } + } else { + 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, + filters: localFilters, + autoPre: localAutoPre, + preamp: localPreamp, + canDel: 1, + }, + ]); + setHeadphoneIdx(headphoneModels.length); + } + setIsAddPresetDialogOpen(false); + toast.success("已新增预设"); + } catch (error) { + console.error("saveAddPreset", error); + toast.error("新增预设失败"); + } + }; // 同步 peqSelect 变化 useEffect(() => { @@ -253,6 +638,8 @@ export default function EQPage() { // 加载耳机列表并初始化曲线 useEffect(() => { async function loadHeadphones() { + allowPeqRemoteSyncRef.current = false; + try { if (isDemoMode || !api) { // 演示模式使用默认列表 const defaultModels = [ @@ -276,33 +663,8 @@ export default function EQPage() { // 初始化当前选中的耳机曲线 const currentPeq = peqState.peq[peqState.peqSelect || 0]; if (currentPeq) { - // 解析 filters(可能是 JSON 字符串或数组) - let filters: any[] = []; - if (typeof currentPeq.filters === 'string') { - try { - filters = JSON.parse(currentPeq.filters); - } catch (error) { - console.error("Failed to parse filters:", error); - filters = []; - } - } else if (Array.isArray(currentPeq.filters)) { - filters = currentPeq.filters; - } - - console.log("Raw filters from API:", filters); - // 修复 filters 数据(兼容不同的字段名) - const fixedFilters = filters.map((f: any) => { - // 兼容不同的字段名:fc, freq, frequency - const freq = f.fc || f.freq || f.frequency || 1000; - return { - freq: Number(freq), - gain: Number(f.gain || 0), - q: Number(f.q || 1), - type: f.type || 'PEAK', - enabled: f.enabled !== undefined ? f.enabled : true, - }; - }); + const fixedFilters = normalizeFiltersFromPeq(currentPeq); console.log("Fixed filters:", fixedFilters); @@ -359,10 +721,86 @@ export default function EQPage() { setHeadphoneModels(defaultModels); setPeqItems(defaultModels.map(name => ({ name, filters: [] }))); } + } finally { + allowPeqRemoteSyncRef.current = true; + } } loadHeadphones(); }, [api, isDemoMode]); + // 切换耳机型号后,立即用该型号 filters 刷新 10 个滤波器与曲线(暂停一次上报避免错写) + useEffect(() => { + const peq = peqItems[headphoneIdx]; + if (!peq) return; + const nextBands = normalizeFiltersFromPeq(peq); + if (!nextBands.length) return; + syncingHeadphoneRef.current = true; + setBands(nextBands); + setSelectedBand(0); + requestAnimationFrame(() => { + syncingHeadphoneRef.current = false; + }); + }, [headphoneIdx, peqItems]); + + const schedulePeqSync = ( + peq: { name: string; autoPre?: number; preamp?: number; canDel?: number } | undefined, + filtersSource: typeof bands, + delay = 320 + ) => { + if (!allowPeqRemoteSyncRef.current) return; + if (!isDemoMode && !api) return; + if (!peq?.name) return; + + if (peqSyncTimerRef.current !== null) { + window.clearTimeout(peqSyncTimerRef.current); + } + + peqSyncTimerRef.current = window.setTimeout(() => { + const filters = filtersSource.map(bandToPeqFilter); + const payload: PeqChangePayload = { + peqChange: { + name: peq.name, + filters, + autoPre: peq.autoPre ?? 0, + preamp: peq.preamp ?? 0, + canDel: peq.canDel ?? 1, + }, + }; + console.log("upgradePeqChange payload:", payload); + upgradePeqChange(payload).catch((err) => { + console.error("upgradePeqChange", err); + toast.error("EQ 保存失败"); + }); + }, delay); + }; + + // 参数变更(含拖动曲线)→ POST peqChange(防抖) + useEffect(() => { + if (syncingHeadphoneRef.current) return; + schedulePeqSync(peqItems[headphoneIdx], bands, 320); + return () => { + if (peqSyncTimerRef.current !== null) { + window.clearTimeout(peqSyncTimerRef.current); + } + }; + }, [bands, headphoneIdx, peqItems, api, isDemoMode, upgradePeqChange]); + + useEffect(() => { + const onPointerDown = (event: PointerEvent) => { + const target = event.target as Node; + const inFilter = filterMenuRef.current?.contains(target) ?? false; + const inHeadphone = headphoneMenuRef.current?.contains(target) ?? false; + if (!inFilter) { + setIsFilterMenuOpen(false); + } + if (!inHeadphone) { + setIsHeadphoneMenuOpen(false); + } + }; + window.addEventListener("pointerdown", onPointerDown); + return () => window.removeEventListener("pointerdown", onPointerDown); + }, []); + const chartRef = useRef(null); const band = bands[selectedBand]; @@ -455,7 +893,19 @@ export default function EQPage() { } }; - const totalGain = bands.reduce((sum, b) => sum + (b.enabled ? b.gain : 0), 0); + 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; + return merged; + }); + return next; + }); + schedulePeqSync(updatedPeq, bands, 0); + }; return (
@@ -497,33 +947,89 @@ export default function EQPage() { {/* ── Headphone model selector ── */}
- +
+ + + {isHeadphoneMenuOpen && ( +
+ {headphoneModels.map((model, idx) => { + const active = idx === headphoneIdx; + return ( + + ); + })} +
+ )} +
{/* ── Frequency response chart ── */} - + setBands((prev) => prev.map((b, i) => i === idx ? { ...b, ...patch } : b))} + onBandSelect={setSelectedBand} + /> {/* ── Band grid (2 rows × 5) ── */}
@@ -542,27 +1048,68 @@ export default function EQPage() { }} onClick={() => setSelectedBand(i)}> {freqLabel(b.freq)} - {b.type} + {getFilterShortName(b.type)} ))}
{/* ── Band detail card ── */} -
- {/* Filter type selector → goes to select page */} +
+ {/* Filter type selector */}
滤波器 - +
+ + + {isFilterMenuOpen && ( +
+ {FILTER_TYPES.map((type) => { + const active = normalizeFilterType(band.type) === type; + return ( + + ); + })} +
+ )} +
{/* FREQ */} @@ -597,7 +1144,7 @@ export default function EQPage() { Q 值 - {band.q.toFixed(2)} + {band.q.toFixed(2)} dB
{/* ── Total gain card ── */} -
+
总增益 - {totalGain >= 0 ? `+${totalGain.toFixed(1)}` : totalGain.toFixed(1)} + {preampValue >= 0 ? `+${preampValue.toFixed(1)}` : preampValue.toFixed(1)} dB
AUTO - + updateCurrentPeqMeta({ autoPre: v ? 1 : 0 })} + />
- {}} /> + updateCurrentPeqMeta({ preamp: Number((v - 15).toFixed(1)) })} + />
+ {isAddPresetDialogOpen && ( +
+
+
+

新增预设

+ +
+ +
+ + + +
+ +
+ + +
+
+
+ )} +
); diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx index b61d1ec..4e9913c 100644 --- a/client/src/pages/Home.tsx +++ b/client/src/pages/Home.tsx @@ -29,6 +29,9 @@ import { import { useLocation } from "wouter"; import { useRef, useState, useEffect, useCallback } from "react"; import { toast } from "sonner"; +import localeZh from "@/locales/data-zh.json"; +import localeZhHK from "@/locales/data-zh-HK.json"; +import localeEn from "@/locales/data-en.json"; // ── Volume in dB (0-200 → -100dB to 0dB) ── function volToDB(v: number) { @@ -88,6 +91,10 @@ export default function Home() { const [localVol, setLocalVol] = useState(deviceState?.volume ?? 100); const isDragging = useRef(false); + const knobDraggingRef = useRef(false); + const knobStartAngleRef = useRef(0); + const knobStartVolRef = useRef(0); + const knobLastVolRef = useRef(localVol); useEffect(() => { if (!isDragging.current && deviceState?.volume !== undefined) { @@ -95,6 +102,10 @@ export default function Home() { } }, [deviceState?.volume]); + useEffect(() => { + knobLastVolRef.current = localVol; + }, [localVol]); + const handleVolChange = useCallback((e: React.ChangeEvent) => { const v = Number(e.target.value); setLocalVol(v); @@ -104,15 +115,67 @@ export default function Home() { if (!isConnected) return ; const ds = deviceState!; + const localeData = ds.language === 0 ? localeEn : ds.language === 1 ? localeZhHK : localeZh; + const homeText = localeData.home ?? {}; const inputLabel = INPUT_LABELS[ds.input] ?? "USB-C"; - const outputLabel = OUTPUT_LABELS[ds.output] ?? "耳机"; + const outputLabel = OUTPUT_LABELS[ds.output] ?? "Headset"; const effectOn = ds.audio_enable === 1; const audioOn = ds.audio_enable === 1; - const vuLabel = `VU表${(ds.vu ?? 0) + 1}`; + const vuLabel = `${homeText.vu ?? "VU表"}${(ds.vu ?? 0) + 1}`; const bypassOn = (ds.dsp_enable ?? 0) === 0; // Slider fill % (0-200 → 0-100%) const fillPct = (localVol / 200) * 100; + // Knob angle (map 0..200 -> -135..+135 degrees) + const knobAngle = -135 + (localVol / 200) * 270; + + const pointAngle = (clientX: number, clientY: number, rect: DOMRect) => { + const cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + return Math.atan2(clientY - cy, clientX - cx); + }; + + const normalizeDelta = (delta: number) => { + // Wrap to [-PI, PI] to avoid jumps across the -PI/PI boundary. + const pi2 = Math.PI * 2; + let d = ((delta + Math.PI) % pi2) - Math.PI; + if (d < -Math.PI) d += pi2; + return d; + }; + + const beginKnobDrag = (e: React.PointerEvent) => { + const el = e.currentTarget; + const rect = el.getBoundingClientRect(); + knobDraggingRef.current = true; + knobStartAngleRef.current = pointAngle(e.clientX, e.clientY, rect); + knobStartVolRef.current = knobLastVolRef.current; + isDragging.current = true; + el.setPointerCapture(e.pointerId); + }; + + const moveKnobDrag = (e: React.PointerEvent) => { + if (!knobDraggingRef.current) return; + const el = e.currentTarget; + const rect = el.getBoundingClientRect(); + const a = pointAngle(e.clientX, e.clientY, rect); + const delta = normalizeDelta(a - knobStartAngleRef.current); + // 270deg travel -> full scale; 1 rad maps to ~200/(1.5*pi) volume steps + const stepsPerRad = 200 / (Math.PI * 1.5); + const next = Math.max(0, Math.min(200, Math.round(knobStartVolRef.current + delta * stepsPerRad))); + setLocalVol(next); + setVolume(next); + }; + + const endKnobDrag = (e: React.PointerEvent) => { + if (!knobDraggingRef.current) return; + knobDraggingRef.current = false; + isDragging.current = false; + try { + e.currentTarget.releasePointerCapture(e.pointerId); + } catch { + // ignore + } + }; return (
@@ -135,11 +198,39 @@ export default function Home() {
{/* Left ports */} -
-
-
+
+
+ {/* Big hole (left) */} +
+
+
+ {/* Two small holes (right, stacked) */} +
+
+
+
-
{/* VU display area */}
{/* Right knob */}
-
+
@@ -278,7 +386,7 @@ export default function Home() { > - 电源 + {homeText.power ?? "电源"}
{/* EQ */} @@ -320,13 +428,13 @@ export default function Home() {
} - label="输入源" + label={homeText.input ?? "输入源"} value={inputLabel} onClick={() => setLocation("/io")} /> } - label="输出端口" + label={homeText.output ?? "输出端口"} value={outputLabel} onClick={() => setLocation("/io")} /> @@ -340,12 +448,12 @@ export default function Home() { /> } - label="音频设置" + label={homeText.audioSet ?? "音频设置"} onClick={() => setLocation("/audio")} /> } - label="VU表" + label={homeText.vu ?? "VU表"} value={vuLabel} onClick={() => setLocation("/vu")} /> diff --git a/client/src/pages/SelectPage.tsx b/client/src/pages/SelectPage.tsx index 1c1faf0..95e9e29 100644 --- a/client/src/pages/SelectPage.tsx +++ b/client/src/pages/SelectPage.tsx @@ -22,6 +22,9 @@ const KEY_TO_SETTING: Record = { language: "language", analogGain: "analogGain", soundStep: "soundStep", + filterCharacteristic: "pcm", + mutePolar: "hdmimutepolar", + IISMode: "hdmiType", xlr: "xlr", dacGain: "dacGain", dacImpedance: "dacImpedance", @@ -45,6 +48,15 @@ export let selectPageState: { key: string; } | null = null; +// Last select result for pages that need local state update +export let selectPageResult: { key: string; selected: number } | null = null; + +export function consumeSelectPageResult() { + const result = selectPageResult; + selectPageResult = null; + return result; +} + // Helper function to set select page state export function navigateToSelect(title: string, options: string[], selected: number, back: string, key: string) { selectPageState = { title, options, selected, back, key }; @@ -75,6 +87,9 @@ export default function SelectPage() { const { title, options, selected: selectedIdx, back, key } = state; const handleSelect = (idx: number) => { + // Keep selection result for pages that handle non-api local updates (e.g. EQ filter type) + selectPageResult = { key, selected: idx }; + // Apply setting if key maps to an API field const apiField = KEY_TO_SETTING[key]; if (apiField) { diff --git a/vite.config.ts b/vite.config.ts index 2322e3b..51a0c17 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -180,11 +180,48 @@ export default defineConfig({ }, rollupOptions: { output: { - manualChunks: { - 'react-vendor': ['react', 'react-dom'], - 'router-vendor': ['wouter'], - 'ui-vendor': ['lucide-react', 'class-variance-authority', 'clsx', 'tailwind-merge'], + manualChunks(id) { + if (!id.includes("node_modules")) return; + + // Keep core runtime dependencies stable for better long-term caching. + if (id.includes("node_modules/react/") || id.includes("node_modules/react-dom/")) { + return "react-vendor"; + } + if (id.includes("node_modules/wouter/")) { + return "router-vendor"; + } + + // Split large libraries into their own chunks to avoid a single huge entry bundle. + if (id.includes("node_modules/echarts/")) return "echarts-vendor"; + if (id.includes("node_modules/recharts/")) return "recharts-vendor"; + if (id.includes("node_modules/framer-motion/")) return "motion-vendor"; + if (id.includes("node_modules/@radix-ui/")) return "radix-vendor"; + + // Group frequently used small UI helpers. + if ( + id.includes("node_modules/lucide-react/") || + id.includes("node_modules/class-variance-authority/") || + id.includes("node_modules/clsx/") || + id.includes("node_modules/tailwind-merge/") + ) { + return "ui-vendor"; + } + + // Fall back to per-package vendor chunks. + const packageMatch = id.match( + /node_modules[\\/](?:\.pnpm[\\/][^\\/]+[\\/]node_modules[\\/])?(@?[^\\/]+(?:[\\/][^\\/]+)?)/, + ); + const packagePath = packageMatch?.[1]; + if (!packagePath) return "vendor"; + + const packageName = packagePath.startsWith("@") + ? packagePath.split(/[\\/]/).slice(0, 2).join("_") + : packagePath.split(/[\\/]/)[0]; + + return `vendor-${packageName}`; }, + // Prevent Rollup from merging manual chunks back into larger bundles. + onlyExplicitManualChunks: true, }, }, chunkSizeWarningLimit: 500,