Implement Luxsin audio API integration: Add proxy configuration in Vite for CORS handling, enhance luxsinApi with brand and model fetching functions, and update EQPage to support audio catalog browsing and searching. Introduce new state management for catalog brands and models.

This commit is contained in:
yangy
2026-04-20 17:51:23 +08:00
parent 0e7670c215
commit 22fdf39670
3 changed files with 660 additions and 4 deletions
+498 -4
View File
@@ -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<BrandDrawerTab>("brands");
const [brandSearchQuery, setBrandSearchQuery] = useState("");
const [catalogBrands, setCatalogBrands] = useState<LuxsinAudioBrand[]>([]);
const [catalogBrandsLoading, setCatalogBrandsLoading] = useState(false);
const [catalogBrandsError, setCatalogBrandsError] = useState<string | null>(null);
const [catalogSearchResults, setCatalogSearchResults] = useState<LuxsinAudioModelListItem[]>([]);
const [catalogSearchLoading, setCatalogSearchLoading] = useState(false);
const [catalogSearchError, setCatalogSearchError] = useState<string | null>(null);
const [selectedCatalogBrand, setSelectedCatalogBrand] = useState<string>("");
const [catalogModels, setCatalogModels] = useState<LuxsinAudioModel[]>([]);
const [catalogModelsLoading, setCatalogModelsLoading] = useState(false);
const [catalogModelsError, setCatalogModelsError] = useState<string | null>(null);
const [selectedCatalogModelFromSearch, setSelectedCatalogModelFromSearch] = useState<string>("");
const [selectedCatalogModelName, setSelectedCatalogModelName] = useState("");
const [selectedCatalogModelForm, setSelectedCatalogModelForm] = useState<string | undefined>(undefined);
const [selectedCatalogTarget, setSelectedCatalogTarget] = useState<string>("");
const [isConfirmingTarget, setIsConfirmingTarget] = useState(false);
const allowPeqRemoteSyncRef = useRef(false);
const syncingHeadphoneRef = useRef(false);
const peqSyncTimerRef = useRef<number | null>(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() {
</button>
<button
className="ios-list-group flex flex-col items-center justify-center py-3 gap-2 active:opacity-70 transition-opacity"
onClick={() => toast.info("添加耳机型号功能即将推出")}>
onClick={openAddHeadsetCatalog}>
<div className="w-10 h-10 rounded-[12px] flex items-center justify-center"
style={{ background: "rgba(0,255,246,0.12)", border: "1px solid rgba(0,255,246,0.2)" }}>
<Headphones size={20} style={{ color: "#00FFF6" }} />
@@ -1252,6 +1419,333 @@ export default function EQPage() {
</div>
)}
{isBrandDrawerOpen && (
<div className="fixed inset-0 z-[110] flex flex-col justify-end">
<button
type="button"
className="absolute inset-0 bg-black/55"
aria-label="关闭"
onClick={() => setIsBrandDrawerOpen(false)}
/>
<div
className="relative z-10 flex max-h-[88vh] flex-col rounded-t-[18px] bg-white shadow-[0_-8px_32px_rgba(0,0,0,0.35)]"
onClick={(e) => e.stopPropagation()}
>
<div className="flex shrink-0 items-center justify-center pt-2 pb-1">
<div className="h-1 w-10 rounded-full bg-black/15" />
</div>
<div className="flex shrink-0 items-stretch rounded-t-[12px] bg-[#2c2c2e] px-1 pt-1">
{(["brands", "models", "target"] as const).map((tab) => {
const active = brandDrawerTab === tab;
const label = tab === "brands" ? "Brands" : tab === "models" ? "Models" : "Target";
return (
<button
key={tab}
type="button"
className={`flex-1 py-2.5 text-[14px] font-medium transition-colors rounded-t-[10px] ${
active ? "bg-[#1c1c1e] text-white" : "text-white/55 hover:text-white/80"
}`}
onClick={() => setBrandDrawerTab(tab)}
>
{label}
</button>
);
})}
</div>
<div className="flex min-h-0 flex-1 flex-col bg-white">
{brandDrawerTab === "brands" && (
<>
<div className="shrink-0 border-b border-black/8 px-3 py-2.5">
<div className="flex items-center gap-2 rounded-[10px] border border-black/12 bg-[#f5f5f7] px-3 py-2">
<Search size={18} className="shrink-0 text-black/35" />
<input
type="search"
value={brandSearchQuery}
onChange={(e) => setBrandSearchQuery(e.target.value)}
placeholder="搜索品牌或型号"
className="min-w-0 flex-1 bg-transparent text-[15px] text-black/80 outline-none placeholder:text-black/35"
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
{brandSearchQuery.trim() && catalogSearchLoading && (
<div className="py-10 text-center text-[14px] text-black/45"></div>
)}
{!!brandSearchQuery.trim() && !catalogSearchLoading && catalogSearchError && (
<div className="px-4 py-8 text-center text-[14px] text-red-600/90">{catalogSearchError}</div>
)}
{!!brandSearchQuery.trim() &&
!catalogSearchLoading &&
!catalogSearchError &&
catalogSearchResults.length === 0 && (
<div className="py-10 text-center text-[14px] text-black/45"></div>
)}
{!!brandSearchQuery.trim() &&
!catalogSearchLoading &&
!catalogSearchError &&
catalogSearchResults.map((item, idx) => (
<button
key={`${item.brandName}-${item.modelName}-${idx}`}
type="button"
className="w-full border-b border-black/[0.06] px-4 py-3.5 text-left active:bg-black/[0.04] transition-colors"
onClick={() => {
setSelectedCatalogBrand(item.brandName);
setSelectedCatalogModelFromSearch(item.modelName);
setSelectedCatalogModelName(item.modelName);
setSelectedCatalogModelForm(item.form);
setSelectedCatalogTarget("");
setCatalogModels([{ id: 0, name: item.modelName, ...(item.form ? { form: item.form } : {}) }]);
setCatalogModelsLoading(false);
setCatalogModelsError(null);
setBrandDrawerTab("target");
}}
>
<div className="text-[15px] text-black/85 font-medium">{item.modelName}</div>
<div className="mt-0.5 text-[12px] text-black/50">{item.brandName}</div>
</button>
))}
{!brandSearchQuery.trim() && catalogBrandsLoading && (
<div className="py-10 text-center text-[14px] text-black/45"></div>
)}
{!brandSearchQuery.trim() && !catalogBrandsLoading && catalogBrandsError && (
<div className="px-4 py-8 text-center text-[14px] text-red-600/90">{catalogBrandsError}</div>
)}
{!brandSearchQuery.trim() && !catalogBrandsLoading && !catalogBrandsError && filteredCatalogBrands.length === 0 && (
<div className="py-10 text-center text-[14px] text-black/45"></div>
)}
{!brandSearchQuery.trim() &&
!catalogBrandsLoading &&
!catalogBrandsError &&
filteredCatalogBrands.map((b) => (
<button
key={b.id}
type="button"
className="w-full border-b border-black/[0.06] px-4 py-3.5 text-left text-[16px] text-black/85 active:bg-black/[0.04] transition-colors"
onClick={() => {
setSelectedCatalogBrand(b.name);
setSelectedCatalogModelFromSearch("");
setSelectedCatalogModelName("");
setSelectedCatalogModelForm(undefined);
setSelectedCatalogTarget("");
setBrandDrawerTab("models");
void loadCatalogModels(b.name);
}}
>
{b.name}
</button>
))}
</div>
</>
)}
{brandDrawerTab === "models" && (
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="sticky top-0 z-10 border-b border-black/8 bg-white px-4 py-2.5 text-[13px] text-black/55">
<span className="font-semibold text-black/80">{selectedCatalogBrand || "—"}</span>
{selectedCatalogModelFromSearch && (
<span className="ml-2 text-black/45">{selectedCatalogModelFromSearch}</span>
)}
</div>
{!selectedCatalogBrand && (
<div className="py-10 text-center text-[14px] text-black/45"> Brands </div>
)}
{!!selectedCatalogBrand && catalogModelsLoading && (
<div className="py-10 text-center text-[14px] text-black/45"></div>
)}
{!!selectedCatalogBrand && !catalogModelsLoading && catalogModelsError && (
<div className="px-4 py-8 text-center text-[14px] text-red-600/90">{catalogModelsError}</div>
)}
{!!selectedCatalogBrand && !catalogModelsLoading && !catalogModelsError && catalogModels.length === 0 && (
<div className="py-10 text-center text-[14px] text-black/45"></div>
)}
{!!selectedCatalogBrand &&
!catalogModelsLoading &&
!catalogModelsError &&
catalogModels.map((m) => (
<button
key={`${m.id}-${m.name}`}
type="button"
className="w-full border-b border-black/[0.06] px-4 py-3.5 text-left text-[16px] text-black/85 active:bg-black/[0.04] transition-colors"
onClick={() => {
setSelectedCatalogModelName(m.name);
setSelectedCatalogModelForm(m.form);
setSelectedCatalogTarget("");
setBrandDrawerTab("target");
}}
>
<div>{m.name}</div>
{m.form && <div className="mt-0.5 text-[12px] text-black/50">{m.form}</div>}
</button>
))}
</div>
)}
{brandDrawerTab === "target" && (
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="sticky top-0 z-10 border-b border-black/8 bg-white px-4 py-2.5 text-[13px] text-black/55 space-y-0.5">
<div><span className="font-semibold text-black/80">{selectedCatalogBrand || "—"}</span></div>
<div><span className="font-semibold text-black/80">{selectedCatalogModelName || "—"}</span></div>
<div>Form<span className="font-semibold text-black/80">{selectedCatalogModelForm || "全部"}</span></div>
</div>
<div className="px-5 pt-4 pb-3">
<button
type="button"
disabled={!selectedCatalogTarget || isConfirmingTarget}
className="w-full h-10 rounded-full text-[24px] leading-none transition-all disabled:cursor-not-allowed flex items-center justify-center gap-3"
style={{
background: selectedCatalogTarget && !isConfirmingTarget
? "linear-gradient(180deg, #6a6a6d 0%, #565659 100%)"
: "linear-gradient(180deg, #8b8b8f 0%, #78787c 100%)",
color: selectedCatalogTarget && !isConfirmingTarget ? "#00FFF6" : "rgba(255,255,255,0.55)",
boxShadow: selectedCatalogTarget && !isConfirmingTarget
? "inset 0 1px 0 rgba(255,255,255,0.15)"
: "inset 0 1px 0 rgba(255,255,255,0.08)",
opacity: selectedCatalogTarget && !isConfirmingTarget ? 1 : 0.9,
}}
onClick={() => {
const brand = selectedCatalogBrand;
const name = selectedCatalogModelName;
const target = selectedCatalogTarget;
const form = selectedCatalogModelForm;
void (async () => {
setIsConfirmingTarget(true);
try {
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<string, unknown>) : null;
const parametricEqRaw = parsedObj?.parametric_eq;
if (!parametricEqRaw || typeof parametricEqRaw !== "object") {
console.warn("getCurve decoded: missing parametric_eq", { brand, name, target, data: parsed });
return;
}
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 postPeq: PeqChangePayload = {
peqChange: {
name: `${brand} ${name}`,
brand,
model: name,
target,
...(form ? { form } : {}),
filters,
preamp: Number(Number(parametricEq.preamp ?? 0).toFixed(2)),
autoPre: 0,
canDel: 1,
},
};
await upgradePeqChange(postPeq);
const createdName = postPeq.peqChange.name;
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 === createdName) ?? -1;
if (createdIndex >= 0) {
setHeadphoneIdx(createdIndex);
updateSetting({ peqSelect: createdIndex });
}
} else {
let nextIndex = 0;
setPeqItems((prev) => [
...prev,
{
name: createdName,
brand,
model: name,
target,
...(form ? { form } : {}),
filters: postPeq.peqChange.filters,
preamp: postPeq.peqChange.preamp ?? 0,
autoPre: postPeq.peqChange.autoPre ?? 0,
canDel: postPeq.peqChange.canDel ?? 1,
},
]);
setHeadphoneModels((prev) => {
nextIndex = prev.length;
return [...prev, createdName];
});
setHeadphoneIdx(nextIndex);
}
setIsBrandDrawerOpen(false);
console.log("getCurve decoded + peqChange posted:", {
brand,
name,
target,
data: parsed,
postPeq,
});
toast.success("新耳机 EQ 已上报");
} catch (error) {
console.error("getCurve failed:", error);
toast.error("获取曲线失败");
} finally {
setIsConfirmingTarget(false);
}
})();
}}
>
{isConfirmingTarget && (
<span
className="inline-block w-4 h-4 rounded-full border-2 border-white/40 border-t-[#00FFF6] animate-spin"
aria-hidden="true"
/>
)}
{isConfirmingTarget ? "loading..." : "confirm"}
</button>
</div>
{!selectedCatalogModelName && (
<div className="py-10 text-center text-[14px] text-black/45"></div>
)}
{!!selectedCatalogModelName && availableCatalogTargets.length === 0 && (
<div className="py-10 text-center text-[14px] text-black/45"> Target</div>
)}
{!!selectedCatalogModelName &&
availableCatalogTargets.map((target) => {
const active = selectedCatalogTarget === target.name;
return (
<button
key={target.name}
type="button"
className="w-full border-b border-black/[0.06] px-4 py-3 text-left active:bg-black/[0.04] transition-colors"
style={active ? { background: "rgba(0,255,246,0.14)" } : undefined}
onClick={() => {
setSelectedCatalogTarget(target.name);
toast.success(`已选 Target${target.name}`);
}}
>
<div className="text-[15px] text-black/85 font-medium">{target.name}</div>
<div className="mt-1 text-[12px] text-black/55">
Bass boost: fc {target.bassBoost.fc}, q {target.bassBoost.q}, gain {target.bassBoost.gain}dB
</div>
</button>
);
})}
</div>
)}
</div>
</div>
</div>
)}
<BottomNav />
</div>
);