Files
controller-v2/client/src/pages/EQPage.tsx
T

2009 lines
72 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
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, Trash2, Copy, Edit3, Headphones, Share2, FilePenLine } 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 {
fetchLuxsinAudioCurve,
normalizePeqFiltersForSubmit,
parseFirmwareVersion,
type PeqFilter,
type PeqApplyPayload,
type PeqChangePayload,
type PeqState,
buildPeqPresetBody,
} from "@/lib/luxsinApi";
import { supportsPeqPresetRenameAndSort } from "@/config/firmwareFeatures";
import { getFilterType, getFilterShortName } from "@/lib/peqAudio";
import localeZh from "@/locales/data-zh.json";
import localeEn from "@/locales/data-en.json";
import { resolveLocalePack } from "@/locales/resolveLocale";
import { usePageTitleWithConnection } from "@/hooks/usePageTitleWithConnection";
import { FreqChart } from "./eq/components/FreqChart";
import { CyanSlider, IOSToggle } from "./eq/components/EqPrimitives";
import { BandParamDialog } from "./eq/components/BandParamDialog";
import { SaveBDialog } from "./eq/components/SaveBDialog";
import { AddPresetDialog } from "./eq/components/AddPresetDialog";
import { BatchEditDialog } from "./eq/components/BatchEditDialog";
import { ShareDialog } from "./eq/components/ShareDialog";
import { BrandDrawer } from "./eq/components/BrandDrawer";
import { PeqOverwriteConfirmDialog } from "./eq/components/PeqOverwriteConfirmDialog";
import { PeqPresetManageDialog } from "./eq/components/PeqPresetManageDialog";
import { useRawCurve } from "./eq/hooks/useRawCurve";
import { isPeqPresetNameTooLong } from "./eq/peqPresetName";
import {
BAND_FREQ_MAX,
BAND_FREQ_MIN,
BAND_GAIN_MAX,
BAND_GAIN_MIN,
BAND_PARAM_VALUE_BOX_STYLE,
BAND_Q_MAX,
BAND_Q_MIN,
DEFAULT_BANDS,
FILTER_TYPES,
FLAT_PRESET_FILTERS,
PEQ_PRESET_MAX,
} from "./eq/eqConstants";
import {
eqInterp,
formatBandFreqDisplay,
formatBandGainDisplay,
formatBandParamForInput,
formatBandQDisplay,
freqLabel,
normalizeFilterType,
normalizeBandFreqHz,
parseBandParamInput,
} from "./eq/eqFormatters";
import {
bandToPeqFilter,
buildPeqCatalogSyncKey,
cloneBands,
fetchEqSyncPeq,
getUniquePresetName,
importedFiltersToPeqFilters,
} 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 pack = resolveLocalePack(deviceState?.language);
const peq = pack.peq as typeof localeZh.peq;
return (peq.eqUi ?? (localeEn.peq as typeof localeEn.peq).eqUi) as PeqEqUi;
}, [deviceState?.language]);
const firmwareVersion = useMemo(
() => parseFirmwareVersion(deviceState?.version),
[deviceState?.version],
);
const peqReorderEnabled = supportsPeqPresetRenameAndSort(firmwareVersion);
const peqCardLabels = useMemo(() => {
const pack = resolveLocalePack(deviceState?.language);
return pack.peq ?? localeEn.peq;
}, [deviceState?.language]);
const eqPageTitle = usePageTitleWithConnection("HP-EQ");
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;
target?: string;
form?: 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 bandSliderDragRef = useRef<BandParamKind | null>(null);
const [isBatchEditDialogOpen, setIsBatchEditDialogOpen] = useState(false);
const [batchEditText, setBatchEditText] = useState("");
const [isBrandDrawerOpen, setIsBrandDrawerOpen] = useState(false);
const [brandDrawerKey, setBrandDrawerKey] = useState(0);
const [isShareDialogOpen, setIsShareDialogOpen] = useState(false);
const [shareDialogKey, setShareDialogKey] = useState(0);
const [isPresetManageDialogOpen, setIsPresetManageDialogOpen] = useState(false);
const [overwritePresetDialog, setOverwritePresetDialog] = useState<{
name: string;
onConfirm: () => void | Promise<void>;
onDismiss: () => void;
} | null>(null);
const [deleteHeadphoneDialog, setDeleteHeadphoneDialog] = useState<{
name: string;
idx: number;
} | null>(null);
const {
currentRawCurve,
setCurrentRawCurve,
rawCurveLoading,
setRawCurveLoading,
loadRawCurveForPeq,
} = useRawCurve();
const allowPeqRemoteSyncRef = useRef(false);
const peqHydrationPendingRef = useRef(true);
const lastPeqCatalogSyncKeyRef = useRef("");
const syncingHeadphoneRef = useRef(false);
const peqSyncTimerRef = useRef<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 guardPeqPresetCapacity = () => {
if (peqItems.length < PEQ_PRESET_MAX) return true;
toast.error(
eqInterp(eqUi.toastPresetLimitReached, { max: PEQ_PRESET_MAX })
|| `Preset limit reached (${PEQ_PRESET_MAX}). Delete some presets first.`,
);
return false;
};
const openAddHeadsetCatalog = () => {
if (!guardPeqPresetCapacity()) return;
setIsBrandDrawerOpen(true);
setBrandDrawerKey((k) => k + 1);
};
const promptOverwriteIfNeeded = useCallback(
(name: string, action: () => void | Promise<void>): Promise<boolean> => {
if (!headphoneModels.includes(name)) {
return Promise.resolve(action()).then(() => true);
}
return new Promise((resolve) => {
setOverwritePresetDialog({
name,
onConfirm: async () => {
setOverwritePresetDialog(null);
await action();
resolve(true);
},
onDismiss: () => {
setOverwritePresetDialog(null);
resolve(false);
},
});
});
},
[headphoneModels],
);
const normalizeFiltersFromPeq = (peq: { filters?: any[] | string } | undefined) => {
if (!peq) return [] as typeof bands;
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 openPresetManageDialog = () => {
setIsPresetManageDialogOpen(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, "byMode", headphoneIdx);
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[] },
) => {
const resolvedPeqSelect =
remote.peqSelect ?? deviceState?.peqSelect ?? peqState?.peqSelect ?? 0;
if (remote.peq !== undefined) {
syncPeqCatalog({
filters: remote.filters ?? peqState?.filters ?? [],
peq: remote.peq,
peqSelect: resolvedPeqSelect,
});
}
lastPeqCatalogSyncKeyRef.current = buildPeqCatalogSyncKey(remote, resolvedPeqSelect);
syncPeqCatalogFromState(remote, resolvedPeqSelect);
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(resolvedPeqSelect, 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 = () => {
const target = peqItems[headphoneIdx];
if (!target?.name) return;
setDeleteHeadphoneDialog({ name: target.name, idx: headphoneIdx });
};
const confirmDeleteHeadphone = async () => {
const target = deleteHeadphoneDialog;
if (!target) return;
setDeleteHeadphoneDialog(null);
try {
if (isDemoMode || !api) {
const nextItems = peqItems.filter((_, idx) => idx !== target.idx);
delete peqPresetCacheRef.current[target.name];
applyPeqStateToUI({
peq: nextItems,
peqSelect: Math.max(0, target.idx - 1),
});
toast.success(eqUi.toastDeleted);
return;
}
delete peqPresetCacheRef.current[target.name];
await api.removePeq([target.name]);
const latest = await fetchEqSyncPeq(api, "deleteHeadphone");
applyPeqStateToUI(latest);
toast.success(eqUi.toastDeleted);
} catch {
toast.error(eqUi.toastDeleteFail);
}
};
const openAddPresetDialog = () => {
if (!guardPeqPresetCapacity()) return;
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 (isPeqPresetNameTooLong(nextName)) {
toast.error(eqUi.addPresetNameTooLong);
return;
}
if (headphoneModels.includes(nextName)) {
toast.error(eqUi.addPresetNameExists);
return;
}
const copyBrand = currentPeq?.brand?.trim() ?? "";
const copyModel = currentPeq?.model?.trim() ?? "";
const copyTarget = currentPeq?.target?.trim() ?? "";
const copyForm = currentPeq?.form?.trim() ?? "";
const copyPayload: PeqChangePayload = {
peqChange: buildPeqPresetBody(
{
name: nextName,
brand: copyBrand || undefined,
model: copyModel || undefined,
target: copyTarget || undefined,
form: copyForm || undefined,
autoPre: currentPeq?.autoPre,
preamp: currentPeq?.preamp,
canDel: currentPeq?.canDel,
},
bands.map(bandToPeqFilter),
),
};
const flatPayload: PeqChangePayload = {
peqChange: buildPeqPresetBody(
{ name: nextName, preamp: 0, canDel: 1, autoPre: 0 },
FLAT_PRESET_FILTERS,
),
};
try {
await upgradePeqChange(addPresetMode === "copy" ? copyPayload : flatPayload);
if (api && !isDemoMode) {
const latest = await fetchEqSyncPeq(api, "addPreset");
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,
...(addPresetMode === "copy" && copyBrand ? { brand: copyBrand } : {}),
...(addPresetMode === "copy" && copyModel ? { model: copyModel } : {}),
...(addPresetMode === "copy" && copyTarget ? { target: copyTarget } : {}),
...(addPresetMode === "copy" && copyForm ? { form: copyForm } : {}),
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 (isPeqPresetNameTooLong(nextName)) {
toast.error(eqUi.addPresetNameTooLong);
return;
}
if (headphoneModels.includes(nextName)) {
toast.error(eqUi.addPresetNameExists);
return;
}
const bBands = cloneBands(bandsByMode.B);
const filters = bBands.map(bandToPeqFilter);
const payload: PeqChangePayload = {
peqChange: buildPeqPresetBody(
{
name: nextName,
brand: currentPeq?.brand,
model: currentPeq?.model,
target: currentPeq?.target,
form: currentPeq?.form,
autoPre: currentPeq?.autoPre,
preamp: currentPeq?.preamp,
canDel: currentPeq?.canDel,
},
filters,
),
};
try {
await upgradePeqChange(payload);
if (api && !isDemoMode) {
const latest = await fetchEqSyncPeq(api, "saveBAsPreset");
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);
}
};
const handleImportSharedEq = async (
data: {
name: string;
brand?: string;
model?: string;
target?: string;
form?: string;
filters?: unknown;
preamp?: number;
autoPre?: number;
},
shareCode: string,
) => {
const nextName = data.name?.trim();
if (!nextName) {
toast.error(eqUi.addPresetNameEmpty);
return;
}
if (isPeqPresetNameTooLong(nextName)) {
toast.error(eqUi.addPresetNameTooLong);
return;
}
if (!shareCode.trim()) {
toast.error(eqUi.importCodeNotFound);
return;
}
if (!guardPeqPresetCapacity() && !headphoneModels.includes(nextName)) return;
const filters = importedFiltersToPeqFilters(data.filters);
if (filters.length === 0) {
toast.error(eqUi.importFail ?? eqUi.toastAddPresetFail);
return;
}
const submitImport = async () => {
const existingIdx = headphoneModels.indexOf(nextName);
const isOverwrite = existingIdx >= 0;
const payload: PeqChangePayload = {
peqChange: buildPeqPresetBody(
{
name: nextName,
brand: data.brand,
model: data.model,
target: data.target,
form: data.form,
preamp: data.preamp,
autoPre: data.autoPre ?? 0,
canDel: 1,
},
filters,
),
};
skipPeqAutoSyncRef.current = true;
try {
await upgradePeqChange(payload);
if (api && !isDemoMode) {
const latest = await fetchEqSyncPeq(api, "importSharedEq");
applyPeqStateToUI(latest as { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number });
const targetIdx = latest.peq?.findIndex((item) => item.name === nextName) ?? -1;
if (targetIdx >= 0) {
setHeadphoneIdx(targetIdx);
updateSetting({ peqSelect: targetIdx });
}
} else if (isOverwrite) {
setPeqItems((prev) =>
prev.map((item, i) =>
i === existingIdx
? {
...item,
name: nextName,
brand: data.brand,
model: data.model,
target: data.target,
form: data.form,
filters,
preamp: data.preamp ?? 0,
autoPre: data.autoPre ?? 0,
canDel: 1,
}
: item,
),
);
setHeadphoneIdx(existingIdx);
} else {
setHeadphoneModels((prev) => [...prev, nextName]);
setPeqItems((prev) => [
...prev,
{
name: nextName,
brand: data.brand,
model: data.model,
target: data.target,
form: data.form,
filters,
preamp: data.preamp ?? 0,
autoPre: data.autoPre ?? 0,
canDel: 1,
},
]);
setHeadphoneIdx(headphoneModels.length);
}
toast.success(eqUi.importSuccess);
setIsShareDialogOpen(false);
} catch {
skipPeqAutoSyncRef.current = false;
toast.error(eqUi.importFail ?? eqUi.toastAddPresetFail);
}
};
await promptOverwriteIfNeeded(nextName, submitImport);
};
// msgCount 轮询更新 peqState 后,同步耳机列表与当前选中项
useEffect(() => {
if (!peqState?.peq) return;
const resolvedPeqSelect = peqState.peqSelect ?? deviceState?.peqSelect;
const key = buildPeqCatalogSyncKey(peqState, resolvedPeqSelect);
if (!key || key === lastPeqCatalogSyncKeyRef.current) return;
lastPeqCatalogSyncKeyRef.current = key;
syncPeqCatalogFromState(peqState, resolvedPeqSelect);
}, [peqState, deviceState?.peqSelect, syncPeqCatalogFromState]);
// 切换耳机时默认回到 A 组对比
useEffect(() => {
setAbMode("A");
}, [headphoneIdx]);
// 加载耳机列表并初始化曲线
useEffect(() => {
async function loadHeadphones() {
peqHydrationPendingRef.current = true;
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 fetchEqSyncPeq(api, "loadHeadphones");
if (loadedPeq.peq && loadedPeq.peq.length > 0) {
const mergedPeq = mergeRemotePeqCatalog(loadedPeq.peq);
setHeadphoneModels(mergedPeq.map((h) => h.name));
setPeqItems(mergedPeq);
const nextIdx = Math.min(
Math.max(deviceState?.peqSelect ?? loadedPeq.peqSelect ?? 0, 0),
mergedPeq.length - 1,
);
setHeadphoneIdx(nextIdx);
lastPeqCatalogSyncKeyRef.current = buildPeqCatalogSyncKey(loadedPeq, deviceState?.peqSelect);
// 初始化当前选中的耳机曲线
const currentPeq = mergedPeq[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;
skipPeqAutoSyncRef.current = true;
queueMicrotask(() => {
peqHydrationPendingRef.current = false;
});
}
}
loadHeadphones();
}, [api, isDemoMode, loadRawCurveForPeq, mergeRemotePeqCatalog, deviceState?.peqSelect]);
/** 切换耳机型号时变;不含 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 });
const shouldFetchModelCurve = !!(peq.brand?.trim() && peq.model?.trim());
if (shouldFetchModelCurve) {
setRawCurveLoading(true);
setCurrentRawCurve(null);
} else {
setRawCurveLoading(false);
}
let cancelled = false;
void (async () => {
const raw = await loadRawCurveForPeq(peq as { brand?: string; model?: string });
if (cancelled) return;
setCurrentRawCurve(raw);
setRawCurveLoading(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;
brand?: string;
model?: string;
target?: string;
form?: string;
} | undefined,
filtersSource: typeof DEFAULT_BANDS,
mode: "A" | "B",
delay = 320,
topLevel: PeqSubmitTopLevel = "byMode",
catalogIdx = headphoneIdx,
) => {
if (!allowPeqRemoteSyncRef.current) return;
if (peqHydrationPendingRef.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 = buildPeqPresetBody(peq, filters);
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→peqChangeB→peqApply(不因切换 A/B 误触发)
useEffect(() => {
if (peqHydrationPendingRef.current) return;
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 band = bands[selectedBand] ?? DEFAULT_BANDS[selectedBand] ?? DEFAULT_BANDS[0];
useEffect(() => {
setBandSliderPreview(null);
}, [selectedBand, abMode, headphoneSwitchKey]);
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: buildPeqPresetBody(
{
name: peq.name,
brand: peq.brand,
model: peq.model,
target: peq.target,
form: peq.form,
autoPre: peq.autoPre,
preamp: peq.preamp,
canDel: peq.canDel,
},
filters,
),
};
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);
toast.success(eqUi.toastApplyBSuccess);
} catch {
skipPeqAutoSyncRef.current = false;
toast.error(eqUi.toastApplyBFail);
}
}, [
bandsByMode.B,
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 clearBandSliderPreviewField = useCallback((kind: BandParamKind) => {
setBandSliderPreview((prev) => {
if (prev?.[kind] === undefined) return prev;
const next = { ...prev };
delete next[kind];
return next.freq !== undefined || next.gain !== undefined || next.q !== undefined ? next : null;
});
}, []);
const beginBandSliderDrag = useCallback((kind: BandParamKind) => {
bandSliderDragRef.current = kind;
}, []);
const endBandSliderDrag = useCallback(
(kind: BandParamKind) => {
if (bandSliderDragRef.current !== kind) return;
bandSliderDragRef.current = null;
clearBandSliderPreviewField(kind);
},
[clearBandSliderPreviewField],
);
const handleBandSliderChange = useCallback(
(kind: BandParamKind, value: number) => {
if (bandSliderDragRef.current !== kind) return;
setBandSliderPreview((prev) => ({ ...prev, [kind]: value }));
updateBand(selectedBand, { [kind]: value });
},
[selectedBand, updateBand],
);
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;
}
bandSliderDragRef.current = null;
if (bandParamDialog === "freq") updateBand(selectedBand, { freq: parsed.value });
else if (bandParamDialog === "gain") updateBand(selectedBand, { gain: parsed.value });
else updateBand(selectedBand, { q: parsed.value });
setBandSliderPreview(null);
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, "byMode", headphoneIdx);
};
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">{eqPageTitle}</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: 批量编辑 / 添加耳机型号 / 分享EQ ── */}
<div className="grid grid-cols-3 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>
<button
className="ios-list-group flex flex-col items-center justify-center py-3 gap-2 active:opacity-70 transition-opacity"
onClick={() => {
setShareDialogKey((k) => k + 1);
setIsShareDialogOpen(true);
}}>
<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)" }}>
<Share2 size={20} style={{ color: "#00FFF6" }} />
</div>
<span className="text-[14px] text-white/80">{peqCardLabels.share}</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={openPresetManageDialog}
>
<FilePenLine size={18} />
</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={handleDeleteHeadphone}
>
<Trash2 size={18} />
</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();
}}
>
<Copy size={18} />
</button>
</div>
</div>
{/* ── Frequency response chart ── */}
<FreqChart
bands={bands}
rawCurve={currentRawCurve}
rawCurveLoading={rawCurveLoading}
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}
onInteractionStart={() => beginBandSliderDrag("freq")}
onInteractionEnd={() => endBandSliderDrag("freq")}
onChange={(v) => handleBandSliderChange("freq", Math.round(Math.pow(10, v)))}
/>
</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}
onInteractionStart={() => beginBandSliderDrag("gain")}
onInteractionEnd={() => endBandSliderDrag("gain")}
onChange={(v) => handleBandSliderChange("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}
onInteractionStart={() => beginBandSliderDrag("q")}
onInteractionEnd={() => endBandSliderDrag("q")}
onChange={(v) => handleBandSliderChange("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
open={!!bandParamDialog && !!bandParamDialogMeta}
meta={bandParamDialogMeta}
input={bandParamInput}
onInputChange={setBandParamInput}
onConfirm={applyBandParamDialog}
onClose={closeBandParamDialog}
eqUi={eqUi}
/>
<SaveBDialog
open={isSaveBDialogOpen}
presetName={saveBPresetName}
onNameChange={setSaveBPresetName}
onSave={() => void handleSaveBAsPreset()}
onClose={() => setIsSaveBDialogOpen(false)}
eqUi={eqUi}
/>
<AddPresetDialog
open={isAddPresetDialogOpen}
mode={addPresetMode}
copyName={copyPresetName}
flatName={flatPresetName}
onModeChange={setAddPresetMode}
onCopyNameChange={setCopyPresetName}
onFlatNameChange={setFlatPresetName}
onSave={handleSaveAddPreset}
onClose={() => setIsAddPresetDialogOpen(false)}
eqUi={eqUi}
/>
<BatchEditDialog
open={isBatchEditDialogOpen}
text={batchEditText}
onChangeText={setBatchEditText}
onSave={handleSaveBatchEdit}
onClose={() => setIsBatchEditDialogOpen(false)}
eqUi={eqUi}
/>
<BrandDrawer
key={`brand-drawer-${brandDrawerKey}`}
open={isBrandDrawerOpen}
onClose={() => setIsBrandDrawerOpen(false)}
eqUi={eqUi}
onConfirm={async (brand, name, target, form) => {
const decoded = await fetchLuxsinAudioCurve(brand, name, target);
let parsed: unknown = decoded;
try {
parsed = JSON.parse(decoded);
} catch {
// Keep raw decoded text when payload isn't JSON.
}
const parsedObj = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
const parametricEqRaw = parsedObj?.parametric_eq;
if (!parametricEqRaw || typeof parametricEqRaw !== "object") {
throw new Error("parametric_eq missing");
}
const parametricEq = parametricEqRaw as {
filters?: Array<{ type: string | number; fc: number; gain: number; q: number }>;
preamp?: number;
};
const filters = (Array.isArray(parametricEq.filters) ? parametricEq.filters : [])
.slice(0, 10)
.map((item) => ({
type: getFilterType(item.type),
fc: Number(Number(item.fc).toFixed(2)),
gain: Number(Number(item.gain).toFixed(2)),
q: Number(Number(item.q).toFixed(2)),
}));
const createdName = `${brand} ${name}`;
const postPeq: PeqChangePayload = {
peqChange: buildPeqPresetBody(
{
name: createdName,
brand,
model: name,
target,
form,
preamp: Number(Number(parametricEq.preamp ?? 0).toFixed(2)),
autoPre: 0,
canDel: 1,
},
filters,
),
};
const submitCatalogPreset = async () => {
const existingIdx = headphoneModels.indexOf(createdName);
const isOverwrite = existingIdx >= 0;
if (!isOverwrite && !guardPeqPresetCapacity()) return;
skipPeqAutoSyncRef.current = true;
try {
await upgradePeqChange(postPeq);
if (api && !isDemoMode) {
const latest = await fetchEqSyncPeq(api, "addCatalogPreset");
applyPeqStateToUI(latest as { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number });
const targetIdx = latest.peq?.findIndex((item) => item.name === createdName) ?? -1;
if (targetIdx >= 0) {
setHeadphoneIdx(targetIdx);
updateSetting({ peqSelect: targetIdx });
}
} else if (isOverwrite) {
setPeqItems((prev) =>
prev.map((item, i) =>
i === existingIdx
? {
...item,
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,
}
: item,
),
);
setHeadphoneIdx(existingIdx);
} 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);
} catch {
skipPeqAutoSyncRef.current = false;
toast.error(eqUi.toastAddPresetFail);
}
};
if (headphoneModels.includes(createdName)) {
setIsBrandDrawerOpen(false);
}
return promptOverwriteIfNeeded(createdName, submitCatalogPreset);
}}
/>
<PeqOverwriteConfirmDialog
open={deleteHeadphoneDialog !== null}
title={eqUi.deletePreset}
description={
deleteHeadphoneDialog
? eqInterp(eqUi.deleteConfirm, { name: deleteHeadphoneDialog.name })
: ""
}
cancelLabel={eqUi.cancel}
confirmLabel={eqUi.deletePreset}
variant="destructive"
onConfirm={() => {
void confirmDeleteHeadphone();
}}
onCancel={() => setDeleteHeadphoneDialog(null)}
/>
<PeqOverwriteConfirmDialog
open={!!overwritePresetDialog}
title={eqUi.overwritePresetTitle ?? "Preset name already exists"}
description={
overwritePresetDialog
? eqInterp(eqUi.overwritePresetDesc, { name: overwritePresetDialog.name })
: ""
}
cancelLabel={eqUi.cancel}
confirmLabel={eqUi.overwritePresetConfirm ?? eqUi.confirmButton ?? eqUi.save}
onConfirm={() => {
void overwritePresetDialog?.onConfirm();
}}
onCancel={() => {
overwritePresetDialog?.onDismiss();
}}
/>
<PeqPresetManageDialog
open={isPresetManageDialogOpen}
items={peqItems}
selectedIdx={headphoneIdx}
reorderEnabled={peqReorderEnabled}
onClose={() => setIsPresetManageDialogOpen(false)}
eqUi={eqUi}
onDeletePresets={async (names) => {
try {
if (isDemoMode || !api) {
const nextItems = peqItems.filter(it => !names.includes(it.name));
names.forEach(n => delete peqPresetCacheRef.current[n]);
applyPeqStateToUI({ peq: nextItems, peqSelect: Math.min(headphoneIdx, Math.max(0, nextItems.length - 1)) });
toast.success(eqUi.toastDeleted);
return true;
}
names.forEach(n => delete peqPresetCacheRef.current[n]);
await api.removePeq(names);
const latest = await fetchEqSyncPeq(api, "deletePresets");
applyPeqStateToUI(latest);
toast.success(eqUi.toastDeleted);
return true;
} catch {
toast.error(eqUi.toastDeleteFail);
return false;
}
}}
onRenamePreset={async (oldName, newName, item) => {
try {
if (isDemoMode || !api) {
const nextItems = peqItems.map(it =>
it.name === oldName ? { ...it, name: newName } : it
);
delete peqPresetCacheRef.current[oldName];
applyPeqStateToUI({ peq: nextItems, peqSelect: headphoneIdx });
toast.success(eqUi.toastRenamed);
return true;
}
const firmwareVersion = parseFirmwareVersion(deviceState?.version);
if (supportsPeqPresetRenameAndSort(firmwareVersion)) {
await api.renamePeq(oldName, newName);
delete peqPresetCacheRef.current[oldName];
const latest = await fetchEqSyncPeq(api, "renamePreset");
applyPeqStateToUI(latest);
toast.success(eqUi.toastRenamed);
return true;
}
const rawFilters = normalizePeqFiltersForSubmit(item.filters);
const filters = rawFilters.map(f => ({
...f,
type: getFilterType(f.type),
}));
const payload: PeqChangePayload = {
peqChange: {
name: newName,
filters,
autoPre: item.autoPre,
preamp: item.preamp,
canDel: item.canDel ?? 1,
brand: item.brand,
model: item.model,
target: item.target,
form: item.form,
},
};
// Legacy (< 29): peqChange then peqRemove
await api.upgradePeqChange(payload);
await new Promise(r => setTimeout(r, 300));
delete peqPresetCacheRef.current[oldName];
await api.removePeq([oldName]);
await new Promise(r => setTimeout(r, 300));
const latest = await fetchEqSyncPeq(api, "renamePreset");
applyPeqStateToUI(latest);
toast.success(eqUi.toastRenamed);
return true;
} catch {
toast.error(eqUi.toastRenameFail);
return false;
}
}}
onConfirmOrder={async (names, peqSelect) => {
try {
if (isDemoMode || !api) {
const byName = new Map(peqItems.map(it => [it.name, it]));
const nextItems = names
.map(name => byName.get(name))
.filter((it): it is NonNullable<typeof it> => !!it);
applyPeqStateToUI({ peq: nextItems, peqSelect });
toast.success(eqUi.toastOrderSaved);
return true;
}
await api.sortPeq(names, peqSelect);
const latest = await fetchEqSyncPeq(api, "sortPresets");
const nextSelect = latest.peqSelect ?? peqSelect;
applyPeqStateToUI({ ...latest, peqSelect: nextSelect });
updateSetting({ peqSelect: nextSelect });
toast.success(eqUi.toastOrderSaved);
return true;
} catch {
toast.error(eqUi.toastOrderFail);
return false;
}
}}
/>
<ShareDialog
key={`share-dialog-${shareDialogKey}`}
open={isShareDialogOpen}
onClose={() => setIsShareDialogOpen(false)}
peqItems={peqItems}
headphoneIdx={headphoneIdx}
eqUi={eqUi}
peqCardLabels={peqCardLabels}
mac={deviceState?.mac ?? ""}
device={deviceState?.device ?? ""}
isDemoMode={isDemoMode}
onImportEq={handleImportSharedEq}
/>
<BottomNav />
</div>
);
}