2009 lines
72 KiB
TypeScript
2009 lines
72 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, 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→peqChange,B→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>
|
||
);
|
||
}
|