diff --git a/client/src/lib/luxsinApi.ts b/client/src/lib/luxsinApi.ts index 5704052..c24ddc3 100644 --- a/client/src/lib/luxsinApi.ts +++ b/client/src/lib/luxsinApi.ts @@ -143,6 +143,10 @@ export interface PeqChangePayload { autoPre?: number; preamp?: number; canDel?: number; + brand?: string; + model?: string; + target?: string; + form?: string; }; } @@ -330,3 +334,153 @@ export const MOCK_PEQ_STATE: PeqState = { { fc: 12000, gain: 1.5, q: 0.7, type: 6 }, ], }; + +// ============================================================ +// Luxsin cloud audio catalog (brands / models) +// Dev: proxied via Vite (`/luxsin-audio-api` → `/audio`) to avoid Origin-based 403. +// ============================================================ + +export interface LuxsinAudioBrand { + id: number; + name: string; +} + +export interface LuxsinAudioModel { + id: number; + name: string; + form?: string; +} + +export interface LuxsinAudioModelListItem { + brandName: string; + modelName: string; + form?: string; + source?: string; +} + +export function buildLuxsinAudioUrl(resourcePath: string): string { + const path = resourcePath.replace(/^\//, ""); + if (import.meta.env.DEV) { + return `/luxsin-audio-api/${path}`; + } + return `https://api.luxsin.com.cn/audio/${path}`; +} + +/** Fetches `getBrand`; body is custom Base64 text, decodes to JSON array of `{ id, name }`. */ +export async function fetchLuxsinAudioBrands(): Promise { + const res = await fetch(buildLuxsinAudioUrl("getBrand")); + if (!res.ok) { + throw new Error(`getBrand HTTP ${res.status}`); + } + const body = (await res.text()).trim(); + const json = decodeCustomBase64(body); + const data = JSON.parse(json) as unknown; + if (!Array.isArray(data)) { + throw new Error("getBrand: response is not an array"); + } + return data + .map((row) => { + if (!row || typeof row !== "object") return null; + const r = row as { id?: unknown; name?: unknown }; + const name = typeof r.name === "string" ? r.name : ""; + if (!name) return null; + const id = typeof r.id === "number" ? r.id : Number(r.id); + return { id: Number.isFinite(id) ? id : 0, name }; + }) + .filter((b): b is LuxsinAudioBrand => b !== null); +} + +/** Fetches `getModel?brandName=...`; body is custom Base64 text, decodes to JSON array of model rows. */ +export async function fetchLuxsinAudioModels(brandName: string): Promise { + const brand = brandName.trim(); + if (!brand) return []; + + const res = await fetch(buildLuxsinAudioUrl(`getModel?brandName=${encodeURIComponent(brand)}`)); + if (!res.ok) { + throw new Error(`getModel HTTP ${res.status}`); + } + const body = (await res.text()).trim(); + const json = decodeCustomBase64(body); + const data = JSON.parse(json) as unknown; + if (!Array.isArray(data)) { + throw new Error("getModel: response is not an array"); + } + return data + .map((row) => { + if (!row || typeof row !== "object") return null; + const r = row as { id?: unknown; name?: unknown; form?: unknown }; + const name = typeof r.name === "string" ? r.name : ""; + if (!name) return null; + const id = typeof r.id === "number" ? r.id : Number(r.id); + const form = typeof r.form === "string" ? r.form : undefined; + return { + id: Number.isFinite(id) ? id : 0, + name, + ...(form ? { form } : {}), + }; + }) + .filter((m): m is LuxsinAudioModel => m !== null); +} + +/** Fetches `modelList?key=...&count=...`; body is custom Base64 text, decodes to brand+model rows. */ +export async function fetchLuxsinAudioModelList( + key: string, + count = 1000, +): Promise { + const q = key.trim(); + if (!q) return []; + const safeCount = Number.isFinite(count) ? Math.max(1, Math.floor(count)) : 1000; + const res = await fetch( + buildLuxsinAudioUrl(`modelList?key=${encodeURIComponent(q)}&count=${safeCount}`), + ); + if (!res.ok) { + throw new Error(`modelList HTTP ${res.status}`); + } + const body = (await res.text()).trim(); + const json = decodeCustomBase64(body); + const data = JSON.parse(json) as unknown; + if (!Array.isArray(data)) { + throw new Error("modelList: response is not an array"); + } + return data + .map((row): LuxsinAudioModelListItem | null => { + if (!row || typeof row !== "object") return null; + const r = row as { + brand_name?: unknown; + name?: unknown; + form?: unknown; + source?: unknown; + }; + const brandName = typeof r.brand_name === "string" ? r.brand_name : ""; + const modelName = typeof r.name === "string" ? r.name : ""; + if (!brandName || !modelName) return null; + const form = typeof r.form === "string" ? r.form : undefined; + const source = typeof r.source === "string" ? r.source : undefined; + return { + brandName, + modelName, + ...(form ? { form } : {}), + ...(source ? { source } : {}), + }; + }) + .filter((item): item is LuxsinAudioModelListItem => item !== null); +} + +/** Fetches `getCurve` and returns decoded text payload. */ +export async function fetchLuxsinAudioCurve( + brand: string, + name: string, + target: string, +): Promise { + const brandName = encodeURIComponent(brand.trim()); + const modelName = encodeURIComponent(name.trim()); + const targetName = encodeURIComponent(target.trim()); + const res = await fetch( + buildLuxsinAudioUrl(`getCurve?brand=${brandName}&name=${modelName}&target=${targetName}`), + ); + if (!res.ok) { + throw new Error(`getCurve HTTP ${res.status}`); + } + const body = (await res.text()).trim(); + return decodeCustomBase64(body); +} diff --git a/client/src/pages/EQPage.tsx b/client/src/pages/EQPage.tsx index e7ed90a..b9a5f67 100644 --- a/client/src/pages/EQPage.tsx +++ b/client/src/pages/EQPage.tsx @@ -11,13 +11,24 @@ 7. Total gain card: 总增益 value + AUTO toggle + slider ============================================================ */ import { useDevice } from "@/contexts/DeviceContext"; -import { ChevronLeft, ChevronDown, Minus, Plus, Edit3, Headphones, X } from "lucide-react"; +import { ChevronLeft, ChevronDown, Minus, Plus, Edit3, Headphones, Search, X } from "lucide-react"; import { useLocation } from "wouter"; -import { useState, useMemo, useEffect, useRef } from "react"; +import { useState, useMemo, useEffect, useRef, useCallback } from "react"; import { cn } from "@/lib/utils"; import BottomNav from "@/components/BottomNav"; import { toast } from "sonner"; -import { decodeCustomBase64, type PeqFilter, type PeqChangePayload } from "@/lib/luxsinApi"; +import { + decodeCustomBase64, + fetchLuxsinAudioBrands, + fetchLuxsinAudioCurve, + fetchLuxsinAudioModelList, + fetchLuxsinAudioModels, + type LuxsinAudioBrand, + type LuxsinAudioModelListItem, + type LuxsinAudioModel, + type PeqFilter, + type PeqChangePayload, +} from "@/lib/luxsinApi"; import * as echarts from "echarts"; import { getSectionsMatrix, @@ -411,6 +422,29 @@ const FLAT_PRESET_FILTERS: PeqFilter[] = [ { type: 4, fc: 18000, gain: 0, q: 0.1 }, ]; +type CatalogTarget = { + name: string; + bassBoost: { fc: number; q: number; gain: number }; + ear: "in" | "over" | "all"; +}; + +const CATALOG_TARGETS: CatalogTarget[] = [ + { name: "Harman over-ear 2018", bassBoost: { fc: 105, q: 0.7, gain: 6 }, ear: "over" }, + { name: "HMS II.3 Harman over-ear 2018", bassBoost: { fc: 105, q: 0.7, gain: 6 }, ear: "over" }, + { name: "crinacle EARS + 711 Harman over-ear 2018", bassBoost: { fc: 105, q: 0.7, gain: 6 }, ear: "over" }, + { name: "Harman in-ear 2019", bassBoost: { fc: 105, q: 0.7, gain: 9.5 }, ear: "in" }, + { name: "AutoEq in-ear", bassBoost: { fc: 105, q: 0.7, gain: 8 }, ear: "in" }, + { name: "HMS II.3 AutoEq in-ear", bassBoost: { fc: 105, q: 0.7, gain: 8 }, ear: "in" }, + { name: "HMS II.3 Harman in-ear 2019", bassBoost: { fc: 105, q: 0.7, gain: 9.5 }, ear: "in" }, + { name: "Diffuse Field 5128 (-1 dB/oct)", bassBoost: { fc: 105, q: 0.7, gain: 0 }, ear: "over" }, + { name: "LMG 5128 0.6 without bass", bassBoost: { fc: 105, q: 0.7, gain: 6 }, ear: "over" }, + { name: "JM-1 with Harman filters", bassBoost: { fc: 105, q: 0.7, gain: 6.5 }, ear: "all" }, + { name: "oratory1990 in-ear", bassBoost: { fc: 105, q: 0.7, gain: 9.5 }, ear: "in" }, + { name: "oratory1990 over-ear", bassBoost: { fc: 105, q: 0.7, gain: 6 }, ear: "over" }, + { name: "Harman over-ear 2013", bassBoost: { fc: 105, q: 0.7, gain: 6 }, ear: "over" }, + { name: "Flat", bassBoost: { fc: 105, q: 0.7, gain: 0 }, ear: "all" }, +]; + /** 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 { @@ -449,6 +483,25 @@ export default function EQPage() { const [addPresetMode, setAddPresetMode] = useState<"copy" | "flat">("copy"); const [copyPresetName, setCopyPresetName] = useState(""); const [flatPresetName, setFlatPresetName] = 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 allowPeqRemoteSyncRef = useRef(false); const syncingHeadphoneRef = useRef(false); const peqSyncTimerRef = useRef(null); @@ -458,6 +511,120 @@ export default function EQPage() { const preampValue = Number(currentPeq?.preamp ?? 0); const autoPreOn = (currentPeq?.autoPre ?? 0) === 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("获取品牌列表失败"); + } finally { + setCatalogBrandsLoading(false); + } + }, []); + + 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("获取型号列表失败"); + } finally { + setCatalogModelsLoading(false); + } + }, []); + + const openAddHeadsetCatalog = () => { + setIsBrandDrawerOpen(true); + setBrandDrawerTab("brands"); + setBrandSearchQuery(""); + setCatalogSearchResults([]); + setCatalogSearchError(null); + setCatalogSearchLoading(false); + setSelectedCatalogBrand(""); + setSelectedCatalogModelFromSearch(""); + setSelectedCatalogModelName(""); + setSelectedCatalogModelForm(undefined); + setSelectedCatalogTarget(""); + setCatalogModels([]); + setCatalogModelsError(null); + void loadCatalogBrands(); + }; + + 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 normalizeFiltersFromPeq = (peq: { filters?: any[] | string } | undefined) => { if (!peq) return [] as typeof bands; let filters: any[] = []; @@ -936,7 +1103,7 @@ export default function EQPage() { + ); + })} + + +
+ {brandDrawerTab === "brands" && ( + <> +
+
+ + setBrandSearchQuery(e.target.value)} + placeholder="搜索品牌或型号" + className="min-w-0 flex-1 bg-transparent text-[15px] text-black/80 outline-none placeholder:text-black/35" + /> +
+
+
+ {brandSearchQuery.trim() && catalogSearchLoading && ( +
搜索中…
+ )} + {!!brandSearchQuery.trim() && !catalogSearchLoading && catalogSearchError && ( +
{catalogSearchError}
+ )} + {!!brandSearchQuery.trim() && + !catalogSearchLoading && + !catalogSearchError && + catalogSearchResults.length === 0 && ( +
未找到相关品牌或型号
+ )} + {!!brandSearchQuery.trim() && + !catalogSearchLoading && + !catalogSearchError && + catalogSearchResults.map((item, idx) => ( + + ))} + {!brandSearchQuery.trim() && catalogBrandsLoading && ( +
加载中…
+ )} + {!brandSearchQuery.trim() && !catalogBrandsLoading && catalogBrandsError && ( +
{catalogBrandsError}
+ )} + {!brandSearchQuery.trim() && !catalogBrandsLoading && !catalogBrandsError && filteredCatalogBrands.length === 0 && ( +
无匹配品牌
+ )} + {!brandSearchQuery.trim() && + !catalogBrandsLoading && + !catalogBrandsError && + filteredCatalogBrands.map((b) => ( + + ))} +
+ + )} + {brandDrawerTab === "models" && ( +
+
+ 品牌:{selectedCatalogBrand || "—"} + {selectedCatalogModelFromSearch && ( + 已选搜索型号:{selectedCatalogModelFromSearch} + )} +
+ {!selectedCatalogBrand && ( +
请先在 Brands 中选择品牌
+ )} + {!!selectedCatalogBrand && catalogModelsLoading && ( +
加载中…
+ )} + {!!selectedCatalogBrand && !catalogModelsLoading && catalogModelsError && ( +
{catalogModelsError}
+ )} + {!!selectedCatalogBrand && !catalogModelsLoading && !catalogModelsError && catalogModels.length === 0 && ( +
该品牌暂无型号
+ )} + {!!selectedCatalogBrand && + !catalogModelsLoading && + !catalogModelsError && + catalogModels.map((m) => ( + + ))} +
+ )} + {brandDrawerTab === "target" && ( +
+
+
品牌:{selectedCatalogBrand || "—"}
+
型号:{selectedCatalogModelName || "—"}
+
Form:{selectedCatalogModelForm || "全部"}
+
+
+ +
+ {!selectedCatalogModelName && ( +
请先选择型号
+ )} + {!!selectedCatalogModelName && availableCatalogTargets.length === 0 && ( +
暂无可用 Target
+ )} + {!!selectedCatalogModelName && + availableCatalogTargets.map((target) => { + const active = selectedCatalogTarget === target.name; + return ( + + ); + })} +
+ )} +
+ + + )} + ); diff --git a/vite.config.ts b/vite.config.ts index 51a0c17..a4e58ca 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -231,6 +231,14 @@ export default defineConfig({ port: 3000, strictPort: false, // Will find next available port if 3000 is busy host: true, + // Browser requests with Origin are rejected (403) by the cloud API; proxy in dev avoids CORS/WAF. + proxy: { + "/luxsin-audio-api": { + target: "https://api.luxsin.com.cn", + changeOrigin: true, + rewrite: (p) => p.replace(/^\/luxsin-audio-api/, "/audio"), + }, + }, allowedHosts: [ ".manuspre.computer", ".manus.computer",