From a045db235aacbe4674783804464109847e3855c9 Mon Sep 17 00:00:00 2001 From: eafonyang Date: Mon, 15 Jun 2026 18:39:07 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=86=E5=88=86=20EQPage=20=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=EF=BC=8C=E6=96=B0=E5=A2=9E=E5=88=86=E4=BA=AB=E7=A0=81?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E4=BF=AE=E5=A4=8D=20bug=EF=BC=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=80=A7=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/lib/luxsinApi.ts | 140 ++ client/src/locales/data-en.json | 243 ++- client/src/locales/data-zh-HK.json | 532 +++++-- client/src/locales/data-zh.json | 533 +++++-- client/src/pages/EQPage.tsx | 1369 ++++++----------- .../pages/eq/components/AddPresetDialog.tsx | 115 ++ .../pages/eq/components/BandParamDialog.tsx | 89 ++ .../pages/eq/components/BatchEditDialog.tsx | 69 + .../src/pages/eq/components/BrandDrawer.tsx | 426 +++++ .../components/PeqOverwriteConfirmDialog.tsx | 74 + .../src/pages/eq/components/SaveBDialog.tsx | 69 + .../src/pages/eq/components/ShareDialog.tsx | 564 +++++++ client/src/pages/eq/hooks/useRawCurve.ts | 102 ++ client/src/pages/eq/peqMappers.ts | 14 +- client/src/pages/eq/shareMessages.ts | 40 + 15 files changed, 3198 insertions(+), 1181 deletions(-) create mode 100644 client/src/pages/eq/components/AddPresetDialog.tsx create mode 100644 client/src/pages/eq/components/BandParamDialog.tsx create mode 100644 client/src/pages/eq/components/BatchEditDialog.tsx create mode 100644 client/src/pages/eq/components/BrandDrawer.tsx create mode 100644 client/src/pages/eq/components/PeqOverwriteConfirmDialog.tsx create mode 100644 client/src/pages/eq/components/SaveBDialog.tsx create mode 100644 client/src/pages/eq/components/ShareDialog.tsx create mode 100644 client/src/pages/eq/hooks/useRawCurve.ts create mode 100644 client/src/pages/eq/shareMessages.ts diff --git a/client/src/lib/luxsinApi.ts b/client/src/lib/luxsinApi.ts index f443c8f..708f9f0 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; + target?: string; form?: string; }>; } @@ -159,6 +160,38 @@ export interface PeqPresetBody { form?: string; } +/** PEQ preset metadata used when building peqChange / peqApply bodies. */ +export type PeqPresetMeta = { + name: string; + autoPre?: number; + preamp?: number; + canDel?: number; + brand?: string; + model?: string; + target?: string; + form?: string; +}; + +/** Build device PEQ POST body; optional brand/model/target/form included only when non-empty. */ +export function buildPeqPresetBody(peq: PeqPresetMeta, filters: PeqFilter[]): PeqPresetBody { + const body: PeqPresetBody = { + name: peq.name, + filters, + autoPre: peq.autoPre ?? 0, + preamp: peq.preamp ?? 0, + canDel: peq.canDel ?? 1, + }; + const brand = peq.brand?.trim(); + const model = peq.model?.trim(); + const target = peq.target?.trim(); + const form = peq.form?.trim(); + if (brand) body.brand = brand; + if (model) body.model = model; + if (target) body.target = target; + if (form) body.form = form; + return body; +} + /** POST body for `/dev/info.cgi` — full peq preset update (custom base64 `json` field). */ export interface PeqChangePayload { peqChange: PeqPresetBody; @@ -591,3 +624,110 @@ export async function fetchLuxsinAudioCurve( const body = (await res.text()).trim(); return decodeCustomBase64(body); } + +// ============================================================ +// Share Code API — shareCreate / shareList / shareQuery / shareAccept +// ============================================================ + +export interface ShareCodeCreateResponse { + code: number; + msg: string; + share_code?: string; + expire_at?: string; + eq_data?: Record; +} + +export interface ShareCodeListItem { + share_code: string; + expire_at: string; + eq_data: Record; +} + +export interface ShareCodeListResponse { + code: number; + msg: string; + share_codes: ShareCodeListItem[]; +} + +export interface ShareCodeAcceptResponse { + code: number; + msg: string; + eq_data?: Record; +} + +export interface ShareCodeQueryResponse { + code: number; + msg: string; + eq_data?: Record; + expire_at?: string; +} + +/** Unwrap stringified PEQ filters (remove JSON escape layers) for API submit. */ +export function normalizePeqFiltersForSubmit(filters: unknown): PeqFilter[] { + if (Array.isArray(filters)) return filters as PeqFilter[]; + if (typeof filters !== "string" || !filters.trim()) return []; + + let current: unknown = filters.trim(); + for (let depth = 0; depth < 3; depth++) { + if (Array.isArray(current)) return current as PeqFilter[]; + if (typeof current !== "string") return []; + try { + current = JSON.parse(current); + } catch { + return []; + } + } + return Array.isArray(current) ? (current as PeqFilter[]) : []; +} + +/** POST `/audio/shareCreate` — 创建 EQ 分享码,返回 share_code + expire_at + eq_data */ +export async function createShareCode( + mac: string, + eqData: Record, +): Promise { + const payload = { + ...eqData, + ...(eqData.filters !== undefined + ? { filters: normalizePeqFiltersForSubmit(eqData.filters) } + : {}), + }; + const res = await fetch(buildLuxsinAudioUrl("shareCreate"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mac, eq_data: payload }), + }); + if (!res.ok) throw new Error(`shareCreate HTTP ${res.status}`); + return (await res.json()) as ShareCodeCreateResponse; +} + +/** GET `/audio/shareList?mac=...` — 查询某 MAC 尚未过期的分享码列表 */ +export async function listShareCodes(mac: string): Promise { + const res = await fetch( + buildLuxsinAudioUrl(`shareList?mac=${encodeURIComponent(mac)}`), + ); + if (!res.ok) throw new Error(`shareList HTTP ${res.status}`); + return (await res.json()) as ShareCodeListResponse; +} + +/** GET `/audio/shareQuery?shareCode=...` — 预览分享码 EQ 数据(不记录导入) */ +export async function queryShareCode(shareCode: string): Promise { + const res = await fetch( + buildLuxsinAudioUrl(`shareQuery?shareCode=${encodeURIComponent(shareCode)}`), + ); + if (!res.ok) throw new Error(`shareQuery HTTP ${res.status}`); + return (await res.json()) as ShareCodeQueryResponse; +} + +/** GET `/audio/shareAccept?mac=...&shareCode=...` — 确认导入分享码并记录流水 */ +export async function acceptShareCode( + mac: string, + shareCode: string, +): Promise { + const res = await fetch( + buildLuxsinAudioUrl( + `shareAccept?mac=${encodeURIComponent(mac)}&shareCode=${encodeURIComponent(shareCode)}`, + ), + ); + if (!res.ok) throw new Error(`shareAccept HTTP ${res.status}`); + return (await res.json()) as ShareCodeAcceptResponse; +} diff --git a/client/src/locales/data-en.json b/client/src/locales/data-en.json index 30ac092..2fd8167 100644 --- a/client/src/locales/data-en.json +++ b/client/src/locales/data-en.json @@ -53,8 +53,20 @@ "pageTitle": "I/O", "inputSection": "Input", "outputSection": "Output", - "inputOptions": ["USB-B", "USB-C", "Coaxial", "Optical", "Bluetooth", "IIS"], - "outputOptions": ["XLR", "RCA", "Headset", "XLR/RCA"], + "inputOptions": [ + "USB-B", + "USB-C", + "Coaxial", + "Optical", + "Bluetooth", + "IIS" + ], + "outputOptions": [ + "XLR", + "RCA", + "Headset", + "XLR/RCA" + ], "input": { "coaxial": "COAXIAL", "optical": "OPTICAL", @@ -75,7 +87,6 @@ "tone": "Tone", "loudness": "Loudness", "threshold": "Threshold", - "delayed": "Delayed", "mainframeCase": "Main speaker", "subwooferUnit": "Subwoofer", @@ -329,8 +340,40 @@ "paramInputInvalid": "Enter a valid number", "paramInputOutOfRangeFreq": "Frequency must be between {{min}} and {{max}} Hz", "paramInputOutOfRangeGain": "Gain must be between {{min}} and {{max}} dB", - "paramInputOutOfRangeQ": "Q must be between {{min}} and {{max}}" - } + "paramInputOutOfRangeQ": "Q must be between {{min}} and {{max}}", + "overwritePresetTitle": "Preset name already exists", + "overwritePresetDesc": "A preset named \"{{name}}\" already exists. Overwrite it?", + "overwritePresetConfirm": "Overwrite", + "overwritePresetCancelled": "Cancelled", + "shareTitle": "Share EQ", + "shareSelectHint": "Select the EQ preset to share", + "shareConfirm": "Generate Code", + "shareCodeLabel": "Share Code", + "shareCopied": "Code copied", + "shareGenerating": "Generating…", + "shareCreateFail": "Failed to generate share code", + "shareActiveExists": "You already have an active share code. Please try again later.", + "shareInvalidParams": "Invalid request. Please check and try again.", + "shareSystemError": "System error. Please try again later.", + "shareExpireAt": "Expires at", + "importTab": "Import EQ", + "importCodeHint": "Enter or paste the 5-character share code", + "importQuery": "Query", + "importQuerying": "Querying…", + "importCodeNotFound": "Share code not found or expired", + "importEqName": "EQ Name", + "importButton": "Import", + "importSaving": "Importing…", + "importSuccess": "EQ imported successfully", + "importFail": "Failed to import EQ", + "mySharesTab": "My Shares", + "mySharesLoading": "Loading…", + "mySharesEmpty": "No shared EQ yet", + "mySharesCode": "Code", + "mySharesName": "EQ Name", + "mySharesLoadFail": "Failed to load share list" + }, + "share": "Share EQ" }, "dac": { "sectionBalanceSensitivity": "Balance & sensitivity", @@ -388,23 +431,44 @@ "autoImpedance": { "label": "Auto Impedance Detection", "options": [ - { "index": 0, "label": "Off" }, - { "index": 1, "label": "On" } + { + "index": 0, + "label": "Off" + }, + { + "index": 1, + "label": "On" + } ] }, "dreMode": { "label": "Dynamic Range Enhancement", "options": [ - { "index": 0, "label": "Off" }, - { "index": 1, "label": "On" } + { + "index": 0, + "label": "Off" + }, + { + "index": 1, + "label": "On" + } ] }, "dacVolumeDirect": { "label": "Pre-out Volume passthrough", "options": [ - { "index": 0, "label": "Off" }, - { "index": 1, "label": "0dB" }, - { "index": 2, "label": "-12dB" } + { + "index": 0, + "label": "Off" + }, + { + "index": 1, + "label": "0dB" + }, + { + "index": 2, + "label": "-12dB" + } ] }, "dacVolumeDirectConfirm": { @@ -419,17 +483,50 @@ "bootVolume": { "label": "Boot volume", "options": [ - { "index": 0, "label": "Default" }, - { "index": 1, "label": "-5dB" }, - { "index": 2, "label": "-10dB" }, - { "index": 3, "label": "-15dB" }, - { "index": 4, "label": "-20dB" }, - { "index": 5, "label": "-25dB" }, - { "index": 6, "label": "-30dB" }, - { "index": 7, "label": "-35dB" }, - { "index": 8, "label": "-40dB" }, - { "index": 9, "label": "-45dB" }, - { "index": 10, "label": "-50dB" } + { + "index": 0, + "label": "Default" + }, + { + "index": 1, + "label": "-5dB" + }, + { + "index": 2, + "label": "-10dB" + }, + { + "index": 3, + "label": "-15dB" + }, + { + "index": 4, + "label": "-20dB" + }, + { + "index": 5, + "label": "-25dB" + }, + { + "index": 6, + "label": "-30dB" + }, + { + "index": 7, + "label": "-35dB" + }, + { + "index": 8, + "label": "-40dB" + }, + { + "index": 9, + "label": "-45dB" + }, + { + "index": 10, + "label": "-50dB" + } ] }, "xlr": { @@ -461,21 +558,51 @@ "mutePolar": { "label": "Mute polar", "options": [ - { "index": 0, "label": "Low(mute)" }, - { "index": 1, "label": "High(mute)" } + { + "index": 0, + "label": "Low(mute)" + }, + { + "index": 1, + "label": "High(mute)" + } ] }, "IISMode": { "label": "IIS mode", "options": [ - { "index": 0, "label": "mode 1" }, - { "index": 1, "label": "mode 2" }, - { "index": 2, "label": "mode 3" }, - { "index": 3, "label": "mode 4" }, - { "index": 4, "label": "mode 5" }, - { "index": 5, "label": "mode 6" }, - { "index": 6, "label": "mode 7" }, - { "index": 7, "label": "mode 8" } + { + "index": 0, + "label": "mode 1" + }, + { + "index": 1, + "label": "mode 2" + }, + { + "index": 2, + "label": "mode 3" + }, + { + "index": 3, + "label": "mode 4" + }, + { + "index": 4, + "label": "mode 5" + }, + { + "index": 5, + "label": "mode 6" + }, + { + "index": 6, + "label": "mode 7" + }, + { + "index": 7, + "label": "mode 8" + } ] } }, @@ -573,17 +700,35 @@ "sleepTime": { "label": "Sleep", "options": [ - { "index": 0, "label": "Off" }, - { "index": 1, "label": "No signal after 1 min" }, - { "index": 2, "label": "No signal after 5 min" }, - { "index": 3, "label": "No signal after 10 min" } + { + "index": 0, + "label": "Off" + }, + { + "index": 1, + "label": "No signal after 1 min" + }, + { + "index": 2, + "label": "No signal after 5 min" + }, + { + "index": 3, + "label": "No signal after 10 min" + } ] }, "buttonLight": { "label": "Knob Screen-Off Breathing Light", "options": [ - { "index": 0, "label": "On" }, - { "index": 1, "label": "Off" } + { + "index": 0, + "label": "On" + }, + { + "index": 1, + "label": "Off" + } ] }, "language": { @@ -672,10 +817,22 @@ "welcomeDescription": "I can assist with Luxsin X8 system configuration, headphone frequency response analysis, EQ optimization, and advanced audio setup guidance.", "suggestionsLabel": "Try asking", "suggestions": [ - { "title": "EQ Optimization", "prompt": "Optimize my current EQ for clearer vocals, deeper bass." }, - { "title": "Recommend Pop EQ", "prompt": "Recommend a set of EQ parameters suitable for pop music." }, - { "title": "Check device status", "prompt": "Show me the current volume, input source and output port." }, - { "title": "Switch input source", "prompt": "Switch the input source to USB-C." } + { + "title": "EQ Optimization", + "prompt": "Optimize my current EQ for clearer vocals, deeper bass." + }, + { + "title": "Recommend Pop EQ", + "prompt": "Recommend a set of EQ parameters suitable for pop music." + }, + { + "title": "Check device status", + "prompt": "Show me the current volume, input source and output port." + }, + { + "title": "Switch input source", + "prompt": "Switch the input source to USB-C." + } ], "inputPlaceholder": "Ask about your device, EQ, effects…", "thinking": "Thinking…", diff --git a/client/src/locales/data-zh-HK.json b/client/src/locales/data-zh-HK.json index cb073d4..1eff797 100644 --- a/client/src/locales/data-zh-HK.json +++ b/client/src/locales/data-zh-HK.json @@ -53,10 +53,29 @@ "pageTitle": "輸入/輸出", "inputSection": "輸入", "outputSection": "輸出", - "inputOptions": ["USB-B", "USB-C", "同軸", "光纖", "藍牙", "IIS"], - "outputOptions": ["XLR", "RCA", "耳機", "XLR/RCA"], - "input": { "coaxial": "同軸", "optical": "光纖", "bluetooth": "藍牙", "rca": "類比RCA" }, - "output": { "headset": "耳機" } + "inputOptions": [ + "USB-B", + "USB-C", + "同軸", + "光纖", + "藍牙", + "IIS" + ], + "outputOptions": [ + "XLR", + "RCA", + "耳機", + "XLR/RCA" + ], + "input": { + "coaxial": "同軸", + "optical": "光纖", + "bluetooth": "藍牙", + "rca": "類比RCA" + }, + "output": { + "headset": "耳機" + } }, "effect": { "pageTitle": "音效", @@ -74,25 +93,75 @@ "style": { "label": "風格", "options": [ - { "index": 0, "label": "古典" }, - { "index": 1, "label": "舞曲" }, - { "index": 2, "label": "流行" }, - { "index": 3, "label": "雷鬼" }, - { "index": 4, "label": "現場" }, - { "index": 5, "label": "搖滾" }, - { "index": 6, "label": "柔和" }, - { "index": 7, "label": "電子樂" }, - { "index": 8, "label": "俱樂部" }, - { "index": 9, "label": "全低音" }, - { "index": 10, "label": "全高音" }, - { "index": 11, "label": "耳機" }, - { "index": 12, "label": "大廳" }, - { "index": 13, "label": "聚合" }, - { "index": 14, "label": "斯卡" }, - { "index": 15, "label": "慢搖" } + { + "index": 0, + "label": "古典" + }, + { + "index": 1, + "label": "舞曲" + }, + { + "index": 2, + "label": "流行" + }, + { + "index": 3, + "label": "雷鬼" + }, + { + "index": 4, + "label": "現場" + }, + { + "index": 5, + "label": "搖滾" + }, + { + "index": 6, + "label": "柔和" + }, + { + "index": 7, + "label": "電子樂" + }, + { + "index": 8, + "label": "俱樂部" + }, + { + "index": 9, + "label": "全低音" + }, + { + "index": 10, + "label": "全高音" + }, + { + "index": 11, + "label": "耳機" + }, + { + "index": 12, + "label": "大廳" + }, + { + "index": 13, + "label": "聚合" + }, + { + "index": 14, + "label": "斯卡" + }, + { + "index": 15, + "label": "慢搖" + } ] }, - "stereoWidth": { "label": "聲場寬度" }, + "stereoWidth": { + "label": "聲場寬度" + }, "crossfeed": { "label": "交叉回授", "options": [ @@ -109,8 +178,14 @@ "output": { "label": "輸出方式", "options": [ - { "label": "單聲道", "index": 0 }, - { "label": "立體聲", "index": 1 } + { + "label": "單聲道", + "index": 0 + }, + { + "label": "立體聲", + "index": 1 + } ] } }, @@ -265,8 +340,40 @@ "paramInputInvalid": "請輸入有效數值", "paramInputOutOfRangeFreq": "頻率需在 {{min}} – {{max}} Hz 之間", "paramInputOutOfRangeGain": "增益需在 {{min}} – {{max}} dB 之間", - "paramInputOutOfRangeQ": "Q 值需在 {{min}} – {{max}} 之間" - } + "paramInputOutOfRangeQ": "Q 值需在 {{min}} – {{max}} 之間", + "overwritePresetTitle": "預設名稱已存在", + "overwritePresetDesc": "預設「{{name}}」已存在,確認覆蓋原有資料嗎?", + "overwritePresetConfirm": "覆蓋", + "overwritePresetCancelled": "已取消", + "shareTitle": "分享 EQ", + "shareSelectHint": "選擇要分享的 EQ 預設", + "shareConfirm": "產生分享碼", + "shareCodeLabel": "分享碼", + "shareCopied": "已複製分享碼", + "shareGenerating": "產生中…", + "shareCreateFail": "產生分享碼失敗", + "shareActiveExists": "已有未過期的分享碼,請稍後再試", + "shareInvalidParams": "請求參數無效,請檢查後重試", + "shareSystemError": "系統錯誤,請稍後重試", + "shareExpireAt": "有效期至", + "importTab": "匯入 EQ", + "importCodeHint": "輸入或貼上 5 位分享碼", + "importQuery": "查詢", + "importQuerying": "查詢中…", + "importCodeNotFound": "分享碼不存在或已過期", + "importEqName": "EQ 名稱", + "importButton": "匯入", + "importSaving": "匯入中…", + "importSuccess": "EQ 匯入成功", + "importFail": "EQ 匯入失敗", + "mySharesTab": "我的分享", + "mySharesLoading": "載入中…", + "mySharesEmpty": "暫無分享記錄", + "mySharesCode": "分享碼", + "mySharesName": "EQ 名稱", + "mySharesLoadFail": "載入分享列表失敗" + }, + "share": "分享 EQ" }, "dac": { "sectionBalanceSensitivity": "平衡與靈敏度", @@ -278,42 +385,90 @@ "filters": { "label": "濾波特性", "options": [ - { "index": 0, "label": "快速衰減" }, - { "index": 1, "label": "慢速衰減" }, - { "index": 2, "label": "短延遲快速衰減" }, - { "index": 3, "label": "短延遲慢速衰減" }, - { "index": 4, "label": "去重強調" }, - { "index": 5, "label": "非過采樣(NOS)" } + { + "index": 0, + "label": "快速衰減" + }, + { + "index": 1, + "label": "慢速衰減" + }, + { + "index": 2, + "label": "短延遲快速衰減" + }, + { + "index": 3, + "label": "短延遲慢速衰減" + }, + { + "index": 4, + "label": "去重強調" + }, + { + "index": 5, + "label": "非過采樣(NOS)" + } ] }, "dacGain": { "label": "耳機增益", "options": [ - { "index": 0, "label": "低" }, - { "index": 1, "label": "中" }, - { "index": 2, "label": "高" } + { + "index": 0, + "label": "低" + }, + { + "index": 1, + "label": "中" + }, + { + "index": 2, + "label": "高" + } ] }, "autoImpedance": { "label": "自動檢測耳機阻抗", "options": [ - { "index": 0, "label": "關閉" }, - { "index": 1, "label": "打開" } + { + "index": 0, + "label": "關閉" + }, + { + "index": 1, + "label": "打開" + } ] }, "dreMode": { "label": "動態範圍增強", "options": [ - { "index": 0, "label": "關閉" }, - { "index": 1, "label": "打開" } + { + "index": 0, + "label": "關閉" + }, + { + "index": 1, + "label": "打開" + } ] }, "dacVolumeDirect": { "label": "前級輸出音量直通模式", "options": [ - { "index": 0, "label": "關閉" }, - { "index": 1, "label": "0dB" }, - { "index": 2, "label": "-12dB" } + { + "index": 0, + "label": "關閉" + }, + { + "index": 1, + "label": "0dB" + }, + { + "index": 2, + "label": "-12dB" + } ] }, "dacVolumeDirectConfirm": { @@ -328,51 +483,126 @@ "bootVolume": { "label": "開機音量", "options": [ - { "index": 0, "label": "預設" }, - { "index": 1, "label": "-5dB" }, - { "index": 2, "label": "-10dB" }, - { "index": 3, "label": "-15dB" }, - { "index": 4, "label": "-20dB" }, - { "index": 5, "label": "-25dB" }, - { "index": 6, "label": "-30dB" }, - { "index": 7, "label": "-35dB" }, - { "index": 8, "label": "-40dB" }, - { "index": 9, "label": "-45dB" }, - { "index": 10, "label": "-50dB" } + { + "index": 0, + "label": "預設" + }, + { + "index": 1, + "label": "-5dB" + }, + { + "index": 2, + "label": "-10dB" + }, + { + "index": 3, + "label": "-15dB" + }, + { + "index": 4, + "label": "-20dB" + }, + { + "index": 5, + "label": "-25dB" + }, + { + "index": 6, + "label": "-30dB" + }, + { + "index": 7, + "label": "-35dB" + }, + { + "index": 8, + "label": "-40dB" + }, + { + "index": 9, + "label": "-45dB" + }, + { + "index": 10, + "label": "-50dB" + } ] }, "xlr": { "label": "XLR端子極性", "options": [ - { "index": 0, "label": "正常" }, - { "index": 1, "label": "反向" } + { + "index": 0, + "label": "正常" + }, + { + "index": 1, + "label": "反向" + } ] }, "arc": { "label": "ARC模式", "options": [ - { "index": 0, "label": "ARC" }, - { "index": 1, "label": "EARC" } + { + "index": 0, + "label": "ARC" + }, + { + "index": 1, + "label": "EARC" + } ] }, "mutePolar": { "label": "IIS 靜音電平", "options": [ - { "index": 0, "label": "低電平" }, - { "index": 1, "label": "高電平" } + { + "index": 0, + "label": "低電平" + }, + { + "index": 1, + "label": "高電平" + } ] }, "IISMode": { "label": "IIS模式", "options": [ - { "index": 0, "label": "模式1" }, - { "index": 1, "label": "模式2" }, - { "index": 2, "label": "模式3" }, - { "index": 3, "label": "模式4" }, - { "index": 4, "label": "模式5" }, - { "index": 5, "label": "模式6" }, - { "index": 6, "label": "模式7" }, - { "index": 7, "label": "模式8" } + { + "index": 0, + "label": "模式1" + }, + { + "index": 1, + "label": "模式2" + }, + { + "index": 2, + "label": "模式3" + }, + { + "index": 3, + "label": "模式4" + }, + { + "index": 4, + "label": "模式5" + }, + { + "index": 5, + "label": "模式6" + }, + { + "index": 6, + "label": "模式7" + }, + { + "index": 7, + "label": "模式8" + } ] } }, @@ -382,71 +612,161 @@ "screenBrightness": { "label": "螢幕亮度", "options": [ - { "index": 0, "label": "較亮" }, - { "index": 1, "label": "中等" }, - { "index": 2, "label": "較暗" } + { + "index": 0, + "label": "較亮" + }, + { + "index": 1, + "label": "中等" + }, + { + "index": 2, + "label": "較暗" + } ] }, "sleep": { "label": "休眠", "options": [ - { "index": 0, "label": "關閉" }, - { "index": 1, "label": "無訊號1分鐘" }, - { "index": 2, "label": "無訊號5分鐘" }, - { "index": 3, "label": "無訊號10分鐘" }, - { "index": 4, "label": "無訊號15分鐘" } + { + "index": 0, + "label": "關閉" + }, + { + "index": 1, + "label": "無訊號1分鐘" + }, + { + "index": 2, + "label": "無訊號5分鐘" + }, + { + "index": 3, + "label": "無訊號10分鐘" + }, + { + "index": 4, + "label": "無訊號15分鐘" + } ] }, "turnOffScreen": { "label": "關閉螢幕", "options": [ - { "index": 0, "label": "常亮" }, - { "index": 1, "label": "無操作30秒" }, - { "index": 2, "label": "無操作1分鐘" }, - { "index": 3, "label": "無操作3分鐘" }, - { "index": 4, "label": "無操作5分鐘" } + { + "index": 0, + "label": "常亮" + }, + { + "index": 1, + "label": "無操作30秒" + }, + { + "index": 2, + "label": "無操作1分鐘" + }, + { + "index": 3, + "label": "無操作3分鐘" + }, + { + "index": 4, + "label": "無操作5分鐘" + } ] }, "knobBrightness": { "label": "旋鈕亮度", "options": [ - { "index": 0, "label": "關閉" }, - { "index": 1, "label": "較亮" }, - { "index": 2, "label": "中等" }, - { "index": 3, "label": "較暗" } + { + "index": 0, + "label": "關閉" + }, + { + "index": 1, + "label": "較亮" + }, + { + "index": 2, + "label": "中等" + }, + { + "index": 3, + "label": "較暗" + } ] }, "sleepTime": { "label": "休眠", "options": [ - { "index": 0, "label": "關閉" }, - { "index": 1, "label": "無信號 1 分鐘後" }, - { "index": 2, "label": "無信號 5 分鐘後" }, - { "index": 3, "label": "無信號 10 分鐘後" } + { + "index": 0, + "label": "關閉" + }, + { + "index": 1, + "label": "無信號 1 分鐘後" + }, + { + "index": 2, + "label": "無信號 5 分鐘後" + }, + { + "index": 3, + "label": "無信號 10 分鐘後" + } ] }, "buttonLight": { "label": "旋鈕熄屏呼吸燈", "options": [ - { "index": 0, "label": "開啟" }, - { "index": 1, "label": "關閉" } + { + "index": 0, + "label": "開啟" + }, + { + "index": 1, + "label": "關閉" + } ] }, "language": { "label": "語言", "options": [ - { "index": 0, "label": "英語" }, - { "index": 1, "label": "中文(繁體)" }, - { "index": 2, "label": "中文(簡體)" } + { + "index": 0, + "label": "英語" + }, + { + "index": 1, + "label": "中文(繁體)" + }, + { + "index": 2, + "label": "中文(簡體)" + } ] }, "Automatic": { "label": "自動返回首頁", "options": [ - { "index": 0, "label": "關閉" }, - { "index": 1, "label": "無操作20秒" }, - { "index": 2, "label": "無操作40秒" }, - { "index": 3, "label": "無操作60秒" } + { + "index": 0, + "label": "關閉" + }, + { + "index": 1, + "label": "無操作20秒" + }, + { + "index": 2, + "label": "無操作40秒" + }, + { + "index": 3, + "label": "無操作60秒" + } ] }, "systemPage": { @@ -497,10 +817,22 @@ "welcomeDescription": "可以幫你查詢與調整 {device} 的設定,分析耳機頻響、優化 EQ,或者解答使用上的疑問。", "suggestionsLabel": "試試這樣問", "suggestions": [ - { "title": "優化我的 EQ", "prompt": "幫我優化目前 EQ,我希望人聲更清晰一些" }, - { "title": "推薦流行樂 EQ", "prompt": "推薦一套適合聽流行音樂的 EQ 參數" }, - { "title": "查看裝置狀態", "prompt": "幫我查看目前的音量、輸入源和輸出埠" }, - { "title": "切換輸入源", "prompt": "把輸入源切換到 USB-C" } + { + "title": "優化我的 EQ", + "prompt": "幫我優化目前 EQ,我希望人聲更清晰一些" + }, + { + "title": "推薦流行樂 EQ", + "prompt": "推薦一套適合聽流行音樂的 EQ 參數" + }, + { + "title": "查看裝置狀態", + "prompt": "幫我查看目前的音量、輸入源和輸出埠" + }, + { + "title": "切換輸入源", + "prompt": "把輸入源切換到 USB-C" + } ], "inputPlaceholder": "問問關於裝置、EQ、音效……", "thinking": "思考中…", diff --git a/client/src/locales/data-zh.json b/client/src/locales/data-zh.json index a1efaac..d0acf6b 100644 --- a/client/src/locales/data-zh.json +++ b/client/src/locales/data-zh.json @@ -53,10 +53,29 @@ "pageTitle": "输入输出", "inputSection": "输入", "outputSection": "输出", - "inputOptions": ["USB-B", "USB-C", "同轴", "光纤", "蓝牙", "IIS"], - "outputOptions": ["XLR", "RCA", "耳机", "XLR/RCA"], - "input": { "coaxial": "同轴", "optical": "光纤", "bluetooth": "蓝牙", "rca": "模拟RCA" }, - "output": { "headset": "耳机" } + "inputOptions": [ + "USB-B", + "USB-C", + "同轴", + "光纤", + "蓝牙", + "IIS" + ], + "outputOptions": [ + "XLR", + "RCA", + "耳机", + "XLR/RCA" + ], + "input": { + "coaxial": "同轴", + "optical": "光纤", + "bluetooth": "蓝牙", + "rca": "模拟RCA" + }, + "output": { + "headset": "耳机" + } }, "effect": { "pageTitle": "音效", @@ -74,25 +93,75 @@ "style": { "label": "风格", "options": [ - { "index": 0, "label": "古典" }, - { "index": 1, "label": "舞曲" }, - { "index": 2, "label": "流行" }, - { "index": 3, "label": "雷鬼" }, - { "index": 4, "label": "现场" }, - { "index": 5, "label": "摇滚" }, - { "index": 6, "label": "柔和" }, - { "index": 7, "label": "电子乐" }, - { "index": 8, "label": "俱乐部" }, - { "index": 9, "label": "全低音" }, - { "index": 10, "label": "全高音" }, - { "index": 11, "label": "耳机" }, - { "index": 12, "label": "大厅" }, - { "index": 13, "label": "聚合" }, - { "index": 14, "label": "斯卡" }, - { "index": 15, "label": "慢摇" } + { + "index": 0, + "label": "古典" + }, + { + "index": 1, + "label": "舞曲" + }, + { + "index": 2, + "label": "流行" + }, + { + "index": 3, + "label": "雷鬼" + }, + { + "index": 4, + "label": "现场" + }, + { + "index": 5, + "label": "摇滚" + }, + { + "index": 6, + "label": "柔和" + }, + { + "index": 7, + "label": "电子乐" + }, + { + "index": 8, + "label": "俱乐部" + }, + { + "index": 9, + "label": "全低音" + }, + { + "index": 10, + "label": "全高音" + }, + { + "index": 11, + "label": "耳机" + }, + { + "index": 12, + "label": "大厅" + }, + { + "index": 13, + "label": "聚合" + }, + { + "index": 14, + "label": "斯卡" + }, + { + "index": 15, + "label": "慢摇" + } ] }, - "stereoWidth": { "label": "声场宽度" }, + "stereoWidth": { + "label": "声场宽度" + }, "crossfeed": { "label": "交叉反馈", "options": [ @@ -109,8 +178,14 @@ "output": { "label": "输出方式", "options": [ - { "label": "单声道", "index": 0 }, - { "label": "立体声", "index": 1 } + { + "label": "单声道", + "index": 0 + }, + { + "label": "立体声", + "index": 1 + } ] } }, @@ -265,8 +340,40 @@ "paramInputInvalid": "请输入有效数值", "paramInputOutOfRangeFreq": "频率需在 {{min}} – {{max}} Hz 之间", "paramInputOutOfRangeGain": "增益需在 {{min}} – {{max}} dB 之间", - "paramInputOutOfRangeQ": "Q 值需在 {{min}} – {{max}} 之间" - } + "paramInputOutOfRangeQ": "Q 值需在 {{min}} – {{max}} 之间", + "overwritePresetTitle": "预设名称已存在", + "overwritePresetDesc": "预设「{{name}}」已存在,确认覆盖原有数据吗?", + "overwritePresetConfirm": "覆盖", + "overwritePresetCancelled": "已取消", + "shareTitle": "分享 EQ", + "shareSelectHint": "选择要分享的 EQ 预设", + "shareConfirm": "生成分享码", + "shareCodeLabel": "分享码", + "shareCopied": "已复制分享码", + "shareGenerating": "生成中…", + "shareCreateFail": "生成分享码失败", + "shareActiveExists": "已有未过期的分享码,请稍后再试", + "shareInvalidParams": "请求参数无效,请检查后重试", + "shareSystemError": "系统错误,请稍后重试", + "shareExpireAt": "有效期至", + "importTab": "导入 EQ", + "importCodeHint": "输入或粘贴 5 位分享码", + "importQuery": "查询", + "importQuerying": "查询中…", + "importCodeNotFound": "分享码不存在或已过期", + "importEqName": "EQ 名称", + "importButton": "导入", + "importSaving": "导入中…", + "importSuccess": "EQ 导入成功", + "importFail": "EQ 导入失败", + "mySharesTab": "我的分享", + "mySharesLoading": "加载中…", + "mySharesEmpty": "暂无分享记录", + "mySharesCode": "分享码", + "mySharesName": "EQ 名称", + "mySharesLoadFail": "加载分享列表失败" + }, + "share": "分享 EQ" }, "dac": { "sectionBalanceSensitivity": "平衡与灵敏度", @@ -278,42 +385,90 @@ "filters": { "label": "滤波特性", "options": [ - { "index": 0, "label": "快速滚降" }, - { "index": 1, "label": "慢速滚降" }, - { "index": 2, "label": "短延迟快速滚降" }, - { "index": 3, "label": "短延迟慢速滚降" }, - { "index": 4, "label": "去重强调" }, - { "index": 5, "label": "非过采样(NOS)" } + { + "index": 0, + "label": "快速滚降" + }, + { + "index": 1, + "label": "慢速滚降" + }, + { + "index": 2, + "label": "短延迟快速滚降" + }, + { + "index": 3, + "label": "短延迟慢速滚降" + }, + { + "index": 4, + "label": "去重强调" + }, + { + "index": 5, + "label": "非过采样(NOS)" + } ] }, "dacGain": { "label": "耳机增益", "options": [ - { "index": 0, "label": "低" }, - { "index": 1, "label": "中" }, - { "index": 2, "label": "高" } + { + "index": 0, + "label": "低" + }, + { + "index": 1, + "label": "中" + }, + { + "index": 2, + "label": "高" + } ] }, "autoImpedance": { "label": "自动检测耳机阻抗", "options": [ - { "index": 0, "label": "关闭" }, - { "index": 1, "label": "打开" } + { + "index": 0, + "label": "关闭" + }, + { + "index": 1, + "label": "打开" + } ] }, "dreMode": { "label": "动态范围增强", "options": [ - { "index": 0, "label": "关闭" }, - { "index": 1, "label": "打开" } + { + "index": 0, + "label": "关闭" + }, + { + "index": 1, + "label": "打开" + } ] }, "dacVolumeDirect": { "label": "前级输出音量直通模式", "options": [ - { "index": 0, "label": "关闭" }, - { "index": 1, "label": "0dB" }, - { "index": 2, "label": "-12dB" } + { + "index": 0, + "label": "关闭" + }, + { + "index": 1, + "label": "0dB" + }, + { + "index": 2, + "label": "-12dB" + } ] }, "dacVolumeDirectConfirm": { @@ -328,51 +483,126 @@ "bootVolume": { "label": "开机音量", "options": [ - { "index": 0, "label": "默认" }, - { "index": 1, "label": "-5dB" }, - { "index": 2, "label": "-10dB" }, - { "index": 3, "label": "-15dB" }, - { "index": 4, "label": "-20dB" }, - { "index": 5, "label": "-25dB" }, - { "index": 6, "label": "-30dB" }, - { "index": 7, "label": "-35dB" }, - { "index": 8, "label": "-40dB" }, - { "index": 9, "label": "-45dB" }, - { "index": 10, "label": "-50dB" } + { + "index": 0, + "label": "默认" + }, + { + "index": 1, + "label": "-5dB" + }, + { + "index": 2, + "label": "-10dB" + }, + { + "index": 3, + "label": "-15dB" + }, + { + "index": 4, + "label": "-20dB" + }, + { + "index": 5, + "label": "-25dB" + }, + { + "index": 6, + "label": "-30dB" + }, + { + "index": 7, + "label": "-35dB" + }, + { + "index": 8, + "label": "-40dB" + }, + { + "index": 9, + "label": "-45dB" + }, + { + "index": 10, + "label": "-50dB" + } ] }, "xlr": { "label": "XLR端口极性", "options": [ - { "index": 0, "label": "正常" }, - { "index": 1, "label": "反向" } + { + "index": 0, + "label": "正常" + }, + { + "index": 1, + "label": "反向" + } ] }, "arc": { "label": "ARC模式", "options": [ - { "index": 0, "label": "ARC" }, - { "index": 1, "label": "EARC" } + { + "index": 0, + "label": "ARC" + }, + { + "index": 1, + "label": "EARC" + } ] }, "mutePolar": { "label": "IIS静音电平", "options": [ - { "index": 0, "label": "低电平" }, - { "index": 1, "label": "高电平" } + { + "index": 0, + "label": "低电平" + }, + { + "index": 1, + "label": "高电平" + } ] }, "IISMode": { "label": "IIS模式", "options": [ - { "index": 0, "label": "模式1" }, - { "index": 1, "label": "模式2" }, - { "index": 2, "label": "模式3" }, - { "index": 3, "label": "模式4" }, - { "index": 4, "label": "模式5" }, - { "index": 5, "label": "模式6" }, - { "index": 6, "label": "模式7" }, - { "index": 7, "label": "模式8" } + { + "index": 0, + "label": "模式1" + }, + { + "index": 1, + "label": "模式2" + }, + { + "index": 2, + "label": "模式3" + }, + { + "index": 3, + "label": "模式4" + }, + { + "index": 4, + "label": "模式5" + }, + { + "index": 5, + "label": "模式6" + }, + { + "index": 6, + "label": "模式7" + }, + { + "index": 7, + "label": "模式8" + } ] } }, @@ -382,72 +612,161 @@ "screenBrightness": { "label": "屏幕亮度", "options": [ - { "index": 0, "label": "较亮" }, - { "index": 1, "label": "中等" }, - { "index": 2, "label": "较暗" } + { + "index": 0, + "label": "较亮" + }, + { + "index": 1, + "label": "中等" + }, + { + "index": 2, + "label": "较暗" + } ] }, "sleep": { "label": "休眠", "options": [ - { "index": 0, "label": "关闭" }, - { "index": 1, "label": "无信号1分钟" }, - { "index": 2, "label": "无信号5分钟" }, - { "index": 3, "label": "无信号10分钟" }, - { "index": 4, "label": "无信号15分钟" } + { + "index": 0, + "label": "关闭" + }, + { + "index": 1, + "label": "无信号1分钟" + }, + { + "index": 2, + "label": "无信号5分钟" + }, + { + "index": 3, + "label": "无信号10分钟" + }, + { + "index": 4, + "label": "无信号15分钟" + } ] }, "turnOffScreen": { "label": "关闭屏幕", "options": [ - { "index": 0, "label": "常亮" }, - { "index": 1, "label": "无操作30秒" }, - { "index": 2, "label": "无操作1分钟" }, - { "index": 3, "label": "无操作3分钟" }, - { "index": 4, "label": "无操作5分钟" } + { + "index": 0, + "label": "常亮" + }, + { + "index": 1, + "label": "无操作30秒" + }, + { + "index": 2, + "label": "无操作1分钟" + }, + { + "index": 3, + "label": "无操作3分钟" + }, + { + "index": 4, + "label": "无操作5分钟" + } ] }, "knobBrightness": { "label": "旋钮亮度", "options": [ - { "index": 0, "label": "关闭" }, - { "index": 1, "label": "较亮" }, - { "index": 2, "label": "中等" }, - { "index": 3, "label": "较暗" } + { + "index": 0, + "label": "关闭" + }, + { + "index": 1, + "label": "较亮" + }, + { + "index": 2, + "label": "中等" + }, + { + "index": 3, + "label": "较暗" + } ] }, - "sleepTime": { "label": "休眠", "options": [ - { "index": 0, "label": "关闭" }, - { "index": 1, "label": "无信号1分钟后" }, - { "index": 2, "label": "无信号5分钟后" }, - { "index": 3, "label": "无信号10分钟后" } + { + "index": 0, + "label": "关闭" + }, + { + "index": 1, + "label": "无信号1分钟后" + }, + { + "index": 2, + "label": "无信号5分钟后" + }, + { + "index": 3, + "label": "无信号10分钟后" + } ] }, "buttonLight": { "label": "旋钮熄屏呼吸灯", "options": [ - { "index": 0, "label": "开启" }, - { "index": 1, "label": "关闭" } + { + "index": 0, + "label": "开启" + }, + { + "index": 1, + "label": "关闭" + } ] }, "language": { "label": "语言", "options": [ - { "index": 0, "label": "英语" }, - { "index": 1, "label": "中文(繁体)" }, - { "index": 2, "label": "中文(简体)" } + { + "index": 0, + "label": "英语" + }, + { + "index": 1, + "label": "中文(繁体)" + }, + { + "index": 2, + "label": "中文(简体)" + } ] }, "Automatic": { "label": "自动返回首页", "options": [ - { "index": 0, "label": "关闭" }, - { "index": 1, "label": "无操作20秒" }, - { "index": 2, "label": "无操作40秒" }, - { "index": 3, "label": "无操作60秒" } + { + "index": 0, + "label": "关闭" + }, + { + "index": 1, + "label": "无操作20秒" + }, + { + "index": 2, + "label": "无操作40秒" + }, + { + "index": 3, + "label": "无操作60秒" + } ] }, "systemPage": { @@ -498,10 +817,22 @@ "welcomeDescription": "可以帮你查询与调整 {device} 的设置,分析耳机频响、优化 EQ,或者解答使用上的疑问。", "suggestionsLabel": "试试这样问", "suggestions": [ - { "title": "优化我的 EQ", "prompt": "帮我优化当前 EQ,我希望人声更清晰一些" }, - { "title": "推荐流行乐 EQ", "prompt": "推荐一套适合听流行音乐的 EQ 参数" }, - { "title": "查看设备状态", "prompt": "帮我查看当前的音量、输入源和输出端口" }, - { "title": "切换输入源", "prompt": "把输入源切换到 USB-C" } + { + "title": "优化我的 EQ", + "prompt": "帮我优化当前 EQ,我希望人声更清晰一些" + }, + { + "title": "推荐流行乐 EQ", + "prompt": "推荐一套适合听流行音乐的 EQ 参数" + }, + { + "title": "查看设备状态", + "prompt": "帮我查看当前的音量、输入源和输出端口" + }, + { + "title": "切换输入源", + "prompt": "把输入源切换到 USB-C" + } ], "inputPlaceholder": "问问关于设备、EQ、音效……", "thinking": "思考中…", diff --git a/client/src/pages/EQPage.tsx b/client/src/pages/EQPage.tsx index e609c5a..a98661b 100644 --- a/client/src/pages/EQPage.tsx +++ b/client/src/pages/EQPage.tsx @@ -11,7 +11,7 @@ 7. Total gain card: 总增益 value + AUTO toggle + slider ============================================================ */ import { useDevice } from "@/contexts/DeviceContext"; -import { ChevronLeft, ChevronDown, Minus, Plus, Edit3, Headphones, Search, X } from "lucide-react"; +import { ChevronLeft, ChevronDown, Minus, Plus, Edit3, Headphones, Share2 } from "lucide-react"; import { useLocation } from "wouter"; import { useState, useMemo, useEffect, useRef, useCallback } from "react"; import { cn } from "@/lib/utils"; @@ -19,27 +19,27 @@ import BottomNav from "@/components/BottomNav"; import { FeatureGate } from "@/components/FeatureGate"; import { toast } from "sonner"; import { - decodeCustomBase64, - fetchLuxsinAudioBrands, fetchLuxsinAudioCurve, - fetchLuxsinAudioModelList, - fetchLuxsinAudioModels, - type LuxsinAudioBrand, - type LuxsinAudioModelListItem, - type LuxsinAudioModel, type PeqFilter, type PeqApplyPayload, type PeqChangePayload, - type PeqPresetBody, type PeqState, + buildPeqPresetBody, } from "@/lib/luxsinApi"; -import * as echarts from "echarts"; -import { getSectionsMatrix, visualizeResponse, getChartOps, getFilterType, getFilterShortName } from "@/lib/peqAudio"; +import { getFilterType, getFilterShortName } from "@/lib/peqAudio"; import localeZh from "@/locales/data-zh.json"; import localeZhHK from "@/locales/data-zh-HK.json"; import localeEn from "@/locales/data-en.json"; import { FreqChart } from "./eq/components/FreqChart"; import { CyanSlider, IOSToggle } from "./eq/components/EqPrimitives"; +import { BandParamDialog } from "./eq/components/BandParamDialog"; +import { SaveBDialog } from "./eq/components/SaveBDialog"; +import { AddPresetDialog } from "./eq/components/AddPresetDialog"; +import { BatchEditDialog } from "./eq/components/BatchEditDialog"; +import { ShareDialog } from "./eq/components/ShareDialog"; +import { BrandDrawer } from "./eq/components/BrandDrawer"; +import { PeqOverwriteConfirmDialog } from "./eq/components/PeqOverwriteConfirmDialog"; +import { useRawCurve } from "./eq/hooks/useRawCurve"; import { BAND_FREQ_MAX, BAND_FREQ_MIN, @@ -48,7 +48,6 @@ import { BAND_PARAM_VALUE_BOX_STYLE, BAND_Q_MAX, BAND_Q_MIN, - CATALOG_TARGETS, DEFAULT_BANDS, FILTER_TYPES, FLAT_PRESET_FILTERS, @@ -71,12 +70,11 @@ import { cloneBands, fetchEqSyncPeq, getUniquePresetName, + importedFiltersToPeqFilters, } from "./eq/peqMappers"; import type { BandParamKind, PeqCatalogItem, PeqEqUi, PeqPresetLocalCache } from "./eq/types"; /* ── Main Component ── */ -const rawCurveCache = new Map(); - export default function EQPage() { const [, setLocation] = useLocation(); const { @@ -121,6 +119,8 @@ export default function EQPage() { name: string; brand?: string; model?: string; + target?: string; + form?: string; filters?: any[] | string; autoPre?: number; preamp?: number; @@ -147,29 +147,24 @@ export default function EQPage() { const bandSliderDragRef = useRef(null); const [isBatchEditDialogOpen, setIsBatchEditDialogOpen] = useState(false); const [batchEditText, setBatchEditText] = useState(""); - type BrandDrawerTab = "brands" | "models" | "target"; const [isBrandDrawerOpen, setIsBrandDrawerOpen] = useState(false); - const [brandDrawerTab, setBrandDrawerTab] = useState("brands"); - const [brandSearchQuery, setBrandSearchQuery] = useState(""); - const [catalogBrands, setCatalogBrands] = useState([]); - const [catalogBrandsLoading, setCatalogBrandsLoading] = useState(false); - const [catalogBrandsError, setCatalogBrandsError] = useState(null); - const [catalogSearchResults, setCatalogSearchResults] = useState([]); - const [catalogSearchLoading, setCatalogSearchLoading] = useState(false); - const [catalogSearchError, setCatalogSearchError] = useState(null); - const [selectedCatalogBrand, setSelectedCatalogBrand] = useState(""); - const [catalogModels, setCatalogModels] = useState([]); - const [catalogModelsLoading, setCatalogModelsLoading] = useState(false); - const [catalogModelsError, setCatalogModelsError] = useState(null); - const [selectedCatalogModelFromSearch, setSelectedCatalogModelFromSearch] = useState(""); - const [selectedCatalogModelName, setSelectedCatalogModelName] = useState(""); - const [selectedCatalogModelForm, setSelectedCatalogModelForm] = useState(undefined); - const [selectedCatalogTarget, setSelectedCatalogTarget] = useState(""); - const [isConfirmingTarget, setIsConfirmingTarget] = useState(false); - const [currentRawCurve, setCurrentRawCurve] = useState(null); - const [rawCurveLoading, setRawCurveLoading] = useState(false); - const rawCurveCacheRef = useRef(rawCurveCache); + const [brandDrawerKey, setBrandDrawerKey] = useState(0); + const [isShareDialogOpen, setIsShareDialogOpen] = useState(false); + const [shareDialogKey, setShareDialogKey] = useState(0); + const [overwritePresetDialog, setOverwritePresetDialog] = useState<{ + name: string; + onConfirm: () => void | Promise; + onDismiss: () => void; + } | null>(null); + const { + currentRawCurve, + setCurrentRawCurve, + rawCurveLoading, + setRawCurveLoading, + loadRawCurveForPeq, + } = useRawCurve(); const allowPeqRemoteSyncRef = useRef(false); + const peqHydrationPendingRef = useRef(true); const lastPeqCatalogSyncKeyRef = useRef(""); const syncingHeadphoneRef = useRef(false); const peqSyncTimerRef = useRef(null); @@ -219,77 +214,6 @@ export default function EQPage() { const preampValue = Number(currentPeqPreamp); const autoPreOn = currentPeqAutoPre === 1; - const filteredCatalogBrands = useMemo(() => { - const q = brandSearchQuery.trim().toLowerCase(); - if (!q) return catalogBrands; - return catalogBrands.filter((b) => b.name.toLowerCase().includes(q)); - }, [catalogBrands, brandSearchQuery]); - - const availableCatalogTargets = useMemo(() => { - if (selectedCatalogModelForm === "in-ear") { - return CATALOG_TARGETS.filter((item) => item.ear === "in" || item.ear === "all"); - } - if (selectedCatalogModelForm === "over-ear") { - return CATALOG_TARGETS.filter((item) => item.ear === "over" || item.ear === "all"); - } - return CATALOG_TARGETS; - }, [selectedCatalogModelForm]); - - const loadCatalogBrands = useCallback(async () => { - setCatalogBrandsLoading(true); - setCatalogBrandsError(null); - try { - const list = await fetchLuxsinAudioBrands(); - const sorted = [...list].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })); - setCatalogBrands(sorted); - } catch (e) { - const msg = e instanceof Error ? e.message : "加载失败"; - setCatalogBrandsError(msg); - toast.error(eqUi.toastBrandsFail); - } finally { - setCatalogBrandsLoading(false); - } - }, [eqUi]); - - const searchCatalogByKeyword = useCallback(async (keyword: string) => { - const q = keyword.trim(); - if (!q) { - setCatalogSearchResults([]); - setCatalogSearchError(null); - setCatalogSearchLoading(false); - return; - } - setCatalogSearchLoading(true); - setCatalogSearchError(null); - try { - const list = await fetchLuxsinAudioModelList(q, 1000); - setCatalogSearchResults(list); - } catch (e) { - const msg = e instanceof Error ? e.message : "搜索失败"; - setCatalogSearchError(msg); - setCatalogSearchResults([]); - } finally { - setCatalogSearchLoading(false); - } - }, []); - - const loadCatalogModels = useCallback(async (brandName: string) => { - setSelectedCatalogModelFromSearch(""); - setCatalogModelsLoading(true); - setCatalogModelsError(null); - try { - const list = await fetchLuxsinAudioModels(brandName); - const sorted = [...list].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })); - setCatalogModels(sorted); - } catch (e) { - const msg = e instanceof Error ? e.message : "加载失败"; - setCatalogModelsError(msg); - toast.error(eqUi.toastModelsFail); - } finally { - setCatalogModelsLoading(false); - } - }, [eqUi]); - const guardPeqPresetCapacity = () => { if (peqItems.length < PEQ_PRESET_MAX) return true; toast.error( @@ -302,46 +226,31 @@ export default function EQPage() { const openAddHeadsetCatalog = () => { if (!guardPeqPresetCapacity()) return; setIsBrandDrawerOpen(true); - setBrandDrawerTab("brands"); - setBrandSearchQuery(""); - setCatalogSearchResults([]); - setCatalogSearchError(null); - setCatalogSearchLoading(false); - setSelectedCatalogBrand(""); - setSelectedCatalogModelFromSearch(""); - setSelectedCatalogModelName(""); - setSelectedCatalogModelForm(undefined); - setSelectedCatalogTarget(""); - setCatalogModels([]); - setCatalogModelsError(null); - void loadCatalogBrands(); + setBrandDrawerKey((k) => k + 1); }; - useEffect(() => { - if (!isBrandDrawerOpen || brandDrawerTab !== "brands") return; - const timer = window.setTimeout(() => { - void searchCatalogByKeyword(brandSearchQuery); - }, 260); - return () => window.clearTimeout(timer); - }, [isBrandDrawerOpen, brandDrawerTab, brandSearchQuery, searchCatalogByKeyword]); - - useEffect(() => { - if (!isBrandDrawerOpen) return; - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") setIsBrandDrawerOpen(false); - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [isBrandDrawerOpen]); - - useEffect(() => { - if (!isBrandDrawerOpen) return; - const prev = document.body.style.overflow; - document.body.style.overflow = "hidden"; - return () => { - document.body.style.overflow = prev; - }; - }, [isBrandDrawerOpen]); + const promptOverwriteIfNeeded = useCallback( + (name: string, action: () => void | Promise): Promise => { + if (!headphoneModels.includes(name)) { + return Promise.resolve(action()).then(() => true); + } + return new Promise((resolve) => { + setOverwritePresetDialog({ + name, + onConfirm: async () => { + setOverwritePresetDialog(null); + await action(); + resolve(true); + }, + onDismiss: () => { + setOverwritePresetDialog(null); + resolve(false); + }, + }); + }); + }, + [headphoneModels], + ); const normalizeFiltersFromPeq = (peq: { filters?: any[] | string } | undefined) => { if (!peq) return [] as typeof bands; @@ -483,7 +392,7 @@ export default function EQPage() { }) ); setBands(parsed.filters); - schedulePeqSync(updatedPeq, parsed.filters, abMode, 0); + schedulePeqSync(updatedPeq, parsed.filters, abMode, 0, "byMode", headphoneIdx); setSelectedBand(0); setIsBatchEditDialogOpen(false); toast.success(eqUi.toastBatchApplied); @@ -688,28 +597,29 @@ export default function EQPage() { const copyBrand = currentPeq?.brand?.trim() ?? ""; const copyModel = currentPeq?.model?.trim() ?? ""; + const copyTarget = currentPeq?.target?.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, - canDel: currentPeq?.canDel ?? 1, - }, + peqChange: buildPeqPresetBody( + { + name: nextName, + brand: copyBrand || undefined, + model: copyModel || undefined, + target: copyTarget || undefined, + form: copyForm || undefined, + autoPre: currentPeq?.autoPre, + preamp: currentPeq?.preamp, + canDel: currentPeq?.canDel, + }, + bands.map(bandToPeqFilter), + ), }; const flatPayload: PeqChangePayload = { - peqChange: { - name: nextName, - preamp: 0, - canDel: 1, - autoPre: 0, - filters: FLAT_PRESET_FILTERS, - }, + peqChange: buildPeqPresetBody( + { name: nextName, preamp: 0, canDel: 1, autoPre: 0 }, + FLAT_PRESET_FILTERS, + ), }; try { @@ -735,6 +645,7 @@ export default function EQPage() { name: nextName, ...(addPresetMode === "copy" && copyBrand ? { brand: copyBrand } : {}), ...(addPresetMode === "copy" && copyModel ? { model: copyModel } : {}), + ...(addPresetMode === "copy" && copyTarget ? { target: copyTarget } : {}), ...(addPresetMode === "copy" && copyForm ? { form: copyForm } : {}), filters: localFilters, autoPre: localAutoPre, @@ -765,13 +676,19 @@ export default function EQPage() { const bBands = cloneBands(bandsByMode.B); const filters = bBands.map(bandToPeqFilter); const payload: PeqChangePayload = { - peqChange: { - name: nextName, + peqChange: buildPeqPresetBody( + { + name: nextName, + brand: currentPeq?.brand, + model: currentPeq?.model, + target: currentPeq?.target, + form: currentPeq?.form, + autoPre: currentPeq?.autoPre, + preamp: currentPeq?.preamp, + canDel: currentPeq?.canDel, + }, filters, - autoPre: currentPeq?.autoPre ?? 0, - preamp: currentPeq?.preamp ?? 0, - canDel: currentPeq?.canDel ?? 1, - }, + ), }; try { @@ -805,6 +722,115 @@ export default function EQPage() { } }; + const handleImportSharedEq = async ( + data: { + name: string; + brand?: string; + model?: string; + target?: string; + form?: string; + filters?: unknown; + preamp?: number; + autoPre?: number; + }, + shareCode: string, + ) => { + const nextName = data.name?.trim(); + if (!nextName) { + toast.error(eqUi.addPresetNameEmpty); + return; + } + if (!shareCode.trim()) { + toast.error(eqUi.importCodeNotFound); + return; + } + if (!guardPeqPresetCapacity() && !headphoneModels.includes(nextName)) return; + + const filters = importedFiltersToPeqFilters(data.filters); + if (filters.length === 0) { + toast.error(eqUi.importFail ?? eqUi.toastAddPresetFail); + return; + } + + const submitImport = async () => { + const existingIdx = headphoneModels.indexOf(nextName); + const isOverwrite = existingIdx >= 0; + const payload: PeqChangePayload = { + peqChange: buildPeqPresetBody( + { + name: nextName, + brand: data.brand, + model: data.model, + target: data.target, + form: data.form, + preamp: data.preamp, + autoPre: data.autoPre ?? 0, + canDel: 1, + }, + filters, + ), + }; + + skipPeqAutoSyncRef.current = true; + try { + await upgradePeqChange(payload); + if (api && !isDemoMode) { + const latest = await fetchEqSyncPeq(api, "importSharedEq"); + applyPeqStateToUI(latest as { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number }); + const targetIdx = latest.peq?.findIndex((item) => item.name === nextName) ?? -1; + if (targetIdx >= 0) { + setHeadphoneIdx(targetIdx); + updateSetting({ peqSelect: targetIdx }); + } + } else if (isOverwrite) { + setPeqItems((prev) => + prev.map((item, i) => + i === existingIdx + ? { + ...item, + name: nextName, + brand: data.brand, + model: data.model, + target: data.target, + form: data.form, + filters, + preamp: data.preamp ?? 0, + autoPre: data.autoPre ?? 0, + canDel: 1, + } + : item, + ), + ); + setHeadphoneIdx(existingIdx); + } else { + setHeadphoneModels((prev) => [...prev, nextName]); + setPeqItems((prev) => [ + ...prev, + { + name: nextName, + brand: data.brand, + model: data.model, + target: data.target, + form: data.form, + filters, + preamp: data.preamp ?? 0, + autoPre: data.autoPre ?? 0, + canDel: 1, + }, + ]); + setHeadphoneIdx(headphoneModels.length); + } + toast.success(eqUi.importSuccess); + setIsShareDialogOpen(false); + } catch { + skipPeqAutoSyncRef.current = false; + toast.error(eqUi.importFail ?? eqUi.toastAddPresetFail); + } + }; + + await promptOverwriteIfNeeded(nextName, submitImport); + }; + // msgCount 轮询更新 peqState 后,同步耳机列表与当前选中项 useEffect(() => { if (!peqState?.peq) return; @@ -819,93 +845,10 @@ export default function EQPage() { setAbMode("A"); }, [headphoneIdx]); - // 获取耳机原始曲线 - const getModelCurve = async (brand: string, name: string) => { - try { - console.log("[modelCurve] request", { brand, name }); - const resp = await fetch( - `//api.luxsin.com.cn/audio/modelCurve?brand=${encodeURIComponent(brand)}&name=${encodeURIComponent(name)}` - ); - const data = await resp.text(); - // 使用自定义 Base64 解码 - const decoded = decodeCustomBase64(data); - const parsed = JSON.parse(decoded); - console.log("[modelCurve] decoded", { - hasFrRaw: - !!(parsed && - typeof parsed === "object" && - "fr" in (parsed as Record) && - (parsed as { fr?: unknown }).fr && - typeof (parsed as { fr?: unknown }).fr === "object" && - "raw" in ((parsed as { fr?: Record }).fr ?? {})), - }); - console.log("[modelCurve] fr.raw", (parsed as { fr?: { raw?: unknown } }).fr?.raw); - return parsed; - } catch (error) { - console.log("[modelCurve] request failed", error); - return null; - } - }; - - const loadRawCurveForPeq = useCallback( - async (peq: { brand?: string; model?: string; name?: string } | undefined): Promise => { - const brand = peq?.brand?.trim() ?? ""; - const model = peq?.model?.trim() ?? ""; - if (!brand || !model) return null; - - const cacheKey = `${brand}|${model}`; - if (rawCurveCacheRef.current.has(cacheKey)) { - const cached = rawCurveCacheRef.current.get(cacheKey); - if (cached) { - console.log("[modelCurve] cache hit", { brand, model, rawLength: cached.length }); - } else { - console.log("[modelCurve] cache hit (no raw)", { brand, model }); - } - return cached ?? null; - } - - const modelCurve = await getModelCurve(brand, model); - if (modelCurve && typeof modelCurve === "object") { - const payload = modelCurve as { - fr?: { raw?: unknown } | unknown; - }; - const rawCandidate = - payload.fr && typeof payload.fr === "object" && Array.isArray((payload.fr as { raw?: unknown }).raw) - ? (payload.fr as { raw?: unknown }).raw - : null; - const raw = Array.isArray(rawCandidate) - ? rawCandidate - .map((point) => { - if (typeof point === "number" && Number.isFinite(point)) return point; - if (Array.isArray(point) && point.length >= 2 && typeof point[1] === "number") return point[1]; - if (point && typeof point === "object") { - const y = (point as Record).y - ?? (point as Record).value - ?? (point as Record).db - ?? (point as Record).gain; - if (typeof y === "number" && Number.isFinite(y)) return y; - } - return NaN; - }) - : null; - const cleanedRaw = raw && raw.every((v) => Number.isFinite(v)) ? (raw as number[]) : null; - rawCurveCacheRef.current.set(cacheKey, cleanedRaw); - console.log("[modelCurve] raw check", { - brand, - model, - hasRaw: !!cleanedRaw, - rawLength: cleanedRaw?.length ?? 0, - }); - return cleanedRaw; - } - return null; - }, - [], - ); - // 加载耳机列表并初始化曲线 useEffect(() => { async function loadHeadphones() { + peqHydrationPendingRef.current = true; allowPeqRemoteSyncRef.current = false; try { if (isDemoMode || !api) { @@ -926,18 +869,18 @@ export default function EQPage() { try { const loadedPeq = await fetchEqSyncPeq(api, "loadHeadphones"); if (loadedPeq.peq && loadedPeq.peq.length > 0) { - syncPeqCatalog(loadedPeq); - setHeadphoneModels(loadedPeq.peq.map((h) => h.name)); - setPeqItems(loadedPeq.peq); + const mergedPeq = mergeRemotePeqCatalog(loadedPeq.peq); + setHeadphoneModels(mergedPeq.map((h) => h.name)); + setPeqItems(mergedPeq); const nextIdx = Math.min( Math.max(deviceState?.peqSelect ?? loadedPeq.peqSelect ?? 0, 0), - loadedPeq.peq.length - 1, + mergedPeq.length - 1, ); setHeadphoneIdx(nextIdx); lastPeqCatalogSyncKeyRef.current = buildPeqCatalogSyncKey(loadedPeq, deviceState?.peqSelect); // 初始化当前选中的耳机曲线 - const currentPeq = loadedPeq.peq[nextIdx]; + const currentPeq = mergedPeq[nextIdx]; if (currentPeq) { // 修复 filters 数据(兼容不同的字段名) const fixedFilters = normalizeFiltersFromPeq(currentPeq); @@ -982,10 +925,14 @@ export default function EQPage() { } } finally { allowPeqRemoteSyncRef.current = true; + skipPeqAutoSyncRef.current = true; + queueMicrotask(() => { + peqHydrationPendingRef.current = false; + }); } } loadHeadphones(); - }, [api, isDemoMode, loadRawCurveForPeq]); + }, [api, isDemoMode, loadRawCurveForPeq, mergeRemotePeqCatalog, deviceState?.peqSelect]); /** 切换耳机型号时变;不含 filters,避免编辑时写回 peqItems 把 B 试听曲线重置 */ const headphoneSwitchKey = useMemo(() => { @@ -1017,7 +964,6 @@ export default function EQPage() { if (cancelled) return; setCurrentRawCurve(raw); setRawCurveLoading(false); - renderCharts(nextBands, raw, false); })(); requestAnimationFrame(() => { syncingHeadphoneRef.current = false; @@ -1031,7 +977,16 @@ export default function EQPage() { const schedulePeqSync = useCallback( ( - peq: { name: string; autoPre?: number; preamp?: number; canDel?: number } | undefined, + peq: { + name: string; + autoPre?: number; + preamp?: number; + canDel?: number; + brand?: string; + model?: string; + target?: string; + form?: string; + } | undefined, filtersSource: typeof DEFAULT_BANDS, mode: "A" | "B", delay = 320, @@ -1039,6 +994,7 @@ export default function EQPage() { catalogIdx = headphoneIdx, ) => { if (!allowPeqRemoteSyncRef.current) return; + if (peqHydrationPendingRef.current) return; if (!isDemoMode && !api) return; if (!peq?.name) return; @@ -1049,13 +1005,7 @@ export default function EQPage() { const idxAtSchedule = catalogIdx; peqSyncTimerRef.current = window.setTimeout(() => { const filters = filtersSource.map(bandToPeqFilter); - const body: PeqPresetBody = { - name: peq.name, - filters, - autoPre: peq.autoPre ?? 0, - preamp: peq.preamp ?? 0, - canDel: peq.canDel ?? 1, - }; + const body = buildPeqPresetBody(peq, filters); const usePeqChange = topLevel === "peqChange" || (topLevel === "byMode" && mode === "A"); if (usePeqChange) { @@ -1100,6 +1050,7 @@ export default function EQPage() { // 参数编辑防抖:A→peqChange,B→peqApply(不因切换 A/B 误触发) useEffect(() => { + if (peqHydrationPendingRef.current) return; if (syncingHeadphoneRef.current) return; if (skipPeqAutoSyncRef.current) { skipPeqAutoSyncRef.current = false; @@ -1133,90 +1084,12 @@ export default function EQPage() { return () => window.removeEventListener("pointerdown", onPointerDown); }, []); - const chartRef = useRef(null); const band = bands[selectedBand] ?? DEFAULT_BANDS[selectedBand] ?? DEFAULT_BANDS[0]; useEffect(() => { setBandSliderPreview(null); }, [selectedBand, abMode, headphoneSwitchKey]); - // Render frequency response chart using ECharts - const renderCharts = (peqFilters: typeof bands, raw: number[] | null, changeParam: boolean) => { - const list: any[] = []; - const fs = 48000; - - // Calculate coefficient matrix for each filter - peqFilters.forEach((item) => { - const filterType = getFilterType(item.type); - - const coeff = getSectionsMatrix( - item.gain, - item.freq, - item.q, - filterType, - false, - fs - ); - if (coeff) { - list.push(coeff); - } - }); - - // Get frequency response data - const dataSet = visualizeResponse(list, fs); - const ops = getChartOps(dataSet, 20, -20, '#FFED00') as any; - - // Get or create chart instance - const chartDom = document.getElementById('freq-chart'); - if (!chartDom) return; - - if (!chartRef.current) { - chartRef.current = echarts.init(chartDom); - } - - const myChart = chartRef.current; - - // Handle changeParam (get raw data from existing chart) - if (changeParam && myChart) { - const option = myChart.getOption() as { series?: Array<{ data?: number[] }> }; - if (option.series && option.series.length > 1) { - raw = (option.series[1] as any).data; - } - } - - // Clear and rebuild chart - myChart.clear(); - - // Add Raw and Equalized curves if raw data is available (expects same 349 points as EQ curve) - if (Array.isArray(raw) && raw.length === dataSet[1].length) { - ops.series.push({ - name: 'Raw', - data: raw, - type: 'line', - showSymbol: false, - lineStyle: { - color: '#ffffff', - }, - }); - - // Calculate equalized curve - const equalizedRaw = dataSet[1].map((value, index) => value + raw[index]); - ops.series.push({ - name: 'Equalized', - data: equalizedRaw, - type: 'line', - showSymbol: false, - lineStyle: { - color: '#23d2fe', - width: 6, - opacity: 0.7, - }, - }); - } - - myChart.setOption(ops, true); - }; - const handleApplyB = useCallback(async () => { const peq = peqItems[headphoneIdx]; if (!peq?.name) { @@ -1227,13 +1100,19 @@ export default function EQPage() { const bBands = cloneBands(bandsByMode.B); const filters = bBands.map(bandToPeqFilter); const payload: PeqChangePayload = { - peqChange: { - name: peq.name, + peqChange: buildPeqPresetBody( + { + name: peq.name, + brand: peq.brand, + model: peq.model, + target: peq.target, + form: peq.form, + autoPre: peq.autoPre, + preamp: peq.preamp, + canDel: peq.canDel, + }, filters, - autoPre: peq.autoPre ?? 0, - preamp: peq.preamp ?? 0, - canDel: peq.canDel ?? 1, - }, + ), }; skipPeqAutoSyncRef.current = true; @@ -1256,9 +1135,6 @@ export default function EQPage() { try { await upgradePeqChange(payload); - if (peqItems.length > 0 && headphoneIdx < peqItems.length) { - renderCharts(bBands, currentRawCurve, false); - } toast.success(eqUi.toastApplyBSuccess); } catch { skipPeqAutoSyncRef.current = false; @@ -1266,7 +1142,6 @@ export default function EQPage() { } }, [ bandsByMode.B, - currentRawCurve, eqUi.toastApplyBFail, eqUi.toastApplyBSuccess, headphoneIdx, @@ -1401,7 +1276,7 @@ export default function EQPage() { }); return next; }); - schedulePeqSync(updatedPeq, bands, abMode, 0); + schedulePeqSync(updatedPeq, bands, abMode, 0, "byMode", headphoneIdx); }; return ( @@ -1444,8 +1319,8 @@ export default function EQPage() { return updateSetting(bypass ? { peqEnable: 1, dsp_enable: 1 } : { peqEnable: 1 }); }} > - {/* ── Action cards: 批量编辑 / 添加耳机型号 ── */} -
+ {/* ── Action cards: 批量编辑 / 添加耳机型号 / 分享EQ ── */} +
{peqCardLabels.headset} +
{/* ── Headphone model selector ── */} @@ -1744,589 +1631,199 @@ export default function EQPage() { - {bandParamDialog && bandParamDialogMeta && ( -
-
e.stopPropagation()} - > -
-

{bandParamDialogMeta.title}

- -
-

{bandParamDialogMeta.hint}

- setBandParamInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - applyBandParamDialog(); + + + void handleSaveBAsPreset()} + onClose={() => setIsSaveBDialogOpen(false)} + eqUi={eqUi} + /> + + setIsAddPresetDialogOpen(false)} + eqUi={eqUi} + /> + + setIsBatchEditDialogOpen(false)} + eqUi={eqUi} + /> + + setIsBrandDrawerOpen(false)} + eqUi={eqUi} + onConfirm={async (brand, name, target, form) => { + const decoded = await fetchLuxsinAudioCurve(brand, name, target); + let parsed: unknown = decoded; + try { + parsed = JSON.parse(decoded); + } catch { + // Keep raw decoded text when payload isn't JSON. + } + + const parsedObj = parsed && typeof parsed === "object" ? (parsed as Record) : null; + const parametricEqRaw = parsedObj?.parametric_eq; + if (!parametricEqRaw || typeof parametricEqRaw !== "object") { + throw new Error("parametric_eq missing"); + } + + const parametricEq = parametricEqRaw as { + filters?: Array<{ type: string | number; fc: number; gain: number; q: number }>; + preamp?: number; + }; + + const filters = (Array.isArray(parametricEq.filters) ? parametricEq.filters : []) + .slice(0, 10) + .map((item) => ({ + type: getFilterType(item.type), + fc: Number(Number(item.fc).toFixed(2)), + gain: Number(Number(item.gain).toFixed(2)), + q: Number(Number(item.q).toFixed(2)), + })); + + const createdName = `${brand} ${name}`; + const postPeq: PeqChangePayload = { + peqChange: buildPeqPresetBody( + { + name: createdName, + brand, + model: name, + target, + form, + preamp: Number(Number(parametricEq.preamp ?? 0).toFixed(2)), + autoPre: 0, + canDel: 1, + }, + filters, + ), + }; + + const submitCatalogPreset = async () => { + const existingIdx = headphoneModels.indexOf(createdName); + const isOverwrite = existingIdx >= 0; + if (!isOverwrite && !guardPeqPresetCapacity()) return; + skipPeqAutoSyncRef.current = true; + try { + await upgradePeqChange(postPeq); + if (api && !isDemoMode) { + const latest = await fetchEqSyncPeq(api, "addCatalogPreset"); + applyPeqStateToUI(latest as { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number }); + const targetIdx = latest.peq?.findIndex((item) => item.name === createdName) ?? -1; + if (targetIdx >= 0) { + setHeadphoneIdx(targetIdx); + updateSetting({ peqSelect: targetIdx }); } - }} - inputMode={bandParamDialogMeta.inputMode} - placeholder={bandParamDialogMeta.placeholder} - className="h-11 w-full rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[15px] text-white/90 outline-none placeholder:text-white/30 focus:border-[#00FFF6]/40" - autoFocus - /> -
- - -
-
-
- )} - - {isSaveBDialogOpen && ( -
-
e.stopPropagation()} - > -
-

{eqUi.saveBTitle}

- -
- - setSaveBPresetName(e.target.value)} - className="h-11 w-full rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[15px] text-white/90 outline-none placeholder:text-white/30 focus:border-white/15" - autoFocus - /> - -
- - -
-
-
- )} - - {isAddPresetDialogOpen && ( -
-
e.stopPropagation()} - > -
-

{eqUi.addPresetTitle}

- -
- -
- - - -
- -
- - -
-
-
- )} - - {isBatchEditDialogOpen && ( -
-
e.stopPropagation()} - > -
-

{eqUi.batchEditTitle}

- -
-

- {eqUi.batchEditHint} -

-