2259 lines
90 KiB
TypeScript
2259 lines
90 KiB
TypeScript
/* ============================================================
|
||
HP-EQ PAGE — Parametric EQ
|
||
Design: Reference luxsin_x8_hpeq_ui.jpeg
|
||
Layout (top to bottom):
|
||
1. Page header: < HP-EQ [toggle]
|
||
2. Two action cards: 批量编辑 / 添加耳机型号
|
||
3. Headphone model row: [Name ▼] [−] [+]
|
||
4. Freq response chart: legend + A/B + 复制到B + DIFF + SVG curve
|
||
5. Band grid: below lg breakpoint 2×5 pills; lg+ single row ×10
|
||
6. Band detail card: 滤波器 / FREQ / GAIN / Q值
|
||
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 { useLocation } from "wouter";
|
||
import { useState, useMemo, useEffect, useRef, useCallback } from "react";
|
||
import { cn } from "@/lib/utils";
|
||
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,
|
||
} from "@/lib/luxsinApi";
|
||
import * as echarts from "echarts";
|
||
import { getSectionsMatrix, visualizeResponse, getChartOps, 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 {
|
||
BAND_FREQ_MAX,
|
||
BAND_FREQ_MIN,
|
||
BAND_GAIN_MAX,
|
||
BAND_GAIN_MIN,
|
||
BAND_PARAM_VALUE_BOX_STYLE,
|
||
BAND_Q_MAX,
|
||
BAND_Q_MIN,
|
||
CATALOG_TARGETS,
|
||
DEFAULT_BANDS,
|
||
FILTER_TYPES,
|
||
FLAT_PRESET_FILTERS,
|
||
} from "./eq/eqConstants";
|
||
import {
|
||
eqInterp,
|
||
formatBandFreqDisplay,
|
||
formatBandGainDisplay,
|
||
formatBandParamForInput,
|
||
formatBandQDisplay,
|
||
freqLabel,
|
||
normalizeFilterType,
|
||
normalizeBandFreqHz,
|
||
parseBandParamInput,
|
||
} from "./eq/eqFormatters";
|
||
import { bandToPeqFilter, buildPeqCatalogSyncKey, cloneBands, getUniquePresetName } from "./eq/peqMappers";
|
||
import type { BandParamKind, PeqCatalogItem, PeqEqUi, PeqPresetLocalCache } from "./eq/types";
|
||
|
||
/* ── Main Component ── */
|
||
export default function EQPage() {
|
||
const [, setLocation] = useLocation();
|
||
const {
|
||
deviceState,
|
||
peqState,
|
||
updateSetting,
|
||
api,
|
||
isDemoMode,
|
||
upgradePeqChange,
|
||
upgradePeqApply,
|
||
syncPeqCatalog,
|
||
} = useDevice();
|
||
const bypassOn = (deviceState?.dsp_enable ?? 0) === 0;
|
||
const peqOn = (deviceState?.peqEnable ?? 0) === 1;
|
||
const eqOn = peqOn && !bypassOn;
|
||
|
||
const eqUi = useMemo((): PeqEqUi => {
|
||
const lang = deviceState?.language;
|
||
const pack = lang === 0 ? localeEn : lang === 1 ? localeZhHK : localeZh;
|
||
const peq = pack.peq as typeof localeZh.peq;
|
||
return (peq.eqUi ?? (localeZh.peq as typeof localeZh.peq).eqUi) as PeqEqUi;
|
||
}, [deviceState?.language]);
|
||
|
||
const peqCardLabels = useMemo(() => {
|
||
const lang = deviceState?.language;
|
||
const pack = lang === 0 ? localeEn : lang === 1 ? localeZhHK : localeZh;
|
||
return pack.peq ?? localeZh.peq;
|
||
}, [deviceState?.language]);
|
||
|
||
const [bandsByMode, setBandsByMode] = useState<Record<"A" | "B", typeof DEFAULT_BANDS>>({
|
||
A: cloneBands(DEFAULT_BANDS),
|
||
B: cloneBands(DEFAULT_BANDS),
|
||
});
|
||
const [selectedBandByMode, setSelectedBandByMode] = useState<Record<"A" | "B", number>>({
|
||
A: 0,
|
||
B: 0,
|
||
});
|
||
const [headphoneIdx, setHeadphoneIdx] = useState(deviceState?.peqSelect ?? 0);
|
||
const [headphoneModels, setHeadphoneModels] = useState<string[]>([]);
|
||
const [peqItems, setPeqItems] = useState<
|
||
Array<{
|
||
name: string;
|
||
brand?: string;
|
||
model?: string;
|
||
filters?: any[] | string;
|
||
autoPre?: number;
|
||
preamp?: number;
|
||
canDel?: number;
|
||
}>
|
||
>([]);
|
||
const [abMode, setAbMode] = useState<"A" | "B">("A");
|
||
const [isHeadphoneMenuOpen, setIsHeadphoneMenuOpen] = useState(false);
|
||
const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false);
|
||
const [isAddPresetDialogOpen, setIsAddPresetDialogOpen] = useState(false);
|
||
const [addPresetMode, setAddPresetMode] = useState<"copy" | "flat">("copy");
|
||
const [copyPresetName, setCopyPresetName] = useState("");
|
||
const [flatPresetName, setFlatPresetName] = useState("");
|
||
const [isSaveBDialogOpen, setIsSaveBDialogOpen] = useState(false);
|
||
const [saveBPresetName, setSaveBPresetName] = useState("");
|
||
const [bandParamDialog, setBandParamDialog] = useState<BandParamKind | null>(null);
|
||
const [bandParamInput, setBandParamInput] = useState("");
|
||
/** 滑块拖动时的实时显示(避免按钮文字滞后于 bands 状态) */
|
||
const [bandSliderPreview, setBandSliderPreview] = useState<{
|
||
freq?: number;
|
||
gain?: number;
|
||
q?: number;
|
||
} | null>(null);
|
||
const [isBatchEditDialogOpen, setIsBatchEditDialogOpen] = useState(false);
|
||
const [batchEditText, setBatchEditText] = 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 [currentRawCurve, setCurrentRawCurve] = useState<number[] | null>(null);
|
||
const allowPeqRemoteSyncRef = useRef(false);
|
||
const lastPeqCatalogSyncKeyRef = useRef("");
|
||
const syncingHeadphoneRef = useRef(false);
|
||
const peqSyncTimerRef = useRef<number | null>(null);
|
||
const skipPeqAutoSyncRef = useRef(false);
|
||
/** A/B 切换已单独下发,避免 bands 变更触发的 effect 用错 peqChange/peqApply */
|
||
const skipBandsSyncFromAbToggleRef = useRef(false);
|
||
/** 本地编辑过的预设(filters / preamp / autoPre);syncPeq 拉取不会覆盖 */
|
||
const peqPresetCacheRef = useRef<Record<string, PeqPresetLocalCache>>({});
|
||
const headphoneMenuRef = useRef<HTMLDivElement | null>(null);
|
||
const filterMenuRef = useRef<HTMLDivElement | null>(null);
|
||
const bands = bandsByMode[abMode];
|
||
const selectedBand = selectedBandByMode[abMode];
|
||
const setBandsForBothModes = useCallback((nextBands: typeof DEFAULT_BANDS) => {
|
||
const cloned = cloneBands(nextBands);
|
||
setBandsByMode({ A: cloneBands(cloned), B: cloneBands(cloned) });
|
||
}, []);
|
||
const setBands = useCallback(
|
||
(updater: typeof DEFAULT_BANDS | ((prev: typeof DEFAULT_BANDS) => typeof DEFAULT_BANDS)) => {
|
||
setBandsByMode((prev) => ({
|
||
...prev,
|
||
[abMode]:
|
||
typeof updater === "function"
|
||
? (updater as (prev: typeof DEFAULT_BANDS) => typeof DEFAULT_BANDS)(prev[abMode])
|
||
: cloneBands(updater),
|
||
}));
|
||
},
|
||
[abMode],
|
||
);
|
||
const setSelectedBand = useCallback(
|
||
(updater: number | ((prev: number) => number)) => {
|
||
setSelectedBandByMode((prev) => ({
|
||
...prev,
|
||
[abMode]: typeof updater === "function" ? (updater as (prev: number) => number)(prev[abMode]) : updater,
|
||
}));
|
||
},
|
||
[abMode],
|
||
);
|
||
const copyModeParams = useCallback((from: "A" | "B", to: "A" | "B") => {
|
||
setBandsByMode((prev) => ({ ...prev, [to]: cloneBands(prev[from]) }));
|
||
setSelectedBandByMode((prev) => ({ ...prev, [to]: prev[from] }));
|
||
}, []);
|
||
|
||
const currentPeq = peqItems[headphoneIdx];
|
||
const currentPeqName = currentPeq?.name ?? "";
|
||
const currentPeqPreamp = currentPeq?.preamp ?? 0;
|
||
const currentPeqAutoPre = currentPeq?.autoPre ?? 0;
|
||
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 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[] = [];
|
||
if (typeof peq.filters === "string") {
|
||
try {
|
||
filters = JSON.parse(peq.filters);
|
||
} catch {
|
||
filters = [];
|
||
}
|
||
} else if (Array.isArray(peq.filters)) {
|
||
filters = peq.filters;
|
||
}
|
||
return filters.map((f: any) => {
|
||
const rawFreq = f.fc ?? f.freq ?? f.frequency ?? 1000;
|
||
const freq = normalizeBandFreqHz(rawFreq) ?? 1000;
|
||
return {
|
||
freq: Math.round(freq),
|
||
gain: Number(f.gain || 0),
|
||
q: Number(f.q || 1),
|
||
type: normalizeFilterType(f.type),
|
||
enabled: f.enabled !== undefined ? f.enabled : true,
|
||
};
|
||
});
|
||
};
|
||
|
||
const toCompactNumber = (value: number) => Number(value.toFixed(3)).toString();
|
||
|
||
const buildBatchEditText = useCallback(() => {
|
||
const preamp = Number(currentPeq?.preamp ?? 0);
|
||
const source = bands.length > 0 ? bands : DEFAULT_BANDS;
|
||
const lines = [`Preamp:${toCompactNumber(preamp)}dB`];
|
||
for (let i = 0; i < 10; i++) {
|
||
const band = source[i] ?? DEFAULT_BANDS[i] ?? DEFAULT_BANDS[0];
|
||
lines.push(
|
||
`Filter ${i + 1}: ${band.enabled ? "ON" : "OFF"} ${normalizeFilterType(band.type)} Fc ${toCompactNumber(band.freq)} Hz Gain ${toCompactNumber(band.gain)} dB Q ${toCompactNumber(band.q)}`
|
||
);
|
||
}
|
||
return lines.join("\n");
|
||
}, [bands, currentPeq?.preamp]);
|
||
|
||
const openBatchEditDialog = () => {
|
||
setBatchEditText(buildBatchEditText());
|
||
setIsBatchEditDialogOpen(true);
|
||
};
|
||
|
||
const parseBatchEditText = useCallback(
|
||
(raw: string) => {
|
||
const t = eqUi;
|
||
const lines = raw
|
||
.split(/\r?\n/)
|
||
.map((line) => line.trim())
|
||
.filter(Boolean);
|
||
if (lines.length < 11) {
|
||
throw new Error(eqInterp(t.errMinLines, {}));
|
||
}
|
||
|
||
const preampMatch = lines[0].match(/^Preamp\s*:\s*([+-]?\d+(?:\.\d+)?)\s*dB$/i);
|
||
if (!preampMatch) {
|
||
throw new Error(eqInterp(t.errPreampFirstLine, {}));
|
||
}
|
||
const preamp = Number(preampMatch[1]);
|
||
if (!Number.isFinite(preamp)) {
|
||
throw new Error(eqInterp(t.errPreampValue, {}));
|
||
}
|
||
|
||
const parsedFilters = new Array<{
|
||
enabled: boolean;
|
||
type: string;
|
||
freq: number;
|
||
gain: number;
|
||
q: number;
|
||
}>(10);
|
||
|
||
for (let i = 1; i <= 10; i++) {
|
||
const line = lines[i];
|
||
const match = line.match(
|
||
/^Filter\s+(\d+)\s*:\s*(ON|OFF)\s+([A-Za-z]+)\s+Fc\s+([+-]?\d+(?:\.\d+)?)\s+Hz\s+Gain\s+([+-]?\d+(?:\.\d+)?)\s+dB\s+Q\s+([+-]?\d+(?:\.\d+)?)$/i
|
||
);
|
||
if (!match) {
|
||
throw new Error(eqInterp(t.errFilterLine, { line: String(i + 1) }));
|
||
}
|
||
|
||
const filterNo = Number(match[1]);
|
||
if (filterNo < 1 || filterNo > 10) {
|
||
throw new Error(eqInterp(t.errFilterNoRange, { no: String(filterNo) }));
|
||
}
|
||
|
||
const type = normalizeFilterType(match[3].toUpperCase());
|
||
if (!FILTER_TYPES.includes(type)) {
|
||
throw new Error(eqInterp(t.errFilterType, { no: String(filterNo), type: match[3] }));
|
||
}
|
||
|
||
const freq = Number(match[4]);
|
||
const gain = Number(match[5]);
|
||
const q = Number(match[6]);
|
||
if (![freq, gain, q].every(Number.isFinite)) {
|
||
throw new Error(eqInterp(t.errFilterNumbers, { no: String(filterNo) }));
|
||
}
|
||
|
||
parsedFilters[filterNo - 1] = {
|
||
enabled: match[2].toUpperCase() === "ON",
|
||
type,
|
||
freq: Number(freq.toFixed(2)),
|
||
gain: Number(gain.toFixed(2)),
|
||
q: Number(q.toFixed(2)),
|
||
};
|
||
}
|
||
|
||
if (parsedFilters.some((item) => !item)) {
|
||
throw new Error(eqInterp(t.errFilterAll, {}));
|
||
}
|
||
|
||
return {
|
||
preamp: Number(preamp.toFixed(2)),
|
||
filters: parsedFilters as typeof bands,
|
||
};
|
||
},
|
||
[eqUi],
|
||
);
|
||
|
||
const handleSaveBatchEdit = () => {
|
||
try {
|
||
const parsed = parseBatchEditText(batchEditText);
|
||
let updatedPeq: { name: string; autoPre?: number; preamp?: number; canDel?: number } | undefined;
|
||
setPeqItems((prev) =>
|
||
prev.map((item, i) => {
|
||
if (i !== headphoneIdx) return item;
|
||
const filters = parsed.filters.map(bandToPeqFilter);
|
||
const merged = {
|
||
...item,
|
||
autoPre: 0,
|
||
preamp: parsed.preamp,
|
||
filters,
|
||
};
|
||
rememberPeqPresetCache(item.name, { filters, preamp: parsed.preamp, autoPre: 0 });
|
||
updatedPeq = merged;
|
||
return merged;
|
||
})
|
||
);
|
||
setBands(parsed.filters);
|
||
schedulePeqSync(updatedPeq, parsed.filters, abMode, 0);
|
||
setSelectedBand(0);
|
||
setIsBatchEditDialogOpen(false);
|
||
toast.success(eqUi.toastBatchApplied);
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : eqUi.toastBatchParseError);
|
||
}
|
||
};
|
||
|
||
const rememberPeqPresetCache = useCallback(
|
||
(presetName: string | undefined, patch: PeqPresetLocalCache) => {
|
||
if (!presetName) return;
|
||
const prev = peqPresetCacheRef.current[presetName] ?? {};
|
||
const next: PeqPresetLocalCache = { ...prev, ...patch };
|
||
if (next.filters?.length === 0) delete next.filters;
|
||
peqPresetCacheRef.current[presetName] = next;
|
||
},
|
||
[],
|
||
);
|
||
|
||
const mergeRemotePeqCatalog = useCallback((items: PeqCatalogItem[]) => {
|
||
return items.map((item) => {
|
||
const cached = peqPresetCacheRef.current[item.name];
|
||
if (!cached) return item;
|
||
return {
|
||
...item,
|
||
...(cached.filters?.length ? { filters: cached.filters } : {}),
|
||
...(cached.preamp !== undefined ? { preamp: cached.preamp } : {}),
|
||
...(cached.autoPre !== undefined ? { autoPre: cached.autoPre } : {}),
|
||
};
|
||
});
|
||
}, []);
|
||
|
||
const syncPeqCatalogFromState = useCallback(
|
||
(
|
||
remote: { peq?: PeqCatalogItem[]; peqSelect?: number },
|
||
devicePeqSelect?: number,
|
||
) => {
|
||
const items = mergeRemotePeqCatalog(remote.peq ?? []);
|
||
setPeqItems(items as typeof peqItems);
|
||
setHeadphoneModels(items.map((item) => item.name));
|
||
if (items.length === 0) {
|
||
setHeadphoneIdx(0);
|
||
return;
|
||
}
|
||
const nextIdx = Math.min(
|
||
Math.max(devicePeqSelect ?? remote.peqSelect ?? 0, 0),
|
||
items.length - 1,
|
||
);
|
||
setHeadphoneIdx(nextIdx);
|
||
},
|
||
[mergeRemotePeqCatalog],
|
||
);
|
||
|
||
/** 将当前 A 曲线与总增益写回 peqItems 与本地缓存,切换耳机时才能恢复编辑结果 */
|
||
const persistPresetEditsToPeqItem = useCallback(
|
||
(
|
||
catalogIdx: number,
|
||
filtersSource: typeof DEFAULT_BANDS,
|
||
meta?: { preamp?: number; autoPre?: number },
|
||
) => {
|
||
const filters = filtersSource.map(bandToPeqFilter);
|
||
setPeqItems((prev) =>
|
||
prev.map((item, i) => {
|
||
if (i !== catalogIdx) return item;
|
||
const merged = {
|
||
...item,
|
||
filters,
|
||
...(meta?.preamp !== undefined ? { preamp: meta.preamp } : {}),
|
||
...(meta?.autoPre !== undefined ? { autoPre: meta.autoPre } : {}),
|
||
};
|
||
rememberPeqPresetCache(item.name, {
|
||
filters,
|
||
preamp: merged.preamp,
|
||
autoPre: merged.autoPre,
|
||
});
|
||
return merged;
|
||
}),
|
||
);
|
||
},
|
||
[rememberPeqPresetCache],
|
||
);
|
||
|
||
const handleSelectHeadphone = useCallback(
|
||
(idx: number) => {
|
||
if (idx === headphoneIdx) {
|
||
setIsHeadphoneMenuOpen(false);
|
||
return;
|
||
}
|
||
const leaving = peqItems[headphoneIdx];
|
||
const leavingFilters = bandsByMode.A.map(bandToPeqFilter);
|
||
rememberPeqPresetCache(leaving?.name, {
|
||
filters: leavingFilters,
|
||
preamp: leaving?.preamp,
|
||
autoPre: leaving?.autoPre,
|
||
});
|
||
persistPresetEditsToPeqItem(headphoneIdx, bandsByMode.A, {
|
||
preamp: leaving?.preamp,
|
||
autoPre: leaving?.autoPre,
|
||
});
|
||
if (peqSyncTimerRef.current !== null) {
|
||
window.clearTimeout(peqSyncTimerRef.current);
|
||
peqSyncTimerRef.current = null;
|
||
}
|
||
setHeadphoneIdx(idx);
|
||
updateSetting({ peqSelect: idx });
|
||
setIsHeadphoneMenuOpen(false);
|
||
},
|
||
[
|
||
bandsByMode.A,
|
||
headphoneIdx,
|
||
peqItems,
|
||
persistPresetEditsToPeqItem,
|
||
rememberPeqPresetCache,
|
||
updateSetting,
|
||
],
|
||
);
|
||
|
||
const applyPeqStateToUI = (
|
||
remote: { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number; filters?: PeqFilter[] },
|
||
) => {
|
||
if (remote.peq !== undefined) {
|
||
syncPeqCatalog({
|
||
filters: remote.filters ?? peqState?.filters ?? [],
|
||
peq: remote.peq,
|
||
peqSelect: remote.peqSelect ?? deviceState?.peqSelect ?? peqState?.peqSelect,
|
||
});
|
||
}
|
||
lastPeqCatalogSyncKeyRef.current = buildPeqCatalogSyncKey(remote, deviceState?.peqSelect);
|
||
syncPeqCatalogFromState(remote, deviceState?.peqSelect);
|
||
|
||
const items = mergeRemotePeqCatalog((remote.peq ?? []) as PeqCatalogItem[]);
|
||
if (items.length === 0) {
|
||
setBandsForBothModes(DEFAULT_BANDS);
|
||
setSelectedBandByMode({ A: 0, B: 0 });
|
||
return;
|
||
}
|
||
|
||
const nextIdx = Math.min(
|
||
Math.max(deviceState?.peqSelect ?? remote.peqSelect ?? 0, 0),
|
||
items.length - 1,
|
||
);
|
||
const nextBands = normalizeFiltersFromPeq(items[nextIdx] as { filters?: any[] | string });
|
||
if (nextBands.length > 0) {
|
||
setBandsForBothModes(nextBands);
|
||
}
|
||
setSelectedBandByMode({ A: 0, B: 0 });
|
||
};
|
||
|
||
const handleDeleteHeadphone = async () => {
|
||
const target = peqItems[headphoneIdx];
|
||
if (!target?.name) return;
|
||
|
||
const confirmed = window.confirm(eqInterp(eqUi.deleteConfirm, { name: target.name }));
|
||
if (!confirmed) return;
|
||
|
||
try {
|
||
if (isDemoMode || !api) {
|
||
const nextItems = peqItems.filter((_, idx) => idx !== headphoneIdx);
|
||
delete peqPresetCacheRef.current[target.name];
|
||
applyPeqStateToUI({ peq: nextItems, peqSelect: Math.max(0, headphoneIdx - 1) });
|
||
toast.success(eqUi.toastDeleted);
|
||
return;
|
||
}
|
||
|
||
delete peqPresetCacheRef.current[target.name];
|
||
await api.removePeq([target.name]);
|
||
const latest = await api.getPeqState();
|
||
applyPeqStateToUI(latest);
|
||
toast.success(eqUi.toastDeleted);
|
||
} catch {
|
||
toast.error(eqUi.toastDeleteFail);
|
||
}
|
||
};
|
||
|
||
const openAddPresetDialog = () => {
|
||
const existingNames = headphoneModels;
|
||
const currentName = headphoneModels[headphoneIdx] ?? "Preset";
|
||
setCopyPresetName(getUniquePresetName(currentName, existingNames));
|
||
setFlatPresetName(getUniquePresetName("Flat", existingNames));
|
||
setAddPresetMode("copy");
|
||
setIsAddPresetDialogOpen(true);
|
||
};
|
||
|
||
const openSaveBDialog = () => {
|
||
const currentName = peqItems[headphoneIdx]?.name ?? headphoneModels[headphoneIdx] ?? "Preset";
|
||
const defaultName = getUniquePresetName(`${currentName}_B`, headphoneModels);
|
||
setSaveBPresetName(defaultName);
|
||
setIsSaveBDialogOpen(true);
|
||
};
|
||
|
||
const handleSaveAddPreset = async () => {
|
||
const nextName = addPresetMode === "copy" ? copyPresetName.trim() : flatPresetName.trim();
|
||
if (!nextName) {
|
||
toast.error(eqUi.addPresetNameEmpty);
|
||
return;
|
||
}
|
||
if (headphoneModels.includes(nextName)) {
|
||
toast.error(eqUi.addPresetNameExists);
|
||
return;
|
||
}
|
||
|
||
const copyPayload: PeqChangePayload = {
|
||
peqChange: {
|
||
name: nextName,
|
||
filters: bands.map(bandToPeqFilter),
|
||
autoPre: currentPeq?.autoPre ?? 0,
|
||
preamp: currentPeq?.preamp ?? 0,
|
||
canDel: currentPeq?.canDel ?? 1,
|
||
},
|
||
};
|
||
|
||
const flatPayload: PeqChangePayload = {
|
||
peqChange: {
|
||
name: nextName,
|
||
preamp: 0,
|
||
canDel: 1,
|
||
autoPre: 0,
|
||
filters: FLAT_PRESET_FILTERS,
|
||
},
|
||
};
|
||
|
||
try {
|
||
await upgradePeqChange(addPresetMode === "copy" ? copyPayload : flatPayload);
|
||
if (api && !isDemoMode) {
|
||
const latest = await api.getPeqState();
|
||
applyPeqStateToUI(latest as { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number });
|
||
const createdIndex = latest.peq?.findIndex((item) => item.name === nextName) ?? -1;
|
||
if (createdIndex >= 0) {
|
||
setHeadphoneIdx(createdIndex);
|
||
updateSetting({ peqSelect: createdIndex });
|
||
}
|
||
} else {
|
||
const localFilters = addPresetMode === "copy"
|
||
? bands.map(bandToPeqFilter)
|
||
: FLAT_PRESET_FILTERS;
|
||
const localAutoPre = addPresetMode === "copy" ? (currentPeq?.autoPre ?? 0) : 0;
|
||
const localPreamp = addPresetMode === "copy" ? (currentPeq?.preamp ?? 0) : 0;
|
||
setHeadphoneModels((prev) => [...prev, nextName]);
|
||
setPeqItems((prev) => [
|
||
...prev,
|
||
{
|
||
name: nextName,
|
||
filters: localFilters,
|
||
autoPre: localAutoPre,
|
||
preamp: localPreamp,
|
||
canDel: 1,
|
||
},
|
||
]);
|
||
setHeadphoneIdx(headphoneModels.length);
|
||
}
|
||
setIsAddPresetDialogOpen(false);
|
||
toast.success(eqUi.toastAddPresetOk);
|
||
} catch {
|
||
toast.error(eqUi.toastAddPresetFail);
|
||
}
|
||
};
|
||
|
||
const handleSaveBAsPreset = async () => {
|
||
const nextName = saveBPresetName.trim();
|
||
if (!nextName) {
|
||
toast.error(eqUi.addPresetNameEmpty);
|
||
return;
|
||
}
|
||
if (headphoneModels.includes(nextName)) {
|
||
toast.error(eqUi.addPresetNameExists);
|
||
return;
|
||
}
|
||
|
||
const bBands = cloneBands(bandsByMode.B);
|
||
const filters = bBands.map(bandToPeqFilter);
|
||
const payload: PeqChangePayload = {
|
||
peqChange: {
|
||
name: nextName,
|
||
filters,
|
||
autoPre: currentPeq?.autoPre ?? 0,
|
||
preamp: currentPeq?.preamp ?? 0,
|
||
canDel: currentPeq?.canDel ?? 1,
|
||
},
|
||
};
|
||
|
||
try {
|
||
await upgradePeqChange(payload);
|
||
if (api && !isDemoMode) {
|
||
const latest = await api.getPeqState();
|
||
applyPeqStateToUI(latest as { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number });
|
||
const createdIndex = latest.peq?.findIndex((item) => item.name === nextName) ?? -1;
|
||
if (createdIndex >= 0) {
|
||
setHeadphoneIdx(createdIndex);
|
||
updateSetting({ peqSelect: createdIndex });
|
||
}
|
||
} else {
|
||
setHeadphoneModels((prev) => [...prev, nextName]);
|
||
setPeqItems((prev) => [
|
||
...prev,
|
||
{
|
||
name: nextName,
|
||
filters,
|
||
autoPre: currentPeq?.autoPre ?? 0,
|
||
preamp: currentPeq?.preamp ?? 0,
|
||
canDel: currentPeq?.canDel ?? 1,
|
||
},
|
||
]);
|
||
setHeadphoneIdx(headphoneModels.length);
|
||
}
|
||
setIsSaveBDialogOpen(false);
|
||
toast.success(eqUi.toastSaveBOk);
|
||
} catch {
|
||
toast.error(eqUi.toastSaveBFail);
|
||
}
|
||
};
|
||
|
||
// msgCount 轮询更新 peqState 后,同步耳机列表与当前选中项
|
||
useEffect(() => {
|
||
if (!peqState?.peq) return;
|
||
const key = buildPeqCatalogSyncKey(peqState, deviceState?.peqSelect);
|
||
if (!key || key === lastPeqCatalogSyncKeyRef.current) return;
|
||
lastPeqCatalogSyncKeyRef.current = key;
|
||
syncPeqCatalogFromState(peqState, deviceState?.peqSelect);
|
||
}, [peqState, deviceState?.peqSelect, syncPeqCatalogFromState]);
|
||
|
||
// 切换耳机时默认回到 A 组对比
|
||
useEffect(() => {
|
||
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<string, unknown>) &&
|
||
(parsed as { fr?: unknown }).fr &&
|
||
typeof (parsed as { fr?: unknown }).fr === "object" &&
|
||
"raw" in ((parsed as { fr?: Record<string, unknown> }).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<number[] | null> => {
|
||
let brand = peq?.brand?.trim() ?? "";
|
||
let model = peq?.model?.trim() ?? "";
|
||
|
||
// Some presets only have `name` (e.g. "Apple AirPods Pro") and miss explicit brand/model.
|
||
if ((!brand || !model) && peq?.name) {
|
||
const [first, ...rest] = peq.name.trim().split(/\s+/);
|
||
if (!brand && first) brand = first;
|
||
if (!model && rest.length > 0) model = rest.join(" ");
|
||
}
|
||
if (!brand || !model) return 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<string, unknown>).y
|
||
?? (point as Record<string, unknown>).value
|
||
?? (point as Record<string, unknown>).db
|
||
?? (point as Record<string, unknown>).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;
|
||
console.log("[modelCurve] raw check", {
|
||
brand,
|
||
model,
|
||
hasRaw: !!cleanedRaw,
|
||
rawLength: cleanedRaw?.length ?? 0,
|
||
});
|
||
return cleanedRaw;
|
||
}
|
||
return null;
|
||
},
|
||
[],
|
||
);
|
||
|
||
// 加载耳机列表并初始化曲线
|
||
useEffect(() => {
|
||
async function loadHeadphones() {
|
||
allowPeqRemoteSyncRef.current = false;
|
||
try {
|
||
if (isDemoMode || !api) {
|
||
// 演示模式使用默认列表
|
||
const defaultModels = [
|
||
"Sennheiser HD 650",
|
||
"Sennheiser HD 800",
|
||
"Sony WH-1000XM5",
|
||
"AKG K701",
|
||
"Beyerdynamic DT 990",
|
||
"Audio-Technica ATH-M50x",
|
||
];
|
||
setHeadphoneModels(defaultModels);
|
||
setPeqItems(defaultModels.map(name => ({ name, filters: [] })));
|
||
setCurrentRawCurve(null);
|
||
return;
|
||
}
|
||
try {
|
||
const loadedPeq = await api.getPeqState();
|
||
if (loadedPeq.peq && loadedPeq.peq.length > 0) {
|
||
syncPeqCatalog(loadedPeq);
|
||
setHeadphoneModels(loadedPeq.peq.map((h) => h.name));
|
||
setPeqItems(loadedPeq.peq);
|
||
const nextIdx = Math.min(
|
||
Math.max(deviceState?.peqSelect ?? loadedPeq.peqSelect ?? 0, 0),
|
||
loadedPeq.peq.length - 1,
|
||
);
|
||
setHeadphoneIdx(nextIdx);
|
||
lastPeqCatalogSyncKeyRef.current = buildPeqCatalogSyncKey(loadedPeq, deviceState?.peqSelect);
|
||
|
||
// 初始化当前选中的耳机曲线
|
||
const currentPeq = loadedPeq.peq[nextIdx];
|
||
if (currentPeq) {
|
||
// 修复 filters 数据(兼容不同的字段名)
|
||
const fixedFilters = normalizeFiltersFromPeq(currentPeq);
|
||
|
||
// 更新 bands 状态
|
||
if (fixedFilters && fixedFilters.length > 0) {
|
||
setBandsForBothModes(fixedFilters);
|
||
setSelectedBandByMode({ A: 0, B: 0 });
|
||
}
|
||
|
||
// raw 与图表渲染统一交给 headphoneIdx/peqItems 监听逻辑处理,
|
||
// 避免首次进入页面时重复请求 modelCurve。
|
||
|
||
}
|
||
} else {
|
||
// 如果没有数据,使用默认列表
|
||
const defaultModels = [
|
||
"Sennheiser HD 650",
|
||
"Sennheiser HD 800",
|
||
"Sony WH-1000XM5",
|
||
"AKG K701",
|
||
"Beyerdynamic DT 990",
|
||
"Audio-Technica ATH-M50x",
|
||
];
|
||
setHeadphoneModels(defaultModels);
|
||
setPeqItems(defaultModels.map(name => ({ name, filters: [] })));
|
||
setCurrentRawCurve(null);
|
||
}
|
||
} catch {
|
||
// 出错时使用默认列表
|
||
const defaultModels = [
|
||
"Sennheiser HD 650",
|
||
"Sennheiser HD 800",
|
||
"Sony WH-1000XM5",
|
||
"AKG K701",
|
||
"Beyerdynamic DT 990",
|
||
"Audio-Technica ATH-M50x",
|
||
];
|
||
setHeadphoneModels(defaultModels);
|
||
setPeqItems(defaultModels.map(name => ({ name, filters: [] })));
|
||
setCurrentRawCurve(null);
|
||
}
|
||
} finally {
|
||
allowPeqRemoteSyncRef.current = true;
|
||
}
|
||
}
|
||
loadHeadphones();
|
||
}, [api, isDemoMode, loadRawCurveForPeq]);
|
||
|
||
/** 切换耳机型号时变;不含 filters,避免编辑时写回 peqItems 把 B 试听曲线重置 */
|
||
const headphoneSwitchKey = useMemo(() => {
|
||
const peq = peqItems[headphoneIdx];
|
||
if (!peq) return String(headphoneIdx);
|
||
return [headphoneIdx, peq.name ?? "", peq.brand ?? "", peq.model ?? ""].join("\u0001");
|
||
}, [headphoneIdx, peqItems]);
|
||
|
||
// 切换耳机型号后,从 peqItems 加载该型号的 filters(暂停一次上报避免错写)
|
||
useEffect(() => {
|
||
const peq = peqItems[headphoneIdx];
|
||
if (!peq) return;
|
||
const nextBands = normalizeFiltersFromPeq(peq);
|
||
if (!nextBands.length) return;
|
||
syncingHeadphoneRef.current = true;
|
||
setBandsForBothModes(nextBands);
|
||
setSelectedBandByMode({ A: 0, B: 0 });
|
||
let cancelled = false;
|
||
void (async () => {
|
||
const raw = await loadRawCurveForPeq(peq as { brand?: string; model?: string });
|
||
if (cancelled) return;
|
||
setCurrentRawCurve(raw);
|
||
renderCharts(nextBands, raw, false);
|
||
})();
|
||
requestAnimationFrame(() => {
|
||
syncingHeadphoneRef.current = false;
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [headphoneSwitchKey, loadRawCurveForPeq]);
|
||
|
||
type PeqSubmitTopLevel = "peqChange" | "peqApply" | "byMode";
|
||
|
||
const schedulePeqSync = useCallback(
|
||
(
|
||
peq: { name: string; autoPre?: number; preamp?: number; canDel?: number } | undefined,
|
||
filtersSource: typeof DEFAULT_BANDS,
|
||
mode: "A" | "B",
|
||
delay = 320,
|
||
topLevel: PeqSubmitTopLevel = "byMode",
|
||
catalogIdx = headphoneIdx,
|
||
) => {
|
||
if (!allowPeqRemoteSyncRef.current) return;
|
||
if (!isDemoMode && !api) return;
|
||
if (!peq?.name) return;
|
||
|
||
if (peqSyncTimerRef.current !== null) {
|
||
window.clearTimeout(peqSyncTimerRef.current);
|
||
}
|
||
|
||
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 usePeqChange =
|
||
topLevel === "peqChange" || (topLevel === "byMode" && mode === "A");
|
||
if (usePeqChange) {
|
||
persistPresetEditsToPeqItem(idxAtSchedule, filtersSource, {
|
||
preamp: peq.preamp,
|
||
autoPre: peq.autoPre,
|
||
});
|
||
}
|
||
const submit = usePeqChange
|
||
? () => upgradePeqChange({ peqChange: body } satisfies PeqChangePayload)
|
||
: () => upgradePeqApply({ peqApply: body } satisfies PeqApplyPayload);
|
||
submit().catch(() => {
|
||
toast.error("EQ 保存失败");
|
||
});
|
||
}, delay);
|
||
},
|
||
[api, headphoneIdx, isDemoMode, persistPresetEditsToPeqItem, upgradePeqApply, upgradePeqChange],
|
||
);
|
||
|
||
/** 点击 A/B 切换试听:顶层固定 peqApply(A/B 数据结构相同,仅 filters 不同) */
|
||
const handleAbToggle = useCallback(
|
||
(m: "A" | "B") => {
|
||
if (m === abMode) return;
|
||
skipBandsSyncFromAbToggleRef.current = true;
|
||
setAbMode(m);
|
||
schedulePeqSync(peqItems[headphoneIdx], bandsByMode[m], m, 0, "peqApply");
|
||
},
|
||
[abMode, bandsByMode, headphoneIdx, peqItems, schedulePeqSync],
|
||
);
|
||
|
||
const handleCopyAndSwitchTo = useCallback(
|
||
(to: "A" | "B") => {
|
||
const from = abMode;
|
||
const copied = cloneBands(bandsByMode[from]);
|
||
copyModeParams(from, to);
|
||
skipBandsSyncFromAbToggleRef.current = true;
|
||
setAbMode(to);
|
||
schedulePeqSync(peqItems[headphoneIdx], copied, to, 0, "peqApply");
|
||
},
|
||
[abMode, bandsByMode, copyModeParams, headphoneIdx, peqItems, schedulePeqSync],
|
||
);
|
||
|
||
// 参数编辑防抖:A→peqChange,B→peqApply(不因切换 A/B 误触发)
|
||
useEffect(() => {
|
||
if (syncingHeadphoneRef.current) return;
|
||
if (skipPeqAutoSyncRef.current) {
|
||
skipPeqAutoSyncRef.current = false;
|
||
return;
|
||
}
|
||
if (skipBandsSyncFromAbToggleRef.current) {
|
||
skipBandsSyncFromAbToggleRef.current = false;
|
||
return;
|
||
}
|
||
schedulePeqSync(peqItems[headphoneIdx], bands, abMode, 320, "byMode", headphoneIdx);
|
||
return () => {
|
||
if (peqSyncTimerRef.current !== null) {
|
||
window.clearTimeout(peqSyncTimerRef.current);
|
||
}
|
||
};
|
||
}, [bands, abMode, headphoneIdx, currentPeqName, currentPeqPreamp, currentPeqAutoPre, schedulePeqSync]);
|
||
|
||
useEffect(() => {
|
||
const onPointerDown = (event: PointerEvent) => {
|
||
const target = event.target as Node;
|
||
const inFilter = filterMenuRef.current?.contains(target) ?? false;
|
||
const inHeadphone = headphoneMenuRef.current?.contains(target) ?? false;
|
||
if (!inFilter) {
|
||
setIsFilterMenuOpen(false);
|
||
}
|
||
if (!inHeadphone) {
|
||
setIsHeadphoneMenuOpen(false);
|
||
}
|
||
};
|
||
window.addEventListener("pointerdown", onPointerDown);
|
||
return () => window.removeEventListener("pointerdown", onPointerDown);
|
||
}, []);
|
||
|
||
const chartRef = useRef<echarts.ECharts | null>(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) {
|
||
toast.error(eqUi.toastApplyBFail);
|
||
return;
|
||
}
|
||
|
||
const bBands = cloneBands(bandsByMode.B);
|
||
const filters = bBands.map(bandToPeqFilter);
|
||
const payload: PeqChangePayload = {
|
||
peqChange: {
|
||
name: peq.name,
|
||
filters,
|
||
autoPre: peq.autoPre ?? 0,
|
||
preamp: peq.preamp ?? 0,
|
||
canDel: peq.canDel ?? 1,
|
||
},
|
||
};
|
||
|
||
skipPeqAutoSyncRef.current = true;
|
||
setBandsByMode((prev) => ({ ...prev, A: cloneBands(bBands) }));
|
||
setSelectedBandByMode((prev) => ({ ...prev, A: prev.B }));
|
||
rememberPeqPresetCache(peq.name, {
|
||
filters,
|
||
preamp: peq.preamp,
|
||
autoPre: peq.autoPre,
|
||
});
|
||
setPeqItems((prev) =>
|
||
prev.map((item, i) => (i !== headphoneIdx ? item : { ...item, filters })),
|
||
);
|
||
setAbMode("A");
|
||
|
||
if (peqSyncTimerRef.current !== null) {
|
||
window.clearTimeout(peqSyncTimerRef.current);
|
||
peqSyncTimerRef.current = null;
|
||
}
|
||
|
||
try {
|
||
await upgradePeqChange(payload);
|
||
if (peqItems.length > 0 && headphoneIdx < peqItems.length) {
|
||
renderCharts(bBands, currentRawCurve, false);
|
||
}
|
||
toast.success(eqUi.toastApplyBSuccess);
|
||
} catch {
|
||
skipPeqAutoSyncRef.current = false;
|
||
toast.error(eqUi.toastApplyBFail);
|
||
}
|
||
}, [
|
||
bandsByMode.B,
|
||
currentRawCurve,
|
||
eqUi.toastApplyBFail,
|
||
eqUi.toastApplyBSuccess,
|
||
headphoneIdx,
|
||
peqItems,
|
||
upgradePeqChange,
|
||
]);
|
||
|
||
const updateBand = useCallback(
|
||
(idx: number, patch: Partial<typeof DEFAULT_BANDS[0]>) => {
|
||
setBands((prevBands) => prevBands.map((b, i) => (i === idx ? { ...b, ...patch } : b)));
|
||
},
|
||
[setBands],
|
||
);
|
||
|
||
const bandParamDialogMeta = useMemo(() => {
|
||
if (!bandParamDialog) return null;
|
||
const rangeMessages = {
|
||
invalid: eqUi.paramInputInvalid ?? "请输入有效数值",
|
||
outOfRange: "",
|
||
};
|
||
switch (bandParamDialog) {
|
||
case "freq":
|
||
return {
|
||
title: eqUi.paramInputTitleFreq ?? "设置频率",
|
||
hint: eqUi.paramInputHintFreq ?? "范围:20 – 20000 Hz,可使用 k 表示 kHz(如 9.5k)",
|
||
placeholder: "9500",
|
||
inputMode: "decimal" as const,
|
||
rangeMessages: {
|
||
...rangeMessages,
|
||
outOfRange: eqInterp(eqUi.paramInputOutOfRangeFreq, {
|
||
min: String(BAND_FREQ_MIN),
|
||
max: String(BAND_FREQ_MAX),
|
||
}) || `频率需在 ${BAND_FREQ_MIN} – ${BAND_FREQ_MAX} Hz 之间`,
|
||
},
|
||
};
|
||
case "gain":
|
||
return {
|
||
title: eqUi.paramInputTitleGain ?? "设置增益",
|
||
hint: eqUi.paramInputHintGain ?? "范围:-15.0 – +15.0 dB",
|
||
placeholder: "0.0",
|
||
inputMode: "decimal" as const,
|
||
rangeMessages: {
|
||
...rangeMessages,
|
||
outOfRange: eqInterp(eqUi.paramInputOutOfRangeGain, {
|
||
min: BAND_GAIN_MIN.toFixed(1),
|
||
max: BAND_GAIN_MAX.toFixed(1),
|
||
}) || `增益需在 ${BAND_GAIN_MIN} – ${BAND_GAIN_MAX} dB 之间`,
|
||
},
|
||
};
|
||
case "q":
|
||
return {
|
||
title: eqUi.paramInputTitleQ ?? "设置 Q 值",
|
||
hint: eqUi.paramInputHintQ ?? "范围:0.10 – 10.00",
|
||
placeholder: "1.41",
|
||
inputMode: "decimal" as const,
|
||
rangeMessages: {
|
||
...rangeMessages,
|
||
outOfRange: eqInterp(eqUi.paramInputOutOfRangeQ, {
|
||
min: BAND_Q_MIN.toFixed(2),
|
||
max: BAND_Q_MAX.toFixed(2),
|
||
}) || `Q 值需在 ${BAND_Q_MIN} – ${BAND_Q_MAX} 之间`,
|
||
},
|
||
};
|
||
}
|
||
}, [bandParamDialog, eqUi]);
|
||
|
||
const openBandParamDialog = (kind: BandParamKind) => {
|
||
setBandParamDialog(kind);
|
||
setBandParamInput(formatBandParamForInput(kind, band));
|
||
};
|
||
|
||
const closeBandParamDialog = () => {
|
||
setBandParamDialog(null);
|
||
setBandParamInput("");
|
||
};
|
||
|
||
const applyBandParamDialog = () => {
|
||
if (!bandParamDialog || !bandParamDialogMeta) return;
|
||
const parsed = parseBandParamInput(bandParamDialog, bandParamInput, bandParamDialogMeta.rangeMessages);
|
||
if (!parsed.ok) {
|
||
toast.error(parsed.message);
|
||
return;
|
||
}
|
||
if (bandParamDialog === "freq") updateBand(selectedBand, { freq: parsed.value });
|
||
else if (bandParamDialog === "gain") updateBand(selectedBand, { gain: parsed.value });
|
||
else updateBand(selectedBand, { q: parsed.value });
|
||
closeBandParamDialog();
|
||
};
|
||
|
||
const updateCurrentPeqMeta = (patch: Partial<{ autoPre: number; preamp: number }>) => {
|
||
let updatedPeq: { name: string; autoPre?: number; preamp?: number; canDel?: number } | undefined;
|
||
setPeqItems((prev) => {
|
||
const next = prev.map((item, i) => {
|
||
if (i !== headphoneIdx) return item;
|
||
const merged = { ...item, ...patch };
|
||
updatedPeq = merged;
|
||
rememberPeqPresetCache(item.name, patch);
|
||
return merged;
|
||
});
|
||
return next;
|
||
});
|
||
schedulePeqSync(updatedPeq, bands, abMode, 0);
|
||
};
|
||
|
||
return (
|
||
<div className="min-h-screen bg-black">
|
||
<div style={{
|
||
filter: eqOn ? "none" : "grayscale(1) opacity(0.4)",
|
||
transition: "filter 0.3s ease-in-out"
|
||
}}>
|
||
{/* ── Header ── */}
|
||
<div className="page-header">
|
||
<button onClick={() => setLocation("/")} className="mr-4 text-white/60 active:text-white transition-colors">
|
||
<ChevronLeft size={24} />
|
||
</button>
|
||
<h1 className="flex-1 text-center text-[17px] font-semibold text-white">HP-EQ</h1>
|
||
<IOSToggle
|
||
checked={eqOn}
|
||
onChange={(v) => {
|
||
if (v) {
|
||
void updateSetting(
|
||
bypassOn ? { peqEnable: 1, dsp_enable: 1 } : { peqEnable: 1 },
|
||
);
|
||
} else {
|
||
void updateSetting({ peqEnable: 0 });
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<FeatureGate
|
||
enabled={eqOn}
|
||
className="px-4 pb-32 pt-3 space-y-3"
|
||
labels={{
|
||
title: eqUi.enableConfirmTitle ?? "HP-EQ 未开启",
|
||
description: eqUi.enableConfirmDesc ?? "当前 HP-EQ 功能已关闭,是否开启?",
|
||
cancel: eqUi.cancel ?? "取消",
|
||
confirm: eqUi.enableConfirmOk ?? "开启",
|
||
}}
|
||
onEnable={() => {
|
||
const bypass = (deviceState?.dsp_enable ?? 0) === 0;
|
||
return updateSetting(bypass ? { peqEnable: 1, dsp_enable: 1 } : { peqEnable: 1 });
|
||
}}
|
||
>
|
||
{/* ── Action cards: 批量编辑 / 添加耳机型号 ── */}
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<button
|
||
className="ios-list-group flex flex-col items-center justify-center py-3 gap-2 active:opacity-70 transition-opacity"
|
||
onClick={openBatchEditDialog}>
|
||
<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)" }}>
|
||
<Edit3 size={20} style={{ color: "#00FFF6" }} />
|
||
</div>
|
||
<span className="text-[14px] text-white/80">{peqCardLabels.edit}</span>
|
||
</button>
|
||
<button
|
||
className="ios-list-group flex flex-col items-center justify-center py-3 gap-2 active:opacity-70 transition-opacity"
|
||
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" }} />
|
||
</div>
|
||
<span className="text-[14px] text-white/80">{peqCardLabels.headset}</span>
|
||
</button>
|
||
</div>
|
||
|
||
{/* ── Headphone model selector ── */}
|
||
<div className="flex items-center justify-between px-1">
|
||
<div
|
||
ref={headphoneMenuRef}
|
||
className="relative z-40 flex-1 mr-2 rounded-[12px] overflow-visible backdrop-blur-md transition-all"
|
||
style={{
|
||
background: "linear-gradient(180deg, rgba(36,38,42,0.92) 0%, rgba(24,26,30,0.92) 100%)",
|
||
border: "1px solid rgba(0,255,246,0.52)",
|
||
boxShadow: "0 0 14px rgba(0,255,246,0.12), inset 0 1px 0 rgba(255,255,255,0.08)",
|
||
}}
|
||
>
|
||
<button
|
||
type="button"
|
||
className="w-full pl-3 pr-8 py-2 text-left text-[13px] font-semibold tracking-wide text-[#E9FBFF] bg-transparent outline-none cursor-pointer active:opacity-80 transition-opacity rounded-[12px] truncate"
|
||
onClick={() => setIsHeadphoneMenuOpen((v) => !v)}
|
||
title={headphoneModels[headphoneIdx] ?? ""}
|
||
>
|
||
{headphoneModels[headphoneIdx] ?? "—"}
|
||
</button>
|
||
<ChevronDown
|
||
size={13}
|
||
className={`absolute right-2.5 top-1/2 -translate-y-1/2 text-[#00FFF6] pointer-events-none transition-transform ${isHeadphoneMenuOpen ? "rotate-180" : ""}`}
|
||
/>
|
||
{isHeadphoneMenuOpen && (
|
||
<div
|
||
className="absolute left-0 right-0 mt-1 z-50 rounded-[10px] overflow-hidden max-h-56 overflow-y-auto"
|
||
style={{
|
||
background: "rgba(10,12,16,0.98)",
|
||
border: "1px solid rgba(0,255,246,0.35)",
|
||
boxShadow: "0 8px 20px rgba(0,0,0,0.45)",
|
||
}}
|
||
>
|
||
{headphoneModels.map((model, idx) => {
|
||
const active = idx === headphoneIdx;
|
||
return (
|
||
<button
|
||
key={`${model}-${idx}`}
|
||
type="button"
|
||
className={`w-full px-3 py-2 text-left text-[13px] transition-colors truncate ${
|
||
active ? "text-black font-semibold" : "text-white/90 hover:bg-white/10"
|
||
}`}
|
||
style={active ? { background: "#00FFF6" } : undefined}
|
||
title={model}
|
||
onClick={() => handleSelectHeadphone(idx)}
|
||
>
|
||
{model}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
className="w-9 h-9 rounded-full flex items-center justify-center text-white/60 active:text-white transition-colors"
|
||
style={{ background: "rgba(44,44,46,0.8)", border: "1px solid rgba(255,255,255,0.1)" }}
|
||
onClick={handleDeleteHeadphone}
|
||
>
|
||
<Minus size={15} />
|
||
</button>
|
||
<button
|
||
className="w-9 h-9 rounded-full flex items-center justify-center text-white/60 active:text-white transition-colors"
|
||
style={{ background: "rgba(44,44,46,0.8)", border: "1px solid rgba(255,255,255,0.1)" }}
|
||
onClick={() => {
|
||
openAddPresetDialog();
|
||
}}
|
||
>
|
||
<Plus size={15} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Frequency response chart ── */}
|
||
<FreqChart
|
||
bands={bands}
|
||
rawCurve={currentRawCurve}
|
||
selectedBand={selectedBand}
|
||
abMode={abMode}
|
||
onAbToggle={handleAbToggle}
|
||
onCopyAndSwitchTo={handleCopyAndSwitchTo}
|
||
onApplyB={() => void handleApplyB()}
|
||
onSaveB={openSaveBDialog}
|
||
onBandDrag={(idx, patch) => setBands((prev) => prev.map((b, i) => i === idx ? { ...b, ...patch } : b))}
|
||
onBandSelect={setSelectedBand}
|
||
eqUi={eqUi}
|
||
/>
|
||
|
||
{/* ── Band grid: 2×5 below lg, 1×10 on lg+ (typical desktop) ── */}
|
||
<div className="ios-list-group p-3">
|
||
<div className="grid grid-cols-5 gap-1.5 lg:grid-cols-10 lg:gap-1">
|
||
{bands.map((b, i) => (
|
||
<button key={i}
|
||
className={cn(
|
||
"flex flex-col items-center rounded-[10px] transition-all duration-150 active:scale-95 py-2.5 px-1 lg:py-2 lg:px-0.5",
|
||
selectedBand === i ? "text-black" : "text-white/55"
|
||
)}
|
||
style={selectedBand === i ? {
|
||
background: "#00FFF6",
|
||
boxShadow: "0 0 14px rgba(0,255,246,0.45)",
|
||
} : {
|
||
background: "rgba(44,44,46,0.65)",
|
||
}}
|
||
onClick={() => setSelectedBand(i)}>
|
||
<span
|
||
className="notranslate text-[12px] font-bold leading-tight tabular-nums lg:text-[11px]"
|
||
translate="no"
|
||
>
|
||
{freqLabel(b.freq)}
|
||
</span>
|
||
<span className="text-[9px] mt-0.5 opacity-70 leading-tight lg:text-[8px] lg:mt-0">{getFilterShortName(b.type)}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Band detail card ── */}
|
||
<div className="ios-list-group p-4 space-y-5 relative z-30 overflow-visible">
|
||
{/* Filter type selector */}
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-[14px] text-white/50 font-medium">{peqCardLabels.filters?.label ?? "滤波器"}</span>
|
||
<div
|
||
ref={filterMenuRef}
|
||
className="relative z-40 w-[136px] rounded-[12px] overflow-visible backdrop-blur-md transition-all"
|
||
style={{
|
||
background: "linear-gradient(180deg, rgba(36,38,42,0.92) 0%, rgba(24,26,30,0.92) 100%)",
|
||
border: "1px solid rgba(0,255,246,0.52)",
|
||
boxShadow: "0 0 14px rgba(0,255,246,0.12), inset 0 1px 0 rgba(255,255,255,0.08)",
|
||
}}
|
||
>
|
||
<button
|
||
type="button"
|
||
className="w-full pl-3 pr-8 py-2 text-left text-[13px] font-semibold tracking-wide text-[#E9FBFF] bg-transparent outline-none cursor-pointer active:opacity-80 transition-opacity rounded-[12px]"
|
||
onClick={() => setIsFilterMenuOpen((v) => !v)}
|
||
>
|
||
{normalizeFilterType(band.type)}
|
||
</button>
|
||
<ChevronDown
|
||
size={13}
|
||
className={`absolute right-2.5 top-1/2 -translate-y-1/2 text-[#00FFF6] pointer-events-none transition-transform ${isFilterMenuOpen ? "rotate-180" : ""}`}
|
||
/>
|
||
{isFilterMenuOpen && (
|
||
<div
|
||
className="absolute left-0 right-0 mt-1 z-50 rounded-[10px] overflow-hidden"
|
||
style={{
|
||
background: "rgba(10,12,16,0.98)",
|
||
border: "1px solid rgba(0,255,246,0.35)",
|
||
boxShadow: "0 8px 20px rgba(0,0,0,0.45)",
|
||
}}
|
||
>
|
||
{FILTER_TYPES.map((type) => {
|
||
const active = normalizeFilterType(band.type) === type;
|
||
return (
|
||
<button
|
||
key={type}
|
||
type="button"
|
||
className={`w-full px-3 py-2 text-left text-[13px] transition-colors ${
|
||
active ? "text-black font-semibold" : "text-white/90 hover:bg-white/10"
|
||
}`}
|
||
style={active ? { background: "#00FFF6" } : undefined}
|
||
onClick={() => {
|
||
updateBand(selectedBand, { type });
|
||
setIsFilterMenuOpen(false);
|
||
}}
|
||
>
|
||
{type}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* FREQ */}
|
||
<div>
|
||
<div className="flex items-center justify-between mb-1">
|
||
<span className="text-[13px] text-white/45 font-medium tracking-wider">FREQ</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => openBandParamDialog("freq")}
|
||
className="notranslate text-[13px] font-semibold tabular-nums px-2.5 py-1 rounded-[8px] text-white active:scale-95 transition-transform"
|
||
style={BAND_PARAM_VALUE_BOX_STYLE}
|
||
translate="no"
|
||
>
|
||
{formatBandFreqDisplay(bandSliderPreview?.freq ?? band.freq)}
|
||
</button>
|
||
</div>
|
||
<CyanSlider
|
||
value={Math.log10(bandSliderPreview?.freq ?? band.freq)}
|
||
min={Math.log10(BAND_FREQ_MIN)}
|
||
max={Math.log10(BAND_FREQ_MAX)}
|
||
step={0.005}
|
||
onChange={(v) => {
|
||
const freq = Math.round(Math.pow(10, v));
|
||
setBandSliderPreview((prev) => ({ ...prev, freq }));
|
||
updateBand(selectedBand, { freq });
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* GAIN */}
|
||
<div>
|
||
<div className="flex items-center justify-between mb-1">
|
||
<span className="text-[13px] text-white/45 font-medium tracking-wider">GAIN</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => openBandParamDialog("gain")}
|
||
className="text-[13px] font-semibold px-2.5 py-1 rounded-[8px] text-white active:scale-95 transition-transform"
|
||
style={BAND_PARAM_VALUE_BOX_STYLE}
|
||
>
|
||
{formatBandGainDisplay(bandSliderPreview?.gain ?? band.gain)}
|
||
</button>
|
||
</div>
|
||
<CyanSlider
|
||
value={bandSliderPreview?.gain ?? band.gain}
|
||
min={BAND_GAIN_MIN}
|
||
max={BAND_GAIN_MAX}
|
||
step={0.1}
|
||
onChange={(v) => {
|
||
setBandSliderPreview((prev) => ({ ...prev, gain: v }));
|
||
updateBand(selectedBand, { gain: v });
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* Q 值 */}
|
||
<div>
|
||
<div className="flex items-center justify-between mb-1">
|
||
<span className="text-[13px] text-white/45 font-medium">{peqCardLabels.q ?? "Q值"}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => openBandParamDialog("q")}
|
||
className="text-[13px] font-semibold px-2.5 py-1 rounded-[8px] text-white active:scale-95 transition-transform"
|
||
style={BAND_PARAM_VALUE_BOX_STYLE}
|
||
>
|
||
{formatBandQDisplay(bandSliderPreview?.q ?? band.q)}
|
||
</button>
|
||
</div>
|
||
<CyanSlider
|
||
value={bandSliderPreview?.q ?? band.q}
|
||
min={BAND_Q_MIN}
|
||
max={BAND_Q_MAX}
|
||
step={0.01}
|
||
onChange={(v) => {
|
||
setBandSliderPreview((prev) => ({ ...prev, q: v }));
|
||
updateBand(selectedBand, { q: v });
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Total gain card ── */}
|
||
<div className="ios-list-group p-4 relative z-10">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className="flex items-baseline gap-1">
|
||
<span className="text-[13px] text-white/45">{peqCardLabels.preamp}</span>
|
||
<span className="text-[28px] font-bold ml-2" style={{ color: "#00FFF6" }}>
|
||
{preampValue >= 0 ? `+${preampValue.toFixed(1)}` : preampValue.toFixed(1)}
|
||
</span>
|
||
<span className="text-[15px] text-white/45 ml-0.5">dB</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-[12px] text-white/40 tracking-wider">AUTO</span>
|
||
<IOSToggle
|
||
checked={autoPreOn}
|
||
onChange={(v) => updateCurrentPeqMeta({ autoPre: v ? 1 : 0 })}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<CyanSlider
|
||
value={preampValue + 15}
|
||
min={0}
|
||
max={30}
|
||
step={0.1}
|
||
disabled={autoPreOn}
|
||
onChange={(v) => updateCurrentPeqMeta({ preamp: Number((v - 15).toFixed(1)) })}
|
||
/>
|
||
</div>
|
||
</FeatureGate>
|
||
</div>
|
||
|
||
{bandParamDialog && bandParamDialogMeta && (
|
||
<div
|
||
className="fixed inset-0 z-[120] flex items-center justify-center bg-black/65 px-4"
|
||
onClick={closeBandParamDialog}
|
||
>
|
||
<div
|
||
className="w-full max-w-[420px] rounded-[14px] p-5"
|
||
style={{
|
||
background: "linear-gradient(180deg, rgba(34,36,40,0.98) 0%, rgba(24,26,30,0.98) 100%)",
|
||
border: "1px solid rgba(255,255,255,0.12)",
|
||
boxShadow: "0 18px 48px rgba(0,0,0,0.55)",
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="flex items-center justify-between mb-2">
|
||
<h3 className="text-[18px] font-semibold leading-tight text-white/90">{bandParamDialogMeta.title}</h3>
|
||
<button
|
||
type="button"
|
||
className="rounded-full p-1.5 text-white/45 transition-colors hover:bg-white/10 hover:text-white/80"
|
||
onClick={closeBandParamDialog}
|
||
aria-label={eqUi.closeDrawer}
|
||
>
|
||
<X size={20} />
|
||
</button>
|
||
</div>
|
||
<p className="mb-4 text-[13px] leading-relaxed text-white/45">{bandParamDialogMeta.hint}</p>
|
||
<input
|
||
value={bandParamInput}
|
||
onChange={(e) => setBandParamInput(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
applyBandParamDialog();
|
||
}
|
||
}}
|
||
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
|
||
/>
|
||
<div className="mt-6 flex items-center justify-center gap-4 sm:gap-8">
|
||
<button
|
||
type="button"
|
||
className="min-w-[112px] rounded-full px-5 py-2.5 text-[15px] font-medium text-white/85 transition-colors bg-[#3f4349] hover:bg-[#4a4f56] active:scale-[0.98]"
|
||
onClick={closeBandParamDialog}
|
||
>
|
||
{eqUi.cancel}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="min-w-[112px] rounded-full px-5 py-2.5 text-[15px] font-semibold text-black transition-all bg-[#00FFF6] hover:brightness-95 active:scale-[0.98]"
|
||
onClick={applyBandParamDialog}
|
||
>
|
||
{eqUi.confirmButton ?? eqUi.save}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{isSaveBDialogOpen && (
|
||
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/65 px-4">
|
||
<div
|
||
className="w-full max-w-[420px] rounded-[14px] p-5"
|
||
style={{
|
||
background: "linear-gradient(180deg, rgba(34,36,40,0.98) 0%, rgba(24,26,30,0.98) 100%)",
|
||
border: "1px solid rgba(255,255,255,0.12)",
|
||
boxShadow: "0 18px 48px rgba(0,0,0,0.55)",
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h3 className="text-[18px] font-semibold leading-tight text-white/90">{eqUi.saveBTitle}</h3>
|
||
<button
|
||
type="button"
|
||
className="rounded-full p-1.5 text-white/45 transition-colors hover:bg-white/10 hover:text-white/80"
|
||
onClick={() => setIsSaveBDialogOpen(false)}
|
||
aria-label={eqUi.closeDrawer}
|
||
>
|
||
<X size={20} />
|
||
</button>
|
||
</div>
|
||
|
||
<input
|
||
value={saveBPresetName}
|
||
onChange={(e) => 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
|
||
/>
|
||
|
||
<div className="mt-6 flex items-center justify-center gap-4 sm:gap-8">
|
||
<button
|
||
type="button"
|
||
className="min-w-[112px] rounded-full px-5 py-2.5 text-[15px] font-medium text-white/85 transition-colors bg-[#3f4349] hover:bg-[#4a4f56] active:scale-[0.98]"
|
||
onClick={() => setIsSaveBDialogOpen(false)}
|
||
>
|
||
{eqUi.cancel}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="min-w-[112px] rounded-full px-5 py-2.5 text-[15px] font-semibold text-black transition-all bg-[#00FFF6] hover:brightness-95 active:scale-[0.98]"
|
||
onClick={() => void handleSaveBAsPreset()}
|
||
>
|
||
{eqUi.save}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{isAddPresetDialogOpen && (
|
||
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/65 px-4">
|
||
<div
|
||
className="w-full max-w-[420px] rounded-[14px] p-5"
|
||
style={{
|
||
background: "linear-gradient(180deg, rgba(34,36,40,0.98) 0%, rgba(24,26,30,0.98) 100%)",
|
||
border: "1px solid rgba(255,255,255,0.12)",
|
||
boxShadow: "0 18px 48px rgba(0,0,0,0.55)",
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h3 className="text-[18px] font-semibold leading-tight text-white/90">{eqUi.addPresetTitle}</h3>
|
||
<button
|
||
type="button"
|
||
className="rounded-full p-1.5 text-white/45 transition-colors hover:bg-white/10 hover:text-white/80"
|
||
onClick={() => setIsAddPresetDialogOpen(false)}
|
||
aria-label={eqUi.closeDrawer}
|
||
>
|
||
<X size={20} />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
<label
|
||
className={cn(
|
||
"flex cursor-pointer items-center gap-3 rounded-[12px] border border-white/10 p-3 transition-colors",
|
||
addPresetMode === "copy" ? "bg-white/[0.06]" : "bg-white/[0.02] hover:bg-white/[0.04]",
|
||
)}
|
||
>
|
||
<input
|
||
type="radio"
|
||
name="add-preset-mode"
|
||
checked={addPresetMode === "copy"}
|
||
onChange={() => setAddPresetMode("copy")}
|
||
className="h-4 w-4 shrink-0 accent-[#00FFF6]"
|
||
/>
|
||
<input
|
||
value={copyPresetName}
|
||
onChange={(e) => setCopyPresetName(e.target.value)}
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="h-10 min-w-0 flex-1 rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[15px] text-white/90 outline-none ring-0 ring-offset-0 placeholder:text-white/30 focus:border-white/15 focus:ring-0 focus-visible:ring-0"
|
||
/>
|
||
</label>
|
||
|
||
<label
|
||
className={cn(
|
||
"flex cursor-pointer items-center gap-3 rounded-[12px] border border-white/10 p-3 transition-colors",
|
||
addPresetMode === "flat" ? "bg-white/[0.06]" : "bg-white/[0.02] hover:bg-white/[0.04]",
|
||
)}
|
||
>
|
||
<input
|
||
type="radio"
|
||
name="add-preset-mode"
|
||
checked={addPresetMode === "flat"}
|
||
onChange={() => setAddPresetMode("flat")}
|
||
className="h-4 w-4 shrink-0 accent-[#00FFF6]"
|
||
/>
|
||
<input
|
||
value={flatPresetName}
|
||
onChange={(e) => setFlatPresetName(e.target.value)}
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="h-10 min-w-0 flex-1 rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[15px] text-white/90 outline-none ring-0 ring-offset-0 placeholder:text-white/30 focus:border-white/15 focus:ring-0 focus-visible:ring-0"
|
||
/>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="mt-6 flex items-center justify-center gap-4 sm:gap-8">
|
||
<button
|
||
type="button"
|
||
className="min-w-[112px] rounded-full px-5 py-2.5 text-[15px] font-medium text-white/85 transition-colors bg-[#3f4349] hover:bg-[#4a4f56] active:scale-[0.98]"
|
||
onClick={() => setIsAddPresetDialogOpen(false)}
|
||
>
|
||
{eqUi.cancel}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="min-w-[112px] rounded-full px-5 py-2.5 text-[15px] font-semibold text-black transition-all bg-[#00FFF6] hover:brightness-95 active:scale-[0.98]"
|
||
onClick={handleSaveAddPreset}
|
||
>
|
||
{eqUi.save}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{isBatchEditDialogOpen && (
|
||
<div className="fixed inset-0 z-[121] flex items-center justify-center bg-black/65 px-4">
|
||
<div
|
||
className="w-full max-w-[760px] rounded-[14px] p-5"
|
||
style={{
|
||
background: "linear-gradient(180deg, rgba(34,36,40,0.98) 0%, rgba(24,26,30,0.98) 100%)",
|
||
border: "1px solid rgba(255,255,255,0.12)",
|
||
boxShadow: "0 18px 48px rgba(0,0,0,0.55)",
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h3 className="text-[28px] text-white/90 font-semibold">{eqUi.batchEditTitle}</h3>
|
||
<button
|
||
type="button"
|
||
className="text-white/45 hover:text-white/75 transition-colors"
|
||
onClick={() => setIsBatchEditDialogOpen(false)}
|
||
>
|
||
<X size={20} />
|
||
</button>
|
||
</div>
|
||
<p className="mb-3 text-[13px] text-white/55">
|
||
{eqUi.batchEditHint}
|
||
</p>
|
||
<textarea
|
||
value={batchEditText}
|
||
onChange={(e) => setBatchEditText(e.target.value)}
|
||
spellCheck={false}
|
||
className="h-[360px] w-full resize-none overflow-auto rounded-[10px] border border-white/12 bg-[#15171b] p-3 font-mono text-[13px] leading-6 text-white/90 outline-none focus:border-[#00FFF6]/55"
|
||
/>
|
||
<div className="mt-5 flex items-center justify-center gap-16">
|
||
<button
|
||
type="button"
|
||
className="px-5 py-2 rounded-full text-[24px] text-white/80 bg-[#3f4349] hover:bg-[#4a4f56] transition-colors"
|
||
onClick={() => setIsBatchEditDialogOpen(false)}
|
||
>
|
||
{eqUi.cancel}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="px-5 py-2 rounded-full text-[24px] text-black bg-[#00FFF6] hover:brightness-95 transition-all"
|
||
onClick={handleSaveBatchEdit}
|
||
>
|
||
{eqUi.save}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</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={eqUi.closeDrawer}
|
||
onClick={() => setIsBrandDrawerOpen(false)}
|
||
/>
|
||
<div
|
||
className="relative z-10 flex h-[58dvh] flex-shrink-0 flex-col overflow-hidden 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" ? eqUi.tabBrands : tab === "models" ? eqUi.tabModels : eqUi.tabTarget;
|
||
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 overflow-hidden bg-white">
|
||
{brandDrawerTab === "brands" && (
|
||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||
<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={eqUi.searchPlaceholder}
|
||
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 overflow-x-hidden overscroll-contain touch-pan-y">
|
||
{brandSearchQuery.trim() && catalogSearchLoading && (
|
||
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.searching}</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">{eqUi.noSearchResults}</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">{eqUi.loading}</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">{eqUi.noBrandMatch}</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>
|
||
</div>
|
||
)}
|
||
{brandDrawerTab === "models" && (
|
||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain touch-pan-y">
|
||
<div className="sticky top-0 z-10 border-b border-black/8 bg-white px-4 py-2.5 text-[13px] text-black/55">
|
||
{eqUi.brandLabel}<span className="font-semibold text-black/80">{selectedCatalogBrand || "—"}</span>
|
||
{selectedCatalogModelFromSearch && (
|
||
<span className="ml-2 text-black/45">
|
||
{eqUi.modelFromSearchLabel}
|
||
{selectedCatalogModelFromSearch}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{!selectedCatalogBrand && (
|
||
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.selectBrandFirst}</div>
|
||
)}
|
||
{!!selectedCatalogBrand && catalogModelsLoading && (
|
||
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.loading}</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">{eqUi.noModelsForBrand}</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>
|
||
</div>
|
||
)}
|
||
{brandDrawerTab === "target" && (
|
||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain touch-pan-y">
|
||
<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>
|
||
{eqUi.targetBrand}
|
||
<span className="font-semibold text-black/80">{selectedCatalogBrand || "—"}</span>
|
||
</div>
|
||
<div>
|
||
{eqUi.targetModel}
|
||
<span className="font-semibold text-black/80">{selectedCatalogModelName || "—"}</span>
|
||
</div>
|
||
<div>
|
||
{eqUi.targetForm}
|
||
<span className="font-semibold text-black/80">{selectedCatalogModelForm || eqUi.formAll}</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") 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);
|
||
toast.success(eqUi.toastNewEqOk);
|
||
} catch {
|
||
toast.error(eqUi.toastCurveFail);
|
||
} 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 ? eqUi.confirmLoading : eqUi.confirmButton}
|
||
</button>
|
||
</div>
|
||
{!selectedCatalogModelName && (
|
||
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.selectModelFirst}</div>
|
||
)}
|
||
{!!selectedCatalogModelName && availableCatalogTargets.length === 0 && (
|
||
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.noTargets}</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(eqInterp(eqUi.toastTargetSelected, { name: target.name }));
|
||
}}
|
||
>
|
||
<div className="text-[15px] text-black/85 font-medium">{target.name}</div>
|
||
<div className="mt-1 text-[12px] text-black/55">
|
||
{eqInterp(eqUi.bassBoost, {
|
||
fc: String(target.bassBoost.fc),
|
||
q: String(target.bassBoost.q),
|
||
gain: String(target.bassBoost.gain),
|
||
})}
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<BottomNav />
|
||
</div>
|
||
);
|
||
}
|