refactor(eq): 重构EQ页面及相关代码以优化PEQ管理与同步
- 禁用Vite配置中的manus debug collector插件,减少无用请求 - 对DeviceContext添加syncPeqCatalog方法用于同步PEQ状态 - 重构luxsinApi.ts中PEQ请求相关代码,统一POST请求封装,增强错误处理 - 将EQPage相关常量、工具和组件抽取至独立模块,简化主文件结构 - 使用缓存机制记忆预设滤波器列表,减少不必要的重复计算 - 优化PEQ状态同步逻辑,支持按模式(A/B)分别保存与应用滤波器 - 实现选择耳机型号时自动保存并更新对应滤波器缓存和显示 - 修改A/B切换逻辑,使用防抖同步和固定的peqApply调用 - 为音量滑块添加thumb-only滑动样式及拖拽区域限制,提升交互体验 - Home页面ListRow组件支持显示自定义缩略图 - 增加eq/constants.ts定义滤波器类型、参数范围和样式,统一管理配置 - 解决多处EQ页面组件交互和状态切换的潜在同步问题,提高代码维护性和可读性
This commit is contained in:
@@ -31,6 +31,8 @@ interface DeviceContextType {
|
||||
updatePeq: (filters: PeqFilter[]) => Promise<void>;
|
||||
upgradePeqChange: (payload: PeqChangePayload) => Promise<void>;
|
||||
upgradePeqApply: (payload: PeqApplyPayload) => Promise<void>;
|
||||
/** Align global peqState with device syncPeq (e.g. after EQ catalog add/delete). */
|
||||
syncPeqCatalog: (state: PeqState) => void;
|
||||
// Optimistic state updaters
|
||||
setVolume: (v: number) => void;
|
||||
setInput: (v: number) => void;
|
||||
@@ -72,6 +74,10 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) {
|
||||
setPeqState(peq);
|
||||
}, []);
|
||||
|
||||
const syncPeqCatalog = useCallback((state: PeqState) => {
|
||||
setPeqState(state);
|
||||
}, []);
|
||||
|
||||
const connect = useCallback(async (forceDemo?: boolean, connectIp?: string) => {
|
||||
const useDemo = forceDemo !== undefined ? forceDemo : isDemoMode;
|
||||
const targetIp = (connectIp ?? ip).trim();
|
||||
@@ -263,7 +269,7 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) {
|
||||
isConnected, isConnecting, isDemoMode, setDemoMode,
|
||||
deviceState, peqState, lastUpdated, error,
|
||||
connect, disconnect, refresh, api,
|
||||
updateSetting, updatePeq, upgradePeqChange, upgradePeqApply,
|
||||
updateSetting, updatePeq, upgradePeqChange, upgradePeqApply, syncPeqCatalog,
|
||||
setVolume, setInput, setOutput, setBalance,
|
||||
}}>
|
||||
{children}
|
||||
|
||||
@@ -315,6 +315,20 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Home volume: track clicks disabled; only thumb is draggable */
|
||||
.cyan-slider-thumb-only {
|
||||
cursor: default;
|
||||
}
|
||||
.cyan-slider-thumb-only::-webkit-slider-thumb {
|
||||
cursor: grab;
|
||||
}
|
||||
.cyan-slider-thumb-only::-webkit-slider-thumb:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.cyan-slider-thumb-only::-moz-range-thumb {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
/* ── Round icon button (like the 4 quick-action circles on home) ── */
|
||||
.icon-circle-btn {
|
||||
width: 64px;
|
||||
|
||||
+20
-17
@@ -260,13 +260,7 @@ export class LuxsinAPI {
|
||||
}
|
||||
|
||||
async setPeqFilters(filters: PeqFilter[]): Promise<void> {
|
||||
const payload = JSON.stringify({ peqChange: { filters } });
|
||||
const encoded = encodeCustomBase64(payload);
|
||||
await fetch(`${this.baseUrl}/dev/info.cgi`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: `json=${encodeURIComponent(encoded)}`,
|
||||
});
|
||||
await this.postPeqJson({ peqChange: { filters } } as PeqChangePayload);
|
||||
}
|
||||
|
||||
/** Full peq preset: name, filters, autoPre, preamp, canDel — same encoding as legacy `upgradePeq`. */
|
||||
@@ -274,30 +268,39 @@ export class LuxsinAPI {
|
||||
await this.postPeqJson(body);
|
||||
}
|
||||
|
||||
/** Apply current EQ filters (e.g. A/B curve switch) without a full preset save. */
|
||||
/** Apply current EQ filters (e.g. A/B comparison curve) without saving preset metadata. */
|
||||
async upgradePeqApply(body: PeqApplyPayload): Promise<void> {
|
||||
await this.postPeqJson(body);
|
||||
}
|
||||
|
||||
/** POST `json=<custom-base64>` — matches legacy axios `upgradePeq`. */
|
||||
private async postPeqJson(body: PeqChangePayload | PeqApplyPayload): Promise<void> {
|
||||
const payload = JSON.stringify(body);
|
||||
const encoded = encodeCustomBase64(payload);
|
||||
await fetch(`${this.baseUrl}/dev/info.cgi`, {
|
||||
const encoded = encodeCustomBase64(JSON.stringify(body));
|
||||
const form = new URLSearchParams();
|
||||
form.set("json", encoded);
|
||||
const response = await fetch(`${this.baseUrl}/dev/info.cgi`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: `json=${encodeURIComponent(encoded)}`,
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
|
||||
body: form.toString(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`PEQ request failed: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove one or more headphone PEQ profiles. */
|
||||
async removePeq(names: string[]): Promise<void> {
|
||||
const payload = JSON.stringify({ peqRemove: names });
|
||||
const encoded = encodeCustomBase64(payload);
|
||||
await fetch(`${this.baseUrl}/dev/info.cgi`, {
|
||||
const encoded = encodeCustomBase64(JSON.stringify({ peqRemove: names }));
|
||||
const form = new URLSearchParams();
|
||||
form.set("json", encoded);
|
||||
const response = await fetch(`${this.baseUrl}/dev/info.cgi`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
|
||||
body: `json=${encodeURIComponent(encoded)}`,
|
||||
body: form.toString(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`PEQ remove failed: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
setVolume(volume: number) { return this.setSetting({ volume }); }
|
||||
|
||||
+200
-261
@@ -47,140 +47,39 @@ import {
|
||||
import localeZh from "@/locales/data-zh.json";
|
||||
import localeZhHK from "@/locales/data-zh-HK.json";
|
||||
import localeEn from "@/locales/data-en.json";
|
||||
import {
|
||||
FILTER_TYPES,
|
||||
BAND_FREQ_MIN,
|
||||
BAND_FREQ_MAX,
|
||||
BAND_GAIN_MIN,
|
||||
BAND_GAIN_MAX,
|
||||
BAND_Q_MIN,
|
||||
BAND_Q_MAX,
|
||||
BAND_PARAM_VALUE_BOX_STYLE,
|
||||
DEFAULT_BANDS,
|
||||
FLAT_PRESET_FILTERS,
|
||||
CATALOG_TARGETS,
|
||||
type BandParamKind,
|
||||
type PeqEqUi,
|
||||
type PeqCatalogItem,
|
||||
type CatalogTarget,
|
||||
} from "./eq/constants";
|
||||
import {
|
||||
eqInterp,
|
||||
formatBandFreqDisplay,
|
||||
formatBandParamForInput,
|
||||
parseBandParamInput,
|
||||
normalizeFilterType,
|
||||
cloneBands,
|
||||
freqLabel,
|
||||
buildPeqCatalogSyncKey,
|
||||
getUniquePresetName,
|
||||
bandToPeqFilter,
|
||||
} from "./eq/utils";
|
||||
import { IOSToggle } from "./eq/components/IOSToggle";
|
||||
import { CyanSlider } from "./eq/components/CyanSlider";
|
||||
|
||||
type PeqEqUi = NonNullable<NonNullable<(typeof localeZh)["peq"]>["eqUi"]>;
|
||||
|
||||
function eqInterp(template: string | undefined, vars: Record<string, string | number>): string {
|
||||
if (!template) return "";
|
||||
let s = template;
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
s = s.split(`{{${k}}}`).join(String(v));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/* ── iOS Toggle ── */
|
||||
function IOSToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<label className="ios-toggle" onClick={(e) => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
||||
<span className="ios-toggle-track"><span className="ios-toggle-thumb" /></span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Cyan Slider ── */
|
||||
function CyanSlider({ value, min, max, step = 1, onChange, disabled = false }: {
|
||||
value: number; min: number; max: number; step?: number; onChange: (v: number) => void; disabled?: boolean;
|
||||
}) {
|
||||
const fillPct = Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100));
|
||||
return (
|
||||
<div className={`relative flex items-center w-full mt-2 ${disabled ? "opacity-60" : ""}`}>
|
||||
<div className="absolute left-0 h-[4px] rounded-full pointer-events-none"
|
||||
style={{
|
||||
width: `${fillPct}%`,
|
||||
background: disabled ? "rgba(148,163,184,0.7)" : "#00FFF6",
|
||||
boxShadow: disabled ? "none" : "0 0 6px rgba(0,255,246,0.45)",
|
||||
}} />
|
||||
<input type="range" min={min} max={max} step={step} value={value}
|
||||
className="cyan-slider relative z-10"
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(Number(e.target.value))} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Constants ── */
|
||||
const FILTER_TYPES = ["LPF", "HPF", "BPF", "NOTCH", "PEAK", "LSHELF", "HSHELF", "APF"];
|
||||
const BAND_FREQ_MIN = 20;
|
||||
const BAND_FREQ_MAX = 20000;
|
||||
const BAND_GAIN_MIN = -15;
|
||||
const BAND_GAIN_MAX = 15;
|
||||
const BAND_Q_MIN = 0.1;
|
||||
const BAND_Q_MAX = 10;
|
||||
|
||||
type BandParamKind = "freq" | "gain" | "q";
|
||||
|
||||
const BAND_PARAM_VALUE_BOX_STYLE: React.CSSProperties = {
|
||||
background: "rgba(44,44,46,0.9)",
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
};
|
||||
|
||||
function formatBandFreqDisplay(freq: number) {
|
||||
return freq >= 1000
|
||||
? `${(freq / 1000).toFixed(2).replace(/\.?0+$/, "")} kHz`
|
||||
: `${freq} Hz`;
|
||||
}
|
||||
|
||||
function formatBandParamForInput(kind: BandParamKind, band: { freq: number; gain: number; q: number }) {
|
||||
switch (kind) {
|
||||
case "freq":
|
||||
return String(band.freq);
|
||||
case "gain":
|
||||
return band.gain.toFixed(1);
|
||||
case "q":
|
||||
return band.q.toFixed(2);
|
||||
}
|
||||
}
|
||||
|
||||
function parseBandParamInput(
|
||||
kind: BandParamKind,
|
||||
raw: string,
|
||||
messages: { invalid: string; outOfRange: string },
|
||||
): { ok: true; value: number } | { ok: false; message: string } {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return { ok: false, message: messages.invalid };
|
||||
|
||||
if (kind === "freq") {
|
||||
let s = trimmed.replace(/\s+/g, "").toLowerCase().replace(/hz$/, "");
|
||||
const kHz = /k(hz)?$/.test(s);
|
||||
if (kHz) s = s.replace(/k(hz)?$/, "");
|
||||
const num = Number.parseFloat(s);
|
||||
if (!Number.isFinite(num)) return { ok: false, message: messages.invalid };
|
||||
const hz = Math.round(kHz ? num * 1000 : num);
|
||||
if (hz < BAND_FREQ_MIN || hz > BAND_FREQ_MAX) {
|
||||
return { ok: false, message: messages.outOfRange };
|
||||
}
|
||||
return { ok: true, value: hz };
|
||||
}
|
||||
|
||||
if (kind === "gain") {
|
||||
const s = trimmed.replace(/\s*dB\s*$/i, "").trim();
|
||||
const num = Number.parseFloat(s);
|
||||
if (!Number.isFinite(num)) return { ok: false, message: messages.invalid };
|
||||
const gain = Number(num.toFixed(1));
|
||||
if (gain < BAND_GAIN_MIN || gain > BAND_GAIN_MAX) {
|
||||
return { ok: false, message: messages.outOfRange };
|
||||
}
|
||||
return { ok: true, value: gain };
|
||||
}
|
||||
|
||||
const num = Number.parseFloat(trimmed);
|
||||
if (!Number.isFinite(num)) return { ok: false, message: messages.invalid };
|
||||
const q = Number(num.toFixed(2));
|
||||
if (q < BAND_Q_MIN || q > BAND_Q_MAX) {
|
||||
return { ok: false, message: messages.outOfRange };
|
||||
}
|
||||
return { ok: true, value: q };
|
||||
}
|
||||
|
||||
function normalizeFilterType(type: string | number | undefined): string {
|
||||
if (type === undefined || type === null) return "PEAK";
|
||||
if (typeof type === "number") {
|
||||
switch (type) {
|
||||
case 0: return "LPF";
|
||||
case 1: return "HPF";
|
||||
case 2: return "BPF";
|
||||
case 3: return "NOTCH";
|
||||
case 4: return "PEAK";
|
||||
case 5: return "LSHELF";
|
||||
case 6: return "HSHELF";
|
||||
case 7: return "APF";
|
||||
default: return "PEAK";
|
||||
}
|
||||
}
|
||||
return getFilterShortName(type);
|
||||
}
|
||||
|
||||
/* ── Frequency Response Chart ── */
|
||||
function FreqChart({
|
||||
@@ -189,7 +88,7 @@ function FreqChart({
|
||||
selectedBand,
|
||||
abMode,
|
||||
onAbToggle,
|
||||
onCopyMode,
|
||||
onCopyAndSwitchTo,
|
||||
onApplyB,
|
||||
onSaveB,
|
||||
onBandDrag,
|
||||
@@ -201,7 +100,7 @@ function FreqChart({
|
||||
selectedBand: number;
|
||||
abMode: "A" | "B";
|
||||
onAbToggle: (m: "A" | "B") => void;
|
||||
onCopyMode: (from: "A" | "B", to: "A" | "B") => void;
|
||||
onCopyAndSwitchTo: (to: "A" | "B") => void;
|
||||
onApplyB: () => void;
|
||||
onSaveB: () => void;
|
||||
onBandDrag: (idx: number, patch: Partial<{ freq: number; gain: number }>) => void;
|
||||
@@ -421,12 +320,11 @@ function FreqChart({
|
||||
className="px-3 py-1 rounded-[8px] text-[11px] text-white/50 active:text-white transition-colors"
|
||||
style={{ background: "rgba(44,44,46,0.7)", border: "1px solid rgba(255,255,255,0.08)" }}
|
||||
onClick={() => {
|
||||
onCopyMode(abMode, targetMode);
|
||||
onCopyAndSwitchTo(targetMode);
|
||||
setCurveVisibilityByMode((prev) => ({
|
||||
...prev,
|
||||
[targetMode]: { ...prev[abMode] },
|
||||
}));
|
||||
onAbToggle(targetMode);
|
||||
toast.success(eqInterp(eqUi.toastChartCopied, { mode: targetMode }));
|
||||
}}>
|
||||
{copyButtonText}
|
||||
@@ -584,102 +482,22 @@ function FreqChart({
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Default bands matching reference image ── */
|
||||
const DEFAULT_BANDS = [
|
||||
{ freq: 9500, gain: 0, q: 1.41, type: "LSHELF", enabled: true },
|
||||
{ freq: 9200, gain: -2, q: 1.41, type: "PEAK", enabled: true },
|
||||
{ freq: 220, gain: 1, q: 1.41, type: "PEAK", enabled: true },
|
||||
{ freq: 500, gain: -3, q: 1.41, type: "PEAK", enabled: true },
|
||||
{ freq: 1200, gain: 0, q: 1.41, type: "PEAK", enabled: true },
|
||||
{ freq: 13800, gain: -1, q: 1.41, type: "NOTCH", enabled: true },
|
||||
{ freq: 11000, gain: -2, q: 1.41, type: "PEAK", enabled: true },
|
||||
{ freq: 7400, gain: 1, q: 1.41, type: "PEAK", enabled: true },
|
||||
{ freq: 8200, gain: -1, q: 1.41, type: "PEAK", enabled: true },
|
||||
{ freq: 10000, gain: 0, q: 1.41, type: "HSHELF", enabled: true },
|
||||
];
|
||||
/* ── Default bands imported from eq/constants ── */
|
||||
|
||||
function cloneBands(
|
||||
source: Array<{ freq: number; gain: number; q: number; type: string; enabled: boolean }>,
|
||||
) {
|
||||
return source.map((band) => ({ ...band }));
|
||||
}
|
||||
|
||||
function freqLabel(f: number) {
|
||||
return f >= 1000 ? `${(f / 1000).toFixed(1)}K` : `${f}`;
|
||||
}
|
||||
|
||||
type PeqCatalogItem = NonNullable<PeqState["peq"]>[number];
|
||||
|
||||
function buildPeqCatalogSyncKey(
|
||||
remote: Pick<PeqState, "peq" | "peqSelect"> | null | undefined,
|
||||
devicePeqSelect?: number,
|
||||
): string {
|
||||
const items = remote?.peq;
|
||||
if (!items?.length) return items ? "empty" : "";
|
||||
const select = devicePeqSelect ?? remote?.peqSelect ?? 0;
|
||||
return `${select}|${items.map((p) => p.name).join("\u0001")}`;
|
||||
}
|
||||
|
||||
function getUniquePresetName(base: string, existingNames: string[]) {
|
||||
if (!existingNames.includes(base)) return base;
|
||||
let index = 1;
|
||||
while (existingNames.includes(`${base}_${index}`)) {
|
||||
index += 1;
|
||||
}
|
||||
return `${base}_${index}`;
|
||||
}
|
||||
|
||||
const FLAT_PRESET_FILTERS: PeqFilter[] = [
|
||||
{ type: 4, fc: 80, gain: 0, q: 0.1 },
|
||||
{ type: 4, fc: 150, gain: 0, q: 0.1 },
|
||||
{ type: 4, fc: 350, gain: 0, q: 0.1 },
|
||||
{ type: 4, fc: 750, gain: 0, q: 0.1 },
|
||||
{ type: 4, fc: 1500, gain: 0, q: 0.1 },
|
||||
{ type: 4, fc: 3000, gain: 0, q: 0.1 },
|
||||
{ type: 4, fc: 6000, gain: 0, q: 0.1 },
|
||||
{ type: 4, fc: 10000, gain: 0, q: 0.1 },
|
||||
{ type: 4, fc: 14000, gain: 0, q: 0.1 },
|
||||
{ type: 4, fc: 18000, gain: 0, q: 0.1 },
|
||||
];
|
||||
|
||||
type CatalogTarget = {
|
||||
name: string;
|
||||
bassBoost: { fc: number; q: number; gain: number };
|
||||
ear: "in" | "over" | "all";
|
||||
};
|
||||
|
||||
const CATALOG_TARGETS: CatalogTarget[] = [
|
||||
{ name: "Harman over-ear 2018", bassBoost: { fc: 105, q: 0.7, gain: 6 }, ear: "over" },
|
||||
{ name: "HMS II.3 Harman over-ear 2018", bassBoost: { fc: 105, q: 0.7, gain: 6 }, ear: "over" },
|
||||
{ name: "crinacle EARS + 711 Harman over-ear 2018", bassBoost: { fc: 105, q: 0.7, gain: 6 }, ear: "over" },
|
||||
{ name: "Harman in-ear 2019", bassBoost: { fc: 105, q: 0.7, gain: 9.5 }, ear: "in" },
|
||||
{ name: "AutoEq in-ear", bassBoost: { fc: 105, q: 0.7, gain: 8 }, ear: "in" },
|
||||
{ name: "HMS II.3 AutoEq in-ear", bassBoost: { fc: 105, q: 0.7, gain: 8 }, ear: "in" },
|
||||
{ name: "HMS II.3 Harman in-ear 2019", bassBoost: { fc: 105, q: 0.7, gain: 9.5 }, ear: "in" },
|
||||
{ name: "Diffuse Field 5128 (-1 dB/oct)", bassBoost: { fc: 105, q: 0.7, gain: 0 }, ear: "over" },
|
||||
{ name: "LMG 5128 0.6 without bass", bassBoost: { fc: 105, q: 0.7, gain: 6 }, ear: "over" },
|
||||
{ name: "JM-1 with Harman filters", bassBoost: { fc: 105, q: 0.7, gain: 6.5 }, ear: "all" },
|
||||
{ name: "oratory1990 in-ear", bassBoost: { fc: 105, q: 0.7, gain: 9.5 }, ear: "in" },
|
||||
{ name: "oratory1990 over-ear", bassBoost: { fc: 105, q: 0.7, gain: 6 }, ear: "over" },
|
||||
{ name: "Harman over-ear 2013", bassBoost: { fc: 105, q: 0.7, gain: 6 }, ear: "over" },
|
||||
{ name: "Flat", bassBoost: { fc: 105, q: 0.7, gain: 0 }, ear: "all" },
|
||||
];
|
||||
|
||||
/** UI band row → device `PeqFilter` (fc + numeric type), same as legacy `getFilterVal` mapping via `getFilterType`. */
|
||||
function bandToPeqFilter(b: { freq: number; gain: number; q: number; type: string | number }): PeqFilter {
|
||||
return {
|
||||
fc: b.freq,
|
||||
gain: b.gain,
|
||||
q: b.q,
|
||||
type: getFilterType(b.type),
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Main Component ── */
|
||||
export default function EQPage() {
|
||||
const [, setLocation] = useLocation();
|
||||
const { deviceState, peqState, updateSetting, api, isDemoMode, upgradePeqChange, upgradePeqApply } =
|
||||
useDevice();
|
||||
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;
|
||||
@@ -755,7 +573,9 @@ export default function EQPage() {
|
||||
const lastPeqCatalogSyncKeyRef = useRef("");
|
||||
const syncingHeadphoneRef = useRef(false);
|
||||
const peqSyncTimerRef = useRef<number | null>(null);
|
||||
const peqFiltersCacheRef = useRef<Record<string, PeqFilter[]>>({});
|
||||
const skipPeqAutoSyncRef = useRef(false);
|
||||
const skipBandsSyncFromAbToggleRef = useRef(false);
|
||||
const headphoneMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const filterMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const bands = bandsByMode[abMode];
|
||||
@@ -1034,18 +854,20 @@ export default function EQPage() {
|
||||
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: parsed.filters.map(bandToPeqFilter),
|
||||
filters,
|
||||
};
|
||||
rememberPeqFiltersForPreset(item.name, filters);
|
||||
updatedPeq = merged;
|
||||
return merged;
|
||||
})
|
||||
);
|
||||
setBands(parsed.filters);
|
||||
schedulePeqSync(updatedPeq, parsed.filters, 0);
|
||||
schedulePeqSync(updatedPeq, parsed.filters, abMode, 0, "byMode", headphoneIdx);
|
||||
setSelectedBand(0);
|
||||
setIsBatchEditDialogOpen(false);
|
||||
toast.success(eqUi.toastBatchApplied);
|
||||
@@ -1054,12 +876,32 @@ export default function EQPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const rememberPeqFiltersForPreset = useCallback(
|
||||
(presetName: string | undefined, filters: PeqFilter[]) => {
|
||||
if (!presetName || filters.length === 0) return;
|
||||
peqFiltersCacheRef.current[presetName] = filters;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const forgetPeqFiltersForPreset = useCallback((presetName: string | undefined) => {
|
||||
if (!presetName) return;
|
||||
delete peqFiltersCacheRef.current[presetName];
|
||||
}, []);
|
||||
|
||||
const mergeRemotePeqCatalog = useCallback((items: PeqCatalogItem[]) => {
|
||||
return items.map((item) => {
|
||||
const cached = peqFiltersCacheRef.current[item.name];
|
||||
return cached?.length ? { ...item, filters: cached } : item;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const syncPeqCatalogFromState = useCallback(
|
||||
(
|
||||
remote: { peq?: PeqCatalogItem[]; peqSelect?: number },
|
||||
devicePeqSelect?: number,
|
||||
) => {
|
||||
const items = remote.peq ?? [];
|
||||
const items = mergeRemotePeqCatalog(remote.peq ?? []);
|
||||
setPeqItems(items as typeof peqItems);
|
||||
setHeadphoneModels(items.map((item) => item.name));
|
||||
if (items.length === 0) {
|
||||
@@ -1072,12 +914,23 @@ export default function EQPage() {
|
||||
);
|
||||
setHeadphoneIdx(nextIdx);
|
||||
},
|
||||
[],
|
||||
[mergeRemotePeqCatalog],
|
||||
);
|
||||
|
||||
const applyPeqStateToUI = (
|
||||
remote: { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number },
|
||||
remote: {
|
||||
peq?: Array<{ name: string; filters?: any[] | string }>;
|
||||
peqSelect?: number;
|
||||
filters?: PeqFilter[];
|
||||
},
|
||||
) => {
|
||||
if (remote.peq !== undefined) {
|
||||
syncPeqCatalog({
|
||||
filters: remote.filters ?? peqState?.filters ?? [],
|
||||
peq: remote.peq,
|
||||
peqSelect: remote.peqSelect ?? deviceState?.peqSelect ?? peqState?.peqSelect,
|
||||
});
|
||||
}
|
||||
lastPeqCatalogSyncKeyRef.current = buildPeqCatalogSyncKey(remote, deviceState?.peqSelect);
|
||||
syncPeqCatalogFromState(remote, deviceState?.peqSelect);
|
||||
|
||||
@@ -1109,14 +962,16 @@ export default function EQPage() {
|
||||
try {
|
||||
if (isDemoMode || !api) {
|
||||
const nextItems = peqItems.filter((_, idx) => idx !== headphoneIdx);
|
||||
forgetPeqFiltersForPreset(target.name);
|
||||
applyPeqStateToUI({ peq: nextItems, peqSelect: Math.max(0, headphoneIdx - 1) });
|
||||
toast.success(eqUi.toastDeleted);
|
||||
return;
|
||||
}
|
||||
|
||||
forgetPeqFiltersForPreset(target.name);
|
||||
await api.removePeq([target.name]);
|
||||
const latest = await api.getPeqState();
|
||||
applyPeqStateToUI(latest as { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number });
|
||||
applyPeqStateToUI(latest);
|
||||
toast.success(eqUi.toastDeleted);
|
||||
} catch {
|
||||
toast.error(eqUi.toastDeleteFail);
|
||||
@@ -1376,17 +1231,18 @@ export default function EQPage() {
|
||||
try {
|
||||
const loadedPeq = await api.getPeqState();
|
||||
if (loadedPeq.peq && loadedPeq.peq.length > 0) {
|
||||
setHeadphoneModels(loadedPeq.peq.map((h) => h.name));
|
||||
setPeqItems(loadedPeq.peq);
|
||||
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),
|
||||
loadedPeq.peq.length - 1,
|
||||
mergedPeq.length - 1,
|
||||
);
|
||||
setHeadphoneIdx(nextIdx);
|
||||
lastPeqCatalogSyncKeyRef.current = buildPeqCatalogSyncKey(loadedPeq, deviceState?.peqSelect);
|
||||
|
||||
// 初始化当前选中的耳机曲线
|
||||
const currentPeq = loadedPeq.peq[nextIdx];
|
||||
const currentPeq = mergedPeq[nextIdx];
|
||||
if (currentPeq) {
|
||||
// 修复 filters 数据(兼容不同的字段名)
|
||||
const fixedFilters = normalizeFiltersFromPeq(currentPeq);
|
||||
@@ -1434,18 +1290,16 @@ export default function EQPage() {
|
||||
}
|
||||
}
|
||||
loadHeadphones();
|
||||
}, [api, isDemoMode, loadRawCurveForPeq]);
|
||||
}, [api, isDemoMode, loadRawCurveForPeq, mergeRemotePeqCatalog, deviceState?.peqSelect]);
|
||||
|
||||
/** 仅耳机/型号/filters 变化时变;preamp、autoPre 变化不触发,避免拖总增益时反复请求 modelCurve */
|
||||
const modelCurveReloadKey = useMemo(() => {
|
||||
/** 切换耳机型号时变;不含 filters,避免编辑写回 peqItems 后反复加载 bands 并触发 peqChange 循环 */
|
||||
const headphoneSwitchKey = useMemo(() => {
|
||||
const peq = peqItems[headphoneIdx];
|
||||
if (!peq) return "";
|
||||
const filtersKey =
|
||||
typeof peq.filters === "string" ? peq.filters : JSON.stringify(peq.filters ?? []);
|
||||
return [headphoneIdx, peq.name ?? "", peq.brand ?? "", peq.model ?? "", filtersKey].join("\u0001");
|
||||
if (!peq) return String(headphoneIdx);
|
||||
return [headphoneIdx, peq.name ?? "", peq.brand ?? "", peq.model ?? ""].join("\u0001");
|
||||
}, [headphoneIdx, peqItems]);
|
||||
|
||||
// 切换耳机型号后,立即用该型号 filters 刷新 10 个滤波器与曲线(暂停一次上报避免错写)
|
||||
// 切换耳机型号后,从 peqItems 加载该型号的 filters(暂停一次上报避免错写)
|
||||
useEffect(() => {
|
||||
const peq = peqItems[headphoneIdx];
|
||||
if (!peq) return;
|
||||
@@ -1467,12 +1321,61 @@ export default function EQPage() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [modelCurveReloadKey, loadRawCurveForPeq]);
|
||||
}, [headphoneSwitchKey, loadRawCurveForPeq]);
|
||||
|
||||
const schedulePeqSync = (
|
||||
type PeqSubmitTopLevel = "peqChange" | "peqApply" | "byMode";
|
||||
|
||||
const persistFiltersToPeqItem = useCallback(
|
||||
(catalogIdx: number, filtersSource: typeof DEFAULT_BANDS) => {
|
||||
const filters = filtersSource.map(bandToPeqFilter);
|
||||
setPeqItems((prev) =>
|
||||
prev.map((item, i) => {
|
||||
if (i !== catalogIdx) return item;
|
||||
rememberPeqFiltersForPreset(item.name, filters);
|
||||
return { ...item, filters };
|
||||
}),
|
||||
);
|
||||
},
|
||||
[rememberPeqFiltersForPreset],
|
||||
);
|
||||
|
||||
const handleSelectHeadphone = useCallback(
|
||||
(idx: number) => {
|
||||
if (idx === headphoneIdx) {
|
||||
setIsHeadphoneMenuOpen(false);
|
||||
return;
|
||||
}
|
||||
const leaving = peqItems[headphoneIdx];
|
||||
persistFiltersToPeqItem(headphoneIdx, bandsByMode.A);
|
||||
if (leaving?.name) {
|
||||
rememberPeqFiltersForPreset(leaving.name, bandsByMode.A.map(bandToPeqFilter));
|
||||
}
|
||||
if (peqSyncTimerRef.current !== null) {
|
||||
window.clearTimeout(peqSyncTimerRef.current);
|
||||
peqSyncTimerRef.current = null;
|
||||
}
|
||||
setHeadphoneIdx(idx);
|
||||
updateSetting({ peqSelect: idx });
|
||||
setIsHeadphoneMenuOpen(false);
|
||||
},
|
||||
[
|
||||
bandsByMode.A,
|
||||
headphoneIdx,
|
||||
peqItems,
|
||||
persistFiltersToPeqItem,
|
||||
rememberPeqFiltersForPreset,
|
||||
updateSetting,
|
||||
],
|
||||
);
|
||||
|
||||
const schedulePeqSync = useCallback(
|
||||
(
|
||||
peq: { name: string; autoPre?: number; preamp?: number; canDel?: number } | undefined,
|
||||
filtersSource: typeof bands,
|
||||
filtersSource: typeof DEFAULT_BANDS,
|
||||
mode: "A" | "B",
|
||||
delay = 320,
|
||||
topLevel: PeqSubmitTopLevel = "byMode",
|
||||
catalogIdx = headphoneIdx,
|
||||
) => {
|
||||
if (!allowPeqRemoteSyncRef.current) return;
|
||||
if (!isDemoMode && !api) return;
|
||||
@@ -1482,6 +1385,7 @@ export default function EQPage() {
|
||||
window.clearTimeout(peqSyncTimerRef.current);
|
||||
}
|
||||
|
||||
const idxAtSchedule = catalogIdx;
|
||||
peqSyncTimerRef.current = window.setTimeout(() => {
|
||||
const filters = filtersSource.map(bandToPeqFilter);
|
||||
const body: PeqPresetBody = {
|
||||
@@ -1491,26 +1395,63 @@ export default function EQPage() {
|
||||
preamp: peq.preamp ?? 0,
|
||||
canDel: peq.canDel ?? 1,
|
||||
};
|
||||
upgradePeqApply({ peqApply: body } satisfies PeqApplyPayload).catch(() => {
|
||||
const usePeqChange =
|
||||
topLevel === "peqChange" || (topLevel === "byMode" && mode === "A");
|
||||
if (usePeqChange) {
|
||||
rememberPeqFiltersForPreset(peq.name, filters);
|
||||
}
|
||||
const submit = usePeqChange
|
||||
? () => upgradePeqChange({ peqChange: body } satisfies PeqChangePayload)
|
||||
: () => upgradePeqApply({ peqApply: body } satisfies PeqApplyPayload);
|
||||
submit().catch(() => {
|
||||
toast.error("EQ 保存失败");
|
||||
});
|
||||
}, delay);
|
||||
};
|
||||
},
|
||||
[api, headphoneIdx, isDemoMode, rememberPeqFiltersForPreset, upgradePeqApply, upgradePeqChange],
|
||||
);
|
||||
|
||||
// A/B 切换与曲线编辑防抖同步 → peqApply(仅试听,不写预设)
|
||||
/** 点击 A/B 切换试听:固定 peqApply */
|
||||
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
|
||||
useEffect(() => {
|
||||
if (syncingHeadphoneRef.current) return;
|
||||
if (skipPeqAutoSyncRef.current) {
|
||||
skipPeqAutoSyncRef.current = false;
|
||||
return;
|
||||
}
|
||||
schedulePeqSync(peqItems[headphoneIdx], bands, 320);
|
||||
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, peqItems, api, isDemoMode, upgradePeqApply]);
|
||||
}, [bands, abMode, headphoneIdx, schedulePeqSync]);
|
||||
|
||||
useEffect(() => {
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
@@ -1630,6 +1571,7 @@ export default function EQPage() {
|
||||
skipPeqAutoSyncRef.current = true;
|
||||
setBandsByMode((prev) => ({ ...prev, A: cloneBands(bBands) }));
|
||||
setSelectedBandByMode((prev) => ({ ...prev, A: prev.B }));
|
||||
rememberPeqFiltersForPreset(peq.name, filters);
|
||||
setPeqItems((prev) =>
|
||||
prev.map((item, i) => (i !== headphoneIdx ? item : { ...item, filters })),
|
||||
);
|
||||
@@ -1657,6 +1599,7 @@ export default function EQPage() {
|
||||
eqUi.toastApplyBSuccess,
|
||||
headphoneIdx,
|
||||
peqItems,
|
||||
rememberPeqFiltersForPreset,
|
||||
upgradePeqChange,
|
||||
]);
|
||||
|
||||
@@ -1751,7 +1694,7 @@ export default function EQPage() {
|
||||
});
|
||||
return next;
|
||||
});
|
||||
schedulePeqSync(updatedPeq, bands, 0);
|
||||
schedulePeqSync(updatedPeq, bands, abMode, 0, "byMode", headphoneIdx);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -1859,11 +1802,7 @@ export default function EQPage() {
|
||||
}`}
|
||||
style={active ? { background: "#00FFF6" } : undefined}
|
||||
title={model}
|
||||
onClick={() => {
|
||||
setHeadphoneIdx(idx);
|
||||
updateSetting({ peqSelect: idx });
|
||||
setIsHeadphoneMenuOpen(false);
|
||||
}}
|
||||
onClick={() => handleSelectHeadphone(idx)}
|
||||
>
|
||||
{model}
|
||||
</button>
|
||||
@@ -1898,8 +1837,8 @@ export default function EQPage() {
|
||||
rawCurve={currentRawCurve}
|
||||
selectedBand={selectedBand}
|
||||
abMode={abMode}
|
||||
onAbToggle={setAbMode}
|
||||
onCopyMode={copyModeParams}
|
||||
onAbToggle={handleAbToggle}
|
||||
onCopyAndSwitchTo={handleCopyAndSwitchTo}
|
||||
onApplyB={() => void handleApplyB()}
|
||||
onSaveB={openSaveBDialog}
|
||||
onBandDrag={(idx, patch) => setBands((prev) => prev.map((b, i) => i === idx ? { ...b, ...patch } : b))}
|
||||
|
||||
@@ -137,7 +137,7 @@ function IOSToggle({ checked, onChange }: { checked: boolean; onChange: (v: bool
|
||||
|
||||
// ── List row ──
|
||||
function ListRow({
|
||||
icon, label, value, onClick, toggle, checked, onToggle,
|
||||
icon, label, value, onClick, toggle, checked, onToggle, thumbnail,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
@@ -146,6 +146,7 @@ function ListRow({
|
||||
toggle?: boolean;
|
||||
checked?: boolean;
|
||||
onToggle?: (v: boolean) => void;
|
||||
thumbnail?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="ios-list-row cursor-pointer" onClick={onClick}>
|
||||
@@ -160,6 +161,13 @@ function ListRow({
|
||||
<IOSToggle checked={!!checked} onChange={onToggle ?? (() => {})} />
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
{thumbnail && (
|
||||
<img
|
||||
src={thumbnail}
|
||||
alt={value ?? label}
|
||||
className="h-8 w-14 object-contain rounded bg-black/20 border border-white/10"
|
||||
/>
|
||||
)}
|
||||
{value && <span className="ios-row-value">{value}</span>}
|
||||
<ChevronRight size={16} className="ios-chevron" />
|
||||
</div>
|
||||
@@ -217,6 +225,8 @@ export default function Home() {
|
||||
|
||||
const [localVol, setLocalVol] = useState(deviceState?.volume ?? 100);
|
||||
const isDragging = useRef(false);
|
||||
const volumeSliderRef = useRef<HTMLInputElement>(null);
|
||||
const volumeSliderDragAllowedRef = useRef(false);
|
||||
const knobDraggingRef = useRef(false);
|
||||
const knobActivePointerIdRef = useRef<number | null>(null);
|
||||
const knobStartAngleRef = useRef(0);
|
||||
@@ -233,6 +243,16 @@ export default function Home() {
|
||||
knobLastVolRef.current = localVol;
|
||||
}, [localVol]);
|
||||
|
||||
const isPointerOnVolumeSliderThumb = useCallback((clientX: number) => {
|
||||
const el = volumeSliderRef.current;
|
||||
if (!el) return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width <= 0) return false;
|
||||
const thumbCenterX = rect.left + (localVol / 200) * rect.width;
|
||||
const hitRadius = Math.max(28, rect.width * 0.05);
|
||||
return Math.abs(clientX - thumbCenterX) <= hitRadius;
|
||||
}, [localVol]);
|
||||
|
||||
const handlePowerOff = useCallback(async () => {
|
||||
if (powerActionRef.current) return;
|
||||
powerActionRef.current = true;
|
||||
@@ -662,29 +682,51 @@ export default function Home() {
|
||||
boxShadow: "0 0 8px rgba(0,255,246,0.5)"
|
||||
}} />
|
||||
<input
|
||||
ref={volumeSliderRef}
|
||||
type="range" min={0} max={200} value={localVol}
|
||||
disabled={volumePassthroughActive}
|
||||
className="cyan-slider relative z-10 disabled:opacity-50"
|
||||
className="cyan-slider cyan-slider-thumb-only relative z-10 disabled:opacity-50"
|
||||
onChange={(e) => {
|
||||
if (volumePassthroughActive) return;
|
||||
if (!volumeSliderDragAllowedRef.current) {
|
||||
e.target.value = String(localVol);
|
||||
return;
|
||||
}
|
||||
const v = Number(e.target.value);
|
||||
setLocalVol(v);
|
||||
}}
|
||||
onPointerDown={() => {
|
||||
onPointerDown={(e) => {
|
||||
if (volumePassthroughActive) return;
|
||||
const onThumb = isPointerOnVolumeSliderThumb(e.clientX);
|
||||
volumeSliderDragAllowedRef.current = onThumb;
|
||||
if (!onThumb) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
isDragging.current = true;
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
const allowed = volumeSliderDragAllowedRef.current;
|
||||
volumeSliderDragAllowedRef.current = false;
|
||||
isDragging.current = false;
|
||||
if (!allowed || volumePassthroughActive) return;
|
||||
applyVolume(Number(e.currentTarget.value));
|
||||
}}
|
||||
onPointerCancel={(e) => {
|
||||
const allowed = volumeSliderDragAllowedRef.current;
|
||||
volumeSliderDragAllowedRef.current = false;
|
||||
isDragging.current = false;
|
||||
if (!volumePassthroughActive && allowed) {
|
||||
applyVolume(Number(e.currentTarget.value));
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const allowed = volumeSliderDragAllowedRef.current;
|
||||
volumeSliderDragAllowedRef.current = false;
|
||||
isDragging.current = false;
|
||||
if (!volumePassthroughActive && allowed) {
|
||||
applyVolume(Number(e.currentTarget.value));
|
||||
}
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
const k = e.key;
|
||||
@@ -916,6 +958,7 @@ export default function Home() {
|
||||
icon={<Gauge size={16} />}
|
||||
label={homeText.vu ?? "VU表"}
|
||||
value={vuLabel}
|
||||
thumbnail={`${import.meta.env.BASE_URL}vu/vu${(ds.vu ?? 0) + 1}.png`}
|
||||
onClick={() => setLocation("/vu")}
|
||||
/>
|
||||
<ListRow
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
export function CyanSlider({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: {
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
onChange: (v: number) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const fillPct = Math.max(
|
||||
0,
|
||||
Math.min(100, ((value - min) / (max - min)) * 100),
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={`relative flex items-center w-full mt-2 ${disabled ? "opacity-60" : ""}`}
|
||||
>
|
||||
<div
|
||||
className="absolute left-0 h-[4px] rounded-full pointer-events-none"
|
||||
style={{
|
||||
width: `${fillPct}%`,
|
||||
background: disabled ? "rgba(148,163,184,0.7)" : "#00FFF6",
|
||||
boxShadow: disabled
|
||||
? "none"
|
||||
: "0 0 6px rgba(0,255,246,0.45)",
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
className="cyan-slider relative z-10"
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export function IOSToggle({
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="ios-toggle" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
<span className="ios-toggle-track">
|
||||
<span className="ios-toggle-thumb" />
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import type { PeqFilter, PeqState } from "@/lib/luxsinApi";
|
||||
import localeZh from "@/locales/data-zh.json";
|
||||
|
||||
/* ── Filter types ── */
|
||||
export const FILTER_TYPES = [
|
||||
"LPF",
|
||||
"HPF",
|
||||
"BPF",
|
||||
"NOTCH",
|
||||
"PEAK",
|
||||
"LSHELF",
|
||||
"HSHELF",
|
||||
"APF",
|
||||
];
|
||||
|
||||
/* ── Band parameter ranges ── */
|
||||
export const BAND_FREQ_MIN = 20;
|
||||
export const BAND_FREQ_MAX = 20000;
|
||||
export const BAND_GAIN_MIN = -15;
|
||||
export const BAND_GAIN_MAX = 15;
|
||||
export const BAND_Q_MIN = 0.1;
|
||||
export const BAND_Q_MAX = 10;
|
||||
|
||||
export type BandParamKind = "freq" | "gain" | "q";
|
||||
|
||||
/* ── Band param value box style ── */
|
||||
export const BAND_PARAM_VALUE_BOX_STYLE: CSSProperties = {
|
||||
background: "rgba(44,44,46,0.9)",
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
};
|
||||
|
||||
/* ── EQ UI locale type ── */
|
||||
export type PeqEqUi = NonNullable<
|
||||
NonNullable<(typeof localeZh)["peq"]>["eqUi"]
|
||||
>;
|
||||
|
||||
/* ── Default bands matching reference image ── */
|
||||
export const DEFAULT_BANDS = [
|
||||
{ freq: 9500, gain: 0, q: 1.41, type: "LSHELF", enabled: true },
|
||||
{ freq: 9200, gain: -2, q: 1.41, type: "PEAK", enabled: true },
|
||||
{ freq: 220, gain: 1, q: 1.41, type: "PEAK", enabled: true },
|
||||
{ freq: 500, gain: -3, q: 1.41, type: "PEAK", enabled: true },
|
||||
{ freq: 1200, gain: 0, q: 1.41, type: "PEAK", enabled: true },
|
||||
{ freq: 13800, gain: -1, q: 1.41, type: "NOTCH", enabled: true },
|
||||
{ freq: 11000, gain: -2, q: 1.41, type: "PEAK", enabled: true },
|
||||
{ freq: 7400, gain: 1, q: 1.41, type: "PEAK", enabled: true },
|
||||
{ freq: 8200, gain: -1, q: 1.41, type: "PEAK", enabled: true },
|
||||
{ freq: 10000, gain: 0, q: 1.41, type: "HSHELF", enabled: true },
|
||||
];
|
||||
|
||||
/* ── PEQ catalog item type ── */
|
||||
export type PeqCatalogItem = NonNullable<PeqState["peq"]>[number];
|
||||
|
||||
/* ── Flat preset filters ── */
|
||||
export const FLAT_PRESET_FILTERS: PeqFilter[] = [
|
||||
{ type: 4, fc: 80, gain: 0, q: 0.1 },
|
||||
{ type: 4, fc: 150, gain: 0, q: 0.1 },
|
||||
{ type: 4, fc: 350, gain: 0, q: 0.1 },
|
||||
{ type: 4, fc: 750, gain: 0, q: 0.1 },
|
||||
{ type: 4, fc: 1500, gain: 0, q: 0.1 },
|
||||
{ type: 4, fc: 3000, gain: 0, q: 0.1 },
|
||||
{ type: 4, fc: 6000, gain: 0, q: 0.1 },
|
||||
{ type: 4, fc: 10000, gain: 0, q: 0.1 },
|
||||
{ type: 4, fc: 14000, gain: 0, q: 0.1 },
|
||||
{ type: 4, fc: 18000, gain: 0, q: 0.1 },
|
||||
];
|
||||
|
||||
/* ── Catalog target type & data ── */
|
||||
export type CatalogTarget = {
|
||||
name: string;
|
||||
bassBoost: { fc: number; q: number; gain: number };
|
||||
ear: "in" | "over" | "all";
|
||||
};
|
||||
|
||||
export const CATALOG_TARGETS: CatalogTarget[] = [
|
||||
{
|
||||
name: "Harman over-ear 2018",
|
||||
bassBoost: { fc: 105, q: 0.7, gain: 6 },
|
||||
ear: "over",
|
||||
},
|
||||
{
|
||||
name: "HMS II.3 Harman over-ear 2018",
|
||||
bassBoost: { fc: 105, q: 0.7, gain: 6 },
|
||||
ear: "over",
|
||||
},
|
||||
{
|
||||
name: "crinacle EARS + 711 Harman over-ear 2018",
|
||||
bassBoost: { fc: 105, q: 0.7, gain: 6 },
|
||||
ear: "over",
|
||||
},
|
||||
{
|
||||
name: "Harman in-ear 2019",
|
||||
bassBoost: { fc: 105, q: 0.7, gain: 9.5 },
|
||||
ear: "in",
|
||||
},
|
||||
{
|
||||
name: "AutoEq in-ear",
|
||||
bassBoost: { fc: 105, q: 0.7, gain: 8 },
|
||||
ear: "in",
|
||||
},
|
||||
{
|
||||
name: "HMS II.3 AutoEq in-ear",
|
||||
bassBoost: { fc: 105, q: 0.7, gain: 8 },
|
||||
ear: "in",
|
||||
},
|
||||
{
|
||||
name: "HMS II.3 Harman in-ear 2019",
|
||||
bassBoost: { fc: 105, q: 0.7, gain: 9.5 },
|
||||
ear: "in",
|
||||
},
|
||||
{
|
||||
name: "Diffuse Field 5128 (-1 dB/oct)",
|
||||
bassBoost: { fc: 105, q: 0.7, gain: 0 },
|
||||
ear: "over",
|
||||
},
|
||||
{
|
||||
name: "LMG 5128 0.6 without bass",
|
||||
bassBoost: { fc: 105, q: 0.7, gain: 6 },
|
||||
ear: "over",
|
||||
},
|
||||
{
|
||||
name: "JM-1 with Harman filters",
|
||||
bassBoost: { fc: 105, q: 0.7, gain: 6.5 },
|
||||
ear: "all",
|
||||
},
|
||||
{
|
||||
name: "oratory1990 in-ear",
|
||||
bassBoost: { fc: 105, q: 0.7, gain: 9.5 },
|
||||
ear: "in",
|
||||
},
|
||||
{
|
||||
name: "oratory1990 over-ear",
|
||||
bassBoost: { fc: 105, q: 0.7, gain: 6 },
|
||||
ear: "over",
|
||||
},
|
||||
{
|
||||
name: "Harman over-ear 2013",
|
||||
bassBoost: { fc: 105, q: 0.7, gain: 6 },
|
||||
ear: "over",
|
||||
},
|
||||
{
|
||||
name: "Flat",
|
||||
bassBoost: { fc: 105, q: 0.7, gain: 0 },
|
||||
ear: "all",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { PeqFilter, PeqState } from "@/lib/luxsinApi";
|
||||
import { getFilterShortName, getFilterType } from "@/lib/peqAudio";
|
||||
import {
|
||||
BAND_FREQ_MIN,
|
||||
BAND_FREQ_MAX,
|
||||
BAND_GAIN_MIN,
|
||||
BAND_GAIN_MAX,
|
||||
BAND_Q_MIN,
|
||||
BAND_Q_MAX,
|
||||
type BandParamKind,
|
||||
type PeqCatalogItem,
|
||||
} from "./constants";
|
||||
|
||||
/* ── Template interpolation ── */
|
||||
export function eqInterp(
|
||||
template: string | undefined,
|
||||
vars: Record<string, string | number>,
|
||||
): string {
|
||||
if (!template) return "";
|
||||
let s = template;
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
s = s.split(`{{${k}}}`).join(String(v));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/* ── Band frequency display formatting ── */
|
||||
export function formatBandFreqDisplay(freq: number) {
|
||||
return freq >= 1000
|
||||
? `${(freq / 1000).toFixed(2).replace(/\.?0+$/, "")} kHz`
|
||||
: `${freq} Hz`;
|
||||
}
|
||||
|
||||
/* ── Band param value for input field ── */
|
||||
export function formatBandParamForInput(
|
||||
kind: BandParamKind,
|
||||
band: { freq: number; gain: number; q: number },
|
||||
) {
|
||||
switch (kind) {
|
||||
case "freq":
|
||||
return String(band.freq);
|
||||
case "gain":
|
||||
return band.gain.toFixed(1);
|
||||
case "q":
|
||||
return band.q.toFixed(2);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Parse band param input with validation ── */
|
||||
export function parseBandParamInput(
|
||||
kind: BandParamKind,
|
||||
raw: string,
|
||||
messages: { invalid: string; outOfRange: string },
|
||||
): { ok: true; value: number } | { ok: false; message: string } {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return { ok: false, message: messages.invalid };
|
||||
|
||||
if (kind === "freq") {
|
||||
let s = trimmed.replace(/\s+/g, "").toLowerCase().replace(/hz$/, "");
|
||||
const kHz = /k(hz)?$/.test(s);
|
||||
if (kHz) s = s.replace(/k(hz)?$/, "");
|
||||
const num = Number.parseFloat(s);
|
||||
if (!Number.isFinite(num)) return { ok: false, message: messages.invalid };
|
||||
const hz = Math.round(kHz ? num * 1000 : num);
|
||||
if (hz < BAND_FREQ_MIN || hz > BAND_FREQ_MAX) {
|
||||
return { ok: false, message: messages.outOfRange };
|
||||
}
|
||||
return { ok: true, value: hz };
|
||||
}
|
||||
|
||||
if (kind === "gain") {
|
||||
const s = trimmed.replace(/\s*dB\s*$/i, "").trim();
|
||||
const num = Number.parseFloat(s);
|
||||
if (!Number.isFinite(num)) return { ok: false, message: messages.invalid };
|
||||
const gain = Number(num.toFixed(1));
|
||||
if (gain < BAND_GAIN_MIN || gain > BAND_GAIN_MAX) {
|
||||
return { ok: false, message: messages.outOfRange };
|
||||
}
|
||||
return { ok: true, value: gain };
|
||||
}
|
||||
|
||||
const num = Number.parseFloat(trimmed);
|
||||
if (!Number.isFinite(num)) return { ok: false, message: messages.invalid };
|
||||
const q = Number(num.toFixed(2));
|
||||
if (q < BAND_Q_MIN || q > BAND_Q_MAX) {
|
||||
return { ok: false, message: messages.outOfRange };
|
||||
}
|
||||
return { ok: true, value: q };
|
||||
}
|
||||
|
||||
/* ── Normalize filter type from string or number ── */
|
||||
export function normalizeFilterType(
|
||||
type: string | number | undefined,
|
||||
): string {
|
||||
if (type === undefined || type === null) return "PEAK";
|
||||
if (typeof type === "number") {
|
||||
switch (type) {
|
||||
case 0:
|
||||
return "LPF";
|
||||
case 1:
|
||||
return "HPF";
|
||||
case 2:
|
||||
return "BPF";
|
||||
case 3:
|
||||
return "NOTCH";
|
||||
case 4:
|
||||
return "PEAK";
|
||||
case 5:
|
||||
return "LSHELF";
|
||||
case 6:
|
||||
return "HSHELF";
|
||||
case 7:
|
||||
return "APF";
|
||||
default:
|
||||
return "PEAK";
|
||||
}
|
||||
}
|
||||
return getFilterShortName(type);
|
||||
}
|
||||
|
||||
/* ── Clone bands array ── */
|
||||
export function cloneBands(
|
||||
source: Array<{
|
||||
freq: number;
|
||||
gain: number;
|
||||
q: number;
|
||||
type: string;
|
||||
enabled: boolean;
|
||||
}>,
|
||||
) {
|
||||
return source.map((band) => ({ ...band }));
|
||||
}
|
||||
|
||||
/* ── Frequency label for band grid ── */
|
||||
export function freqLabel(f: number) {
|
||||
return f >= 1000 ? `${(f / 1000).toFixed(1)}K` : `${f}`;
|
||||
}
|
||||
|
||||
/* ── Build PEQ catalog sync key ── */
|
||||
export function buildPeqCatalogSyncKey(
|
||||
remote: Pick<PeqState, "peq" | "peqSelect"> | null | undefined,
|
||||
devicePeqSelect?: number,
|
||||
): string {
|
||||
const items = remote?.peq;
|
||||
if (!items?.length) return items ? "empty" : "";
|
||||
const select = devicePeqSelect ?? remote?.peqSelect ?? 0;
|
||||
return `${select}|${items.map((p) => p.name).join("\u0001")}`;
|
||||
}
|
||||
|
||||
/* ── Generate unique preset name ── */
|
||||
export function getUniquePresetName(
|
||||
base: string,
|
||||
existingNames: string[],
|
||||
) {
|
||||
if (!existingNames.includes(base)) return base;
|
||||
let index = 1;
|
||||
while (existingNames.includes(`${base}_${index}`)) {
|
||||
index += 1;
|
||||
}
|
||||
return `${base}_${index}`;
|
||||
}
|
||||
|
||||
/* ── Convert UI band → device PeqFilter ── */
|
||||
export function bandToPeqFilter(b: {
|
||||
freq: number;
|
||||
gain: number;
|
||||
q: number;
|
||||
type: string | number;
|
||||
}): PeqFilter {
|
||||
return {
|
||||
fc: b.freq,
|
||||
gain: b.gain,
|
||||
q: b.q,
|
||||
type: getFilterType(b.type),
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -170,7 +170,7 @@ const plugins = [
|
||||
tailwindcss(),
|
||||
jsxLocPlugin(),
|
||||
vitePluginManusRuntime(),
|
||||
vitePluginManusDebugCollector(),
|
||||
// vitePluginManusDebugCollector(), // 已禁用:不再注入 debug-collector.js,消除浏览器网络面板中定时出现的 __manus__/logs 请求
|
||||
vitePluginExcludeManusFromDist(),
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user