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:
+498
-4
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user