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,