分享码功能的实现,并修复了几个 bug
This commit is contained in:
+11
-2
@@ -7,6 +7,7 @@ import {
|
||||
LuxsinAPI,
|
||||
PeqFilter,
|
||||
FILTER_TYPE_LABELS,
|
||||
buildPeqPresetBody,
|
||||
dbToVolume,
|
||||
volumeToDb,
|
||||
} from "./luxsinApi";
|
||||
@@ -34,6 +35,8 @@ export interface OptimizePeqPayload {
|
||||
name?: string;
|
||||
brand?: string;
|
||||
model?: string;
|
||||
target?: string;
|
||||
form?: string;
|
||||
preamp?: number;
|
||||
canDel?: number;
|
||||
autoPre?: number;
|
||||
@@ -327,13 +330,19 @@ export async function executeFrontendTool(
|
||||
case "set_peq": {
|
||||
const p = block.input ?? {};
|
||||
await api.upgradePeqChange({
|
||||
peqChange: {
|
||||
peqChange: buildPeqPresetBody(
|
||||
{
|
||||
name: p.name,
|
||||
filters: p.filters ?? [],
|
||||
brand: p.brand,
|
||||
model: p.model,
|
||||
target: p.target,
|
||||
form: p.form,
|
||||
autoPre: p.autoPre,
|
||||
preamp: p.preamp,
|
||||
canDel: p.canDel,
|
||||
},
|
||||
(p.filters ?? []) as PeqFilter[],
|
||||
),
|
||||
});
|
||||
await api.refreshState();
|
||||
return { ok: true, content: { ok: true, name: p.name } };
|
||||
|
||||
@@ -145,6 +145,7 @@ export interface PeqState {
|
||||
canDel?: number;
|
||||
brand?: string;
|
||||
model?: string;
|
||||
target?: string;
|
||||
form?: string;
|
||||
}>;
|
||||
}
|
||||
@@ -162,6 +163,38 @@ export interface PeqPresetBody {
|
||||
form?: string;
|
||||
}
|
||||
|
||||
/** PEQ preset metadata used when building peqChange / peqApply bodies. */
|
||||
export type PeqPresetMeta = {
|
||||
name: string;
|
||||
autoPre?: number;
|
||||
preamp?: number;
|
||||
canDel?: number;
|
||||
brand?: string;
|
||||
model?: string;
|
||||
target?: string;
|
||||
form?: string;
|
||||
};
|
||||
|
||||
/** Build device PEQ POST body; optional brand/model/target/form included only when non-empty. */
|
||||
export function buildPeqPresetBody(peq: PeqPresetMeta, filters: PeqFilter[]): PeqPresetBody {
|
||||
const body: PeqPresetBody = {
|
||||
name: peq.name,
|
||||
filters,
|
||||
autoPre: peq.autoPre ?? 0,
|
||||
preamp: peq.preamp ?? 0,
|
||||
canDel: peq.canDel ?? 1,
|
||||
};
|
||||
const brand = peq.brand?.trim();
|
||||
const model = peq.model?.trim();
|
||||
const target = peq.target?.trim();
|
||||
const form = peq.form?.trim();
|
||||
if (brand) body.brand = brand;
|
||||
if (model) body.model = model;
|
||||
if (target) body.target = target;
|
||||
if (form) body.form = form;
|
||||
return body;
|
||||
}
|
||||
|
||||
/** POST body for `/dev/info.cgi` — full peq preset update (custom base64 `json` field). */
|
||||
export interface PeqChangePayload {
|
||||
peqChange: PeqPresetBody;
|
||||
@@ -635,3 +668,110 @@ export async function fetchLuxsinAudioCurve(
|
||||
const body = (await res.text()).trim();
|
||||
return decodeCustomBase64(body);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Share Code API — shareCreate / shareList / shareQuery / shareAccept
|
||||
// ============================================================
|
||||
|
||||
export interface ShareCodeCreateResponse {
|
||||
code: number;
|
||||
msg: string;
|
||||
share_code?: string;
|
||||
expire_at?: string;
|
||||
eq_data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ShareCodeListItem {
|
||||
share_code: string;
|
||||
expire_at: string;
|
||||
eq_data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ShareCodeListResponse {
|
||||
code: number;
|
||||
msg: string;
|
||||
share_codes: ShareCodeListItem[];
|
||||
}
|
||||
|
||||
export interface ShareCodeAcceptResponse {
|
||||
code: number;
|
||||
msg: string;
|
||||
eq_data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ShareCodeQueryResponse {
|
||||
code: number;
|
||||
msg: string;
|
||||
eq_data?: Record<string, unknown>;
|
||||
expire_at?: string;
|
||||
}
|
||||
|
||||
/** Unwrap stringified PEQ filters (remove JSON escape layers) for API submit. */
|
||||
export function normalizePeqFiltersForSubmit(filters: unknown): PeqFilter[] {
|
||||
if (Array.isArray(filters)) return filters as PeqFilter[];
|
||||
if (typeof filters !== "string" || !filters.trim()) return [];
|
||||
|
||||
let current: unknown = filters.trim();
|
||||
for (let depth = 0; depth < 3; depth++) {
|
||||
if (Array.isArray(current)) return current as PeqFilter[];
|
||||
if (typeof current !== "string") return [];
|
||||
try {
|
||||
current = JSON.parse(current);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return Array.isArray(current) ? (current as PeqFilter[]) : [];
|
||||
}
|
||||
|
||||
/** POST `/audio/shareCreate` — 创建 EQ 分享码,返回 share_code + expire_at + eq_data */
|
||||
export async function createShareCode(
|
||||
mac: string,
|
||||
eqData: Record<string, unknown>,
|
||||
): Promise<ShareCodeCreateResponse> {
|
||||
const payload = {
|
||||
...eqData,
|
||||
...(eqData.filters !== undefined
|
||||
? { filters: normalizePeqFiltersForSubmit(eqData.filters) }
|
||||
: {}),
|
||||
};
|
||||
const res = await fetch(buildLuxsinAudioUrl("shareCreate"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mac, eq_data: payload }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`shareCreate HTTP ${res.status}`);
|
||||
return (await res.json()) as ShareCodeCreateResponse;
|
||||
}
|
||||
|
||||
/** GET `/audio/shareList?mac=...` — 查询某 MAC 尚未过期的分享码列表 */
|
||||
export async function listShareCodes(mac: string): Promise<ShareCodeListResponse> {
|
||||
const res = await fetch(
|
||||
buildLuxsinAudioUrl(`shareList?mac=${encodeURIComponent(mac)}`),
|
||||
);
|
||||
if (!res.ok) throw new Error(`shareList HTTP ${res.status}`);
|
||||
return (await res.json()) as ShareCodeListResponse;
|
||||
}
|
||||
|
||||
/** GET `/audio/shareQuery?shareCode=...` — 预览分享码 EQ 数据(不记录导入) */
|
||||
export async function queryShareCode(shareCode: string): Promise<ShareCodeQueryResponse> {
|
||||
const res = await fetch(
|
||||
buildLuxsinAudioUrl(`shareQuery?shareCode=${encodeURIComponent(shareCode)}`),
|
||||
);
|
||||
if (!res.ok) throw new Error(`shareQuery HTTP ${res.status}`);
|
||||
return (await res.json()) as ShareCodeQueryResponse;
|
||||
}
|
||||
|
||||
/** GET `/audio/shareAccept?mac=...&shareCode=...` — 确认导入分享码并记录流水 */
|
||||
export async function acceptShareCode(
|
||||
mac: string,
|
||||
shareCode: string,
|
||||
): Promise<ShareCodeAcceptResponse> {
|
||||
const res = await fetch(
|
||||
buildLuxsinAudioUrl(
|
||||
`shareAccept?mac=${encodeURIComponent(mac)}&shareCode=${encodeURIComponent(shareCode)}`,
|
||||
),
|
||||
);
|
||||
if (!res.ok) throw new Error(`shareAccept HTTP ${res.status}`);
|
||||
return (await res.json()) as ShareCodeAcceptResponse;
|
||||
}
|
||||
|
||||
@@ -287,6 +287,10 @@
|
||||
"addPresetTitle": "New preset",
|
||||
"addPresetNameEmpty": "Name cannot be empty",
|
||||
"addPresetNameExists": "That name already exists. Choose another.",
|
||||
"overwritePresetTitle": "Preset name already exists",
|
||||
"overwritePresetDesc": "A preset named \"{{name}}\" already exists. Overwrite it?",
|
||||
"overwritePresetConfirm": "Overwrite",
|
||||
"overwritePresetCancelled": "Cancelled",
|
||||
"toastAddPresetOk": "Preset added",
|
||||
"toastAddPresetFail": "Failed to add preset",
|
||||
"toastPresetLimitReached": "Preset limit reached ({{max}}). Delete some presets before adding more.",
|
||||
@@ -360,6 +364,8 @@
|
||||
"shareCodeLabel": "Share Code",
|
||||
"shareCopied": "Code copied",
|
||||
"shareGenerating": "Generating…",
|
||||
"shareCreateFail": "Failed to generate share code",
|
||||
"shareExpireAt": "Expires at",
|
||||
"importTab": "Import EQ",
|
||||
"importCodeHint": "Enter or paste the 5-character share code",
|
||||
"importQuery": "Query",
|
||||
@@ -367,13 +373,16 @@
|
||||
"importCodeNotFound": "Share code not found",
|
||||
"importEqName": "EQ Name",
|
||||
"importButton": "Import",
|
||||
"importSaving": "Importing…",
|
||||
"importSuccess": "EQ imported successfully",
|
||||
"importFail": "Failed to import EQ",
|
||||
"mySharesTab": "My Shares",
|
||||
"mySharesLoading": "Loading…",
|
||||
"mySharesEmpty": "No shared EQ yet",
|
||||
"mySharesCode": "Code",
|
||||
"mySharesName": "EQ Name",
|
||||
"mySharesLoadFail": "Failed to load share list"
|
||||
"mySharesLoadFail": "Failed to load share list",
|
||||
"importCodeNotFound": "Share code not found or expired"
|
||||
}
|
||||
},
|
||||
"dac": {
|
||||
|
||||
@@ -223,6 +223,10 @@
|
||||
"addPresetTitle": "新增預設",
|
||||
"addPresetNameEmpty": "名稱不能為空",
|
||||
"addPresetNameExists": "名稱已存在,請修改後再儲存",
|
||||
"overwritePresetTitle": "預設名稱已存在",
|
||||
"overwritePresetDesc": "預設「{{name}}」已存在,確認覆蓋原有資料嗎?",
|
||||
"overwritePresetConfirm": "覆蓋",
|
||||
"overwritePresetCancelled": "已取消",
|
||||
"toastAddPresetOk": "已新增預設",
|
||||
"toastAddPresetFail": "新增預設失敗",
|
||||
"toastPresetLimitReached": "預設已達上限({{max}} 個),請先清理後再操作",
|
||||
@@ -296,6 +300,8 @@
|
||||
"shareCodeLabel": "分享碼",
|
||||
"shareCopied": "已複製分享碼",
|
||||
"shareGenerating": "產生中…",
|
||||
"shareCreateFail": "產生分享碼失敗",
|
||||
"shareExpireAt": "有效期至",
|
||||
"importTab": "匯入 EQ",
|
||||
"importCodeHint": "輸入或貼上 5 位分享碼",
|
||||
"importQuery": "查詢",
|
||||
@@ -303,13 +309,16 @@
|
||||
"importCodeNotFound": "分享碼不存在",
|
||||
"importEqName": "EQ 名稱",
|
||||
"importButton": "匯入",
|
||||
"importSaving": "匯入中…",
|
||||
"importSuccess": "EQ 匯入成功",
|
||||
"importFail": "EQ 匯入失敗",
|
||||
"mySharesTab": "我的分享",
|
||||
"mySharesLoading": "載入中…",
|
||||
"mySharesEmpty": "暫無分享記錄",
|
||||
"mySharesCode": "分享碼",
|
||||
"mySharesName": "EQ 名稱",
|
||||
"mySharesLoadFail": "載入分享列表失敗"
|
||||
"mySharesLoadFail": "載入分享列表失敗",
|
||||
"importCodeNotFound": "分享碼不存在或已過期"
|
||||
}
|
||||
},
|
||||
"dac": {
|
||||
|
||||
@@ -223,6 +223,10 @@
|
||||
"addPresetTitle": "新增预设",
|
||||
"addPresetNameEmpty": "名称不能为空",
|
||||
"addPresetNameExists": "名称已存在,请修改后再保存",
|
||||
"overwritePresetTitle": "预设名称已存在",
|
||||
"overwritePresetDesc": "预设「{{name}}」已存在,确认覆盖原有数据吗?",
|
||||
"overwritePresetConfirm": "覆盖",
|
||||
"overwritePresetCancelled": "已取消",
|
||||
"toastAddPresetOk": "已新增预设",
|
||||
"toastAddPresetFail": "新增预设失败",
|
||||
"toastPresetLimitReached": "预设已达上限({{max}} 个),请先清理后再操作",
|
||||
@@ -296,6 +300,8 @@
|
||||
"shareCodeLabel": "分享码",
|
||||
"shareCopied": "已复制分享码",
|
||||
"shareGenerating": "生成中…",
|
||||
"shareCreateFail": "生成分享码失败",
|
||||
"shareExpireAt": "有效期至",
|
||||
"importTab": "导入 EQ",
|
||||
"importCodeHint": "输入或粘贴 5 位分享码",
|
||||
"importQuery": "查询",
|
||||
@@ -303,13 +309,16 @@
|
||||
"importCodeNotFound": "分享码不存在",
|
||||
"importEqName": "EQ 名称",
|
||||
"importButton": "导入",
|
||||
"importSaving": "导入中…",
|
||||
"importSuccess": "EQ 导入成功",
|
||||
"importFail": "EQ 导入失败",
|
||||
"mySharesTab": "我的分享",
|
||||
"mySharesLoading": "加载中…",
|
||||
"mySharesEmpty": "暂无分享记录",
|
||||
"mySharesCode": "分享码",
|
||||
"mySharesName": "EQ 名称",
|
||||
"mySharesLoadFail": "加载分享列表失败"
|
||||
"mySharesLoadFail": "加载分享列表失败",
|
||||
"importCodeNotFound": "分享码不存在或已过期"
|
||||
}
|
||||
},
|
||||
"dac": {
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from "@/lib/aiApi";
|
||||
|
||||
import { buildPeqSvgCurveData, getFilterType, PeqBandForResponse } from "@/lib/peqAudio";
|
||||
import { buildPeqPresetBody } from "@/lib/luxsinApi";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ArrowUp,
|
||||
@@ -622,6 +623,8 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {})
|
||||
autoPre: selected?.autoPre,
|
||||
brand: selected?.brand,
|
||||
model: selected?.model,
|
||||
target: selected?.target,
|
||||
form: selected?.form,
|
||||
filters: (selectedFilters ?? peqState.filters ?? []) as OptimizePeqPayload["filters"],
|
||||
});
|
||||
} catch {
|
||||
@@ -641,21 +644,28 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {})
|
||||
}
|
||||
const nextApplied = !msg.applied;
|
||||
const targetPeq = nextApplied ? msg.afterPeq : msg.beforePeq;
|
||||
const metaSource = currentDevicePeq ?? targetPeq;
|
||||
setApplyingId(msg.id);
|
||||
try {
|
||||
await api.upgradePeqChange({
|
||||
peqChange: {
|
||||
name: targetPeq.name ?? msg.name,
|
||||
filters: (targetPeq.filters ?? []).map((f) => ({
|
||||
peqChange: buildPeqPresetBody(
|
||||
{
|
||||
name: targetPeq.name ?? msg.name ?? metaSource?.name ?? "",
|
||||
brand: targetPeq.brand ?? metaSource?.brand,
|
||||
model: targetPeq.model ?? metaSource?.model,
|
||||
target: targetPeq.target ?? metaSource?.target,
|
||||
form: targetPeq.form ?? metaSource?.form,
|
||||
autoPre: targetPeq.autoPre ?? metaSource?.autoPre,
|
||||
preamp: targetPeq.preamp ?? metaSource?.preamp,
|
||||
canDel: targetPeq.canDel ?? metaSource?.canDel,
|
||||
},
|
||||
(targetPeq.filters ?? []).map((f) => ({
|
||||
type: getFilterType(f.type),
|
||||
fc: (f.fc ?? f.frequency ?? 1000) as number,
|
||||
gain: f.gain,
|
||||
q: f.q,
|
||||
})),
|
||||
autoPre: targetPeq.autoPre,
|
||||
preamp: targetPeq.preamp,
|
||||
canDel: targetPeq.canDel,
|
||||
},
|
||||
),
|
||||
});
|
||||
await updateMessageApplied(msg.id, nextApplied);
|
||||
await api.refreshState();
|
||||
@@ -679,7 +689,7 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {})
|
||||
setApplyingId(null);
|
||||
}
|
||||
},
|
||||
[aiText, api, refreshCurrentDevicePeq],
|
||||
[aiText, api, currentDevicePeq, refreshCurrentDevicePeq],
|
||||
);
|
||||
|
||||
const openCompare = useCallback(
|
||||
|
||||
+284
-69
@@ -23,8 +23,9 @@ import {
|
||||
type PeqFilter,
|
||||
type PeqApplyPayload,
|
||||
type PeqChangePayload,
|
||||
type PeqPresetBody,
|
||||
type PeqState,
|
||||
buildPeqPresetBody,
|
||||
acceptShareCode,
|
||||
} from "@/lib/luxsinApi";
|
||||
import {
|
||||
getFilterType,
|
||||
@@ -64,6 +65,7 @@ import {
|
||||
getUniquePresetName,
|
||||
bandToPeqFilter,
|
||||
fetchEqSyncPeq,
|
||||
importedFiltersToPeqFilters,
|
||||
} from "./eq/utils";
|
||||
import { IOSToggle } from "./eq/components/IOSToggle";
|
||||
import { CyanSlider } from "./eq/components/CyanSlider";
|
||||
@@ -74,6 +76,7 @@ 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 { useRawCurve } from "./eq/hooks/useRawCurve";
|
||||
|
||||
/* ── Default bands imported from eq/constants ── */
|
||||
@@ -123,6 +126,7 @@ export default function EQPage() {
|
||||
name: string;
|
||||
brand?: string;
|
||||
model?: string;
|
||||
target?: string;
|
||||
form?: string;
|
||||
filters?: any[] | string;
|
||||
autoPre?: number;
|
||||
@@ -154,6 +158,11 @@ export default function EQPage() {
|
||||
const [brandDrawerKey, setBrandDrawerKey] = useState(0);
|
||||
const [isShareDialogOpen, setIsShareDialogOpen] = useState(false);
|
||||
const [shareDialogKey, setShareDialogKey] = useState(0);
|
||||
const [overwritePresetDialog, setOverwritePresetDialog] = useState<{
|
||||
name: string;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
onDismiss: () => void;
|
||||
} | null>(null);
|
||||
const {
|
||||
currentRawCurve,
|
||||
setCurrentRawCurve,
|
||||
@@ -162,6 +171,7 @@ export default function EQPage() {
|
||||
loadRawCurveForPeq,
|
||||
} = useRawCurve();
|
||||
const allowPeqRemoteSyncRef = useRef(false);
|
||||
const peqHydrationPendingRef = useRef(true);
|
||||
const lastPeqCatalogSyncKeyRef = useRef("");
|
||||
const syncingHeadphoneRef = useRef(false);
|
||||
const peqSyncTimerRef = useRef<number | null>(null);
|
||||
@@ -215,6 +225,29 @@ export default function EQPage() {
|
||||
return false;
|
||||
};
|
||||
|
||||
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 openAddHeadsetCatalog = () => {
|
||||
if (!guardPeqPresetCapacity()) return;
|
||||
setIsBrandDrawerOpen(true);
|
||||
@@ -510,28 +543,29 @@ export default function EQPage() {
|
||||
|
||||
const copyBrand = currentPeq?.brand?.trim() ?? "";
|
||||
const copyModel = currentPeq?.model?.trim() ?? "";
|
||||
const copyTarget = currentPeq?.target?.trim() ?? "";
|
||||
const copyForm = currentPeq?.form?.trim() ?? "";
|
||||
const copyPayload: PeqChangePayload = {
|
||||
peqChange: {
|
||||
peqChange: buildPeqPresetBody(
|
||||
{
|
||||
name: nextName,
|
||||
...(copyBrand ? { brand: copyBrand } : {}),
|
||||
...(copyModel ? { model: copyModel } : {}),
|
||||
...(copyForm ? { form: copyForm } : {}),
|
||||
filters: bands.map(bandToPeqFilter),
|
||||
autoPre: currentPeq?.autoPre ?? 0,
|
||||
preamp: currentPeq?.preamp ?? 0,
|
||||
canDel: currentPeq?.canDel ?? 1,
|
||||
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: {
|
||||
name: nextName,
|
||||
preamp: 0,
|
||||
canDel: 1,
|
||||
autoPre: 0,
|
||||
filters: FLAT_PRESET_FILTERS,
|
||||
},
|
||||
peqChange: buildPeqPresetBody(
|
||||
{ name: nextName, preamp: 0, canDel: 1, autoPre: 0 },
|
||||
FLAT_PRESET_FILTERS,
|
||||
),
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -557,6 +591,7 @@ export default function EQPage() {
|
||||
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,
|
||||
@@ -587,13 +622,19 @@ export default function EQPage() {
|
||||
const bBands = cloneBands(bandsByMode.B);
|
||||
const filters = bBands.map(bandToPeqFilter);
|
||||
const payload: PeqChangePayload = {
|
||||
peqChange: {
|
||||
peqChange: buildPeqPresetBody(
|
||||
{
|
||||
name: nextName,
|
||||
filters,
|
||||
autoPre: currentPeq?.autoPre ?? 0,
|
||||
preamp: currentPeq?.preamp ?? 0,
|
||||
canDel: currentPeq?.canDel ?? 1,
|
||||
brand: currentPeq?.brand,
|
||||
model: currentPeq?.model,
|
||||
target: currentPeq?.target,
|
||||
form: currentPeq?.form,
|
||||
autoPre: currentPeq?.autoPre,
|
||||
preamp: currentPeq?.preamp,
|
||||
canDel: currentPeq?.canDel,
|
||||
},
|
||||
filters,
|
||||
),
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -627,6 +668,125 @@ export default function EQPage() {
|
||||
}
|
||||
};
|
||||
|
||||
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 (!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 {
|
||||
const mac = deviceState?.mac ?? "";
|
||||
if (!isDemoMode && mac) {
|
||||
const acceptRes = await acceptShareCode(mac, shareCode.trim());
|
||||
if (acceptRes.code !== 200) {
|
||||
toast.error(eqUi.importFail ?? eqUi.importCodeNotFound);
|
||||
skipPeqAutoSyncRef.current = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -644,6 +804,7 @@ export default function EQPage() {
|
||||
// 加载耳机列表并初始化曲线
|
||||
useEffect(() => {
|
||||
async function loadHeadphones() {
|
||||
peqHydrationPendingRef.current = true;
|
||||
allowPeqRemoteSyncRef.current = false;
|
||||
try {
|
||||
if (isDemoMode || !api) {
|
||||
@@ -720,6 +881,10 @@ export default function EQPage() {
|
||||
}
|
||||
} finally {
|
||||
allowPeqRemoteSyncRef.current = true;
|
||||
skipPeqAutoSyncRef.current = true;
|
||||
queueMicrotask(() => {
|
||||
peqHydrationPendingRef.current = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
loadHeadphones();
|
||||
@@ -739,6 +904,7 @@ export default function EQPage() {
|
||||
const nextBands = normalizeFiltersFromPeq(peq);
|
||||
if (!nextBands.length) return;
|
||||
syncingHeadphoneRef.current = true;
|
||||
skipPeqAutoSyncRef.current = true;
|
||||
setBandsForBothModes(nextBands);
|
||||
setSelectedBandByMode({ A: 0, B: 0 });
|
||||
const shouldFetchModelCurve = !!(peq.brand?.trim() && peq.model?.trim());
|
||||
@@ -835,7 +1001,16 @@ export default function EQPage() {
|
||||
|
||||
const schedulePeqSync = useCallback(
|
||||
(
|
||||
peq: { name: string; autoPre?: number; preamp?: number; canDel?: number } | undefined,
|
||||
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,
|
||||
@@ -843,6 +1018,7 @@ export default function EQPage() {
|
||||
catalogIdx = headphoneIdx,
|
||||
) => {
|
||||
if (!allowPeqRemoteSyncRef.current) return;
|
||||
if (peqHydrationPendingRef.current) return;
|
||||
if (!isDemoMode && !api) return;
|
||||
if (!peq?.name) return;
|
||||
|
||||
@@ -853,13 +1029,7 @@ export default function EQPage() {
|
||||
const idxAtSchedule = catalogIdx;
|
||||
peqSyncTimerRef.current = window.setTimeout(() => {
|
||||
const filters = filtersSource.map(bandToPeqFilter);
|
||||
const body: PeqPresetBody = {
|
||||
name: peq.name,
|
||||
filters,
|
||||
autoPre: peq.autoPre ?? 0,
|
||||
preamp: peq.preamp ?? 0,
|
||||
canDel: peq.canDel ?? 1,
|
||||
};
|
||||
const body = buildPeqPresetBody(peq, filters);
|
||||
const usePeqChange =
|
||||
topLevel === "peqChange" || (topLevel === "byMode" && mode === "A");
|
||||
if (usePeqChange) {
|
||||
@@ -904,6 +1074,7 @@ export default function EQPage() {
|
||||
|
||||
// 参数编辑防抖:A→peqChange,B→peqApply
|
||||
useEffect(() => {
|
||||
if (peqHydrationPendingRef.current) return;
|
||||
if (syncingHeadphoneRef.current) return;
|
||||
if (skipPeqAutoSyncRef.current) {
|
||||
skipPeqAutoSyncRef.current = false;
|
||||
@@ -949,13 +1120,19 @@ export default function EQPage() {
|
||||
const bBands = cloneBands(bandsByMode.B);
|
||||
const filters = bBands.map(bandToPeqFilter);
|
||||
const payload: PeqChangePayload = {
|
||||
peqChange: {
|
||||
peqChange: buildPeqPresetBody(
|
||||
{
|
||||
name: peq.name,
|
||||
filters,
|
||||
autoPre: peq.autoPre ?? 0,
|
||||
preamp: peq.preamp ?? 0,
|
||||
canDel: peq.canDel ?? 1,
|
||||
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;
|
||||
@@ -1525,7 +1702,7 @@ export default function EQPage() {
|
||||
/>
|
||||
|
||||
<BrandDrawer
|
||||
key={brandDrawerKey}
|
||||
key={`brand-drawer-${brandDrawerKey}`}
|
||||
open={isBrandDrawerOpen}
|
||||
onClose={() => setIsBrandDrawerOpen(false)}
|
||||
eqUi={eqUi}
|
||||
@@ -1540,7 +1717,9 @@ export default function EQPage() {
|
||||
|
||||
const parsedObj = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
|
||||
const parametricEqRaw = parsedObj?.parametric_eq;
|
||||
if (!parametricEqRaw || typeof parametricEqRaw !== "object") return;
|
||||
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 }>;
|
||||
@@ -1556,30 +1735,58 @@ export default function EQPage() {
|
||||
q: Number(Number(item.q).toFixed(2)),
|
||||
}));
|
||||
|
||||
const createdName = `${brand} ${name}`;
|
||||
const postPeq: PeqChangePayload = {
|
||||
peqChange: {
|
||||
name: `${brand} ${name}`,
|
||||
peqChange: buildPeqPresetBody(
|
||||
{
|
||||
name: createdName,
|
||||
brand,
|
||||
model: name,
|
||||
target,
|
||||
...(form ? { form } : {}),
|
||||
filters,
|
||||
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);
|
||||
const createdName = postPeq.peqChange.name;
|
||||
if (api && !isDemoMode) {
|
||||
const latest = await fetchEqSyncPeq(api, "addCatalogPreset");
|
||||
applyPeqStateToUI(latest as { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number });
|
||||
const createdIndex = latest.peq?.findIndex((item) => item.name === createdName) ?? -1;
|
||||
if (createdIndex >= 0) {
|
||||
setHeadphoneIdx(createdIndex);
|
||||
updateSetting({ peqSelect: createdIndex });
|
||||
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) => [
|
||||
@@ -1602,40 +1809,48 @@ export default function EQPage() {
|
||||
});
|
||||
setHeadphoneIdx(nextIndex);
|
||||
}
|
||||
setIsBrandDrawerOpen(false);
|
||||
} catch {
|
||||
skipPeqAutoSyncRef.current = false;
|
||||
toast.error(eqUi.toastAddPresetFail);
|
||||
}
|
||||
};
|
||||
|
||||
if (headphoneModels.includes(createdName)) {
|
||||
setIsBrandDrawerOpen(false);
|
||||
}
|
||||
return promptOverwriteIfNeeded(createdName, submitCatalogPreset);
|
||||
}}
|
||||
/>
|
||||
|
||||
<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();
|
||||
}}
|
||||
/>
|
||||
|
||||
<ShareDialog
|
||||
key={shareDialogKey}
|
||||
key={`share-dialog-${shareDialogKey}`}
|
||||
open={isShareDialogOpen}
|
||||
onClose={() => setIsShareDialogOpen(false)}
|
||||
peqItems={peqItems}
|
||||
headphoneIdx={headphoneIdx}
|
||||
eqUi={eqUi}
|
||||
peqCardLabels={peqCardLabels}
|
||||
onImportEq={(data) => {
|
||||
// ── TODO: 伪代码 — 导入 EQ 到预设列表 ──
|
||||
// if (!guardPeqPresetCapacity()) return;
|
||||
// setPeqItems((prev) => [
|
||||
// ...prev,
|
||||
// {
|
||||
// name: data.name,
|
||||
// brand: data.brand,
|
||||
// model: data.model,
|
||||
// form: data.form,
|
||||
// filters: data.filters,
|
||||
// preamp: data.preamp,
|
||||
// autoPre: 0,
|
||||
// canDel: 1,
|
||||
// },
|
||||
// ]);
|
||||
// setHeadphoneIdx(peqItems.length);
|
||||
// toast.success(eqUi.importSuccess);
|
||||
|
||||
// 模拟导入
|
||||
toast.success(eqUi.importSuccess);
|
||||
setIsShareDialogOpen(false);
|
||||
}}
|
||||
mac={deviceState?.mac ?? ""}
|
||||
onImportEq={handleImportSharedEq}
|
||||
/>
|
||||
|
||||
<BottomNav />
|
||||
|
||||
@@ -24,7 +24,7 @@ export function BrandDrawer({
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
eqUi: PeqEqUi;
|
||||
onConfirm: (brand: string, name: string, target: string, form?: string) => Promise<void>;
|
||||
onConfirm: (brand: string, name: string, target: string, form?: string) => Promise<boolean>;
|
||||
}) {
|
||||
const [brandDrawerTab, setBrandDrawerTab] = useState<BrandDrawerTab>("brands");
|
||||
const [brandSearchQuery, setBrandSearchQuery] = useState("");
|
||||
@@ -357,9 +357,18 @@ export function BrandDrawer({
|
||||
if (isConfirmingTarget) return;
|
||||
setIsConfirmingTarget(true);
|
||||
try {
|
||||
await onConfirm(selectedCatalogBrand, selectedCatalogModelName, selectedCatalogTarget, selectedCatalogModelForm);
|
||||
const completed = await onConfirm(
|
||||
selectedCatalogBrand,
|
||||
selectedCatalogModelName,
|
||||
selectedCatalogTarget,
|
||||
selectedCatalogModelForm,
|
||||
);
|
||||
if (completed) {
|
||||
onClose();
|
||||
toast.success(eqUi.toastNewEqOk);
|
||||
} else {
|
||||
toast.info(eqUi.overwritePresetCancelled);
|
||||
}
|
||||
} catch {
|
||||
toast.error(eqUi.toastCurveFail);
|
||||
} finally {
|
||||
|
||||
@@ -327,7 +327,7 @@ export function FreqChart({
|
||||
>
|
||||
{/* dB grid */}
|
||||
{gainLabels.map((g) => (
|
||||
<g key={g}>
|
||||
<g key={`gain-${g}`}>
|
||||
<line x1="0" y1={gainToY(g)} x2={W} y2={gainToY(g)}
|
||||
stroke="rgba(255,255,255,0.05)" strokeWidth="1" />
|
||||
<text x="3" y={gainToY(g) - 2} fontSize="7" fill="rgba(255,255,255,0.2)">
|
||||
@@ -337,7 +337,7 @@ export function FreqChart({
|
||||
))}
|
||||
{/* Freq grid */}
|
||||
{freqLabels.map((f) => (
|
||||
<line key={f} x1={freqToX(f)} y1="0" x2={freqToX(f)} y2={H - 12}
|
||||
<line key={`freq-line-${f}`} x1={freqToX(f)} y1="0" x2={freqToX(f)} y2={H - 12}
|
||||
stroke="rgba(255,255,255,0.05)" strokeWidth="1" />
|
||||
))}
|
||||
{/* Zero line */}
|
||||
@@ -385,7 +385,7 @@ export function FreqChart({
|
||||
{/* Band nodes with index */}
|
||||
{showEq && bands.map((band, i) => (
|
||||
<g
|
||||
key={i}
|
||||
key={`band-${i}`}
|
||||
style={{ cursor: "grab" }}
|
||||
onPointerDown={(e) => handleBandPointerDown(i, e)}
|
||||
>
|
||||
@@ -430,7 +430,7 @@ export function FreqChart({
|
||||
))}
|
||||
{/* Freq axis labels */}
|
||||
{freqLabels.map((f) => (
|
||||
<text key={f} x={freqToX(f)} y={H - 1} textAnchor="middle"
|
||||
<text key={`freq-text-${f}`} x={freqToX(f)} y={H - 1} textAnchor="middle"
|
||||
fontSize="7" fill="rgba(255,255,255,0.25)">
|
||||
{f >= 1000 ? `${f / 1000}k` : f}
|
||||
</text>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Above BrandDrawer (z-110) and ShareDialog (z-120). */
|
||||
const EQ_OVERWRITE_DIALOG_Z = "z-[130]";
|
||||
|
||||
export function PeqOverwriteConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
cancelLabel,
|
||||
confirmLabel,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
cancelLabel: string;
|
||||
confirmLabel: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
return (
|
||||
<AlertDialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) onCancel();
|
||||
}}
|
||||
>
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay className={cn(EQ_OVERWRITE_DIALOG_Z, "bg-black/65")} />
|
||||
<AlertDialogPrimitive.Content
|
||||
className={cn(
|
||||
EQ_OVERWRITE_DIALOG_Z,
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[42%] left-[50%] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-md border-white/10 bg-zinc-900 text-white",
|
||||
)}
|
||||
>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-white">{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-white/60">
|
||||
{description}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel
|
||||
className="border-white/20 bg-transparent text-white hover:bg-white/10"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{cancelLabel}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-[#00FFF6] text-black hover:brightness-95 focus-visible:ring-[#00FFF6]"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmLabel}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogPrimitive.Content>
|
||||
</AlertDialogPortal>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import { X, Copy, Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { createShareCode, listShareCodes, queryShareCode } from "@/lib/luxsinApi";
|
||||
import type { PeqEqUi } from "../constants";
|
||||
|
||||
type PeqItem = {
|
||||
@@ -23,9 +24,11 @@ type ImportedEqData = {
|
||||
name: string;
|
||||
brand?: string;
|
||||
model?: string;
|
||||
target?: string;
|
||||
form?: string;
|
||||
filters?: any[] | string;
|
||||
filters?: unknown;
|
||||
preamp?: number;
|
||||
autoPre?: number;
|
||||
};
|
||||
|
||||
export function ShareDialog({
|
||||
@@ -35,6 +38,7 @@ export function ShareDialog({
|
||||
headphoneIdx,
|
||||
eqUi,
|
||||
peqCardLabels,
|
||||
mac,
|
||||
onImportEq,
|
||||
}: {
|
||||
open: boolean;
|
||||
@@ -43,7 +47,8 @@ export function ShareDialog({
|
||||
headphoneIdx: number;
|
||||
eqUi: PeqEqUi;
|
||||
peqCardLabels: PeqCardLabels;
|
||||
onImportEq: (data: ImportedEqData) => void;
|
||||
mac: string;
|
||||
onImportEq: (data: ImportedEqData, shareCode: string) => void | Promise<void>;
|
||||
}) {
|
||||
const [shareDialogTab, setShareDialogTab] = useState<"share" | "import" | "myShares">("share");
|
||||
const [shareSelectedIdx, setShareSelectedIdx] = useState<number>(headphoneIdx);
|
||||
@@ -51,8 +56,12 @@ export function ShareDialog({
|
||||
const [shareGenerating, setShareGenerating] = useState(false);
|
||||
const [importCodeInputs, setImportCodeInputs] = useState<string[]>(["", "", "", "", ""]);
|
||||
const [importQuerying, setImportQuerying] = useState(false);
|
||||
const [importSaving, setImportSaving] = useState(false);
|
||||
const [importPresetName, setImportPresetName] = useState("");
|
||||
const [queriedShareCode, setQueriedShareCode] = useState("");
|
||||
const [importedEqData, setImportedEqData] = useState<ImportedEqData | null>(null);
|
||||
const [mySharesList, setMySharesList] = useState<Array<{ code: string; name: string }>>([]);
|
||||
const [shareCodeExpireAt, setShareCodeExpireAt] = useState<string | null>(null);
|
||||
const [mySharesList, setMySharesList] = useState<Array<{ share_code: string; expire_at: string; eq_data: Record<string, unknown> }>>([]);
|
||||
const [mySharesLoading, setMySharesLoading] = useState(false);
|
||||
|
||||
if (!open) return null;
|
||||
@@ -131,25 +140,14 @@ export function ShareDialog({
|
||||
void (async () => {
|
||||
setMySharesLoading(true);
|
||||
try {
|
||||
// ── TODO: 伪代码 — 调用查询我的分享列表接口 ──
|
||||
// const res = await fetch("/api/eq/share/list");
|
||||
// if (!res.ok) {
|
||||
// toast.error(eqUi.mySharesLoadFail);
|
||||
// return;
|
||||
// }
|
||||
// const data = await res.json();
|
||||
// setMySharesList(data.list);
|
||||
|
||||
// 模拟接口延迟
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
// 模拟返回分享列表
|
||||
setMySharesList([
|
||||
{ code: "AK7NR", name: "Sennheiser HD 600 (AutoEQ)" },
|
||||
{ code: "P3WQM", name: "Beyerdynamic DT 880" },
|
||||
{ code: "Z5XKL", name: "Hifiman HE400i" },
|
||||
]);
|
||||
const res = await listShareCodes(mac);
|
||||
if (res.code !== 200) {
|
||||
toast.error(eqUi.mySharesLoadFail);
|
||||
return;
|
||||
}
|
||||
setMySharesList(res.share_codes ?? []);
|
||||
} catch {
|
||||
// TODO: 错误处理
|
||||
toast.error(eqUi.mySharesLoadFail);
|
||||
} finally {
|
||||
setMySharesLoading(false);
|
||||
}
|
||||
@@ -220,33 +218,31 @@ export function ShareDialog({
|
||||
if (shareGenerating) return;
|
||||
setShareGenerating(true);
|
||||
setShareCode(null);
|
||||
setShareCodeExpireAt(null);
|
||||
try {
|
||||
// ── TODO: 伪代码 — 调用分享接口 ──
|
||||
// const selectedPeq = peqItems[shareSelectedIdx];
|
||||
// const res = await fetch("/api/eq/share", {
|
||||
// method: "POST",
|
||||
// headers: { "Content-Type": "application/json" },
|
||||
// body: JSON.stringify({
|
||||
// brand: selectedPeq.brand,
|
||||
// model: selectedPeq.model,
|
||||
// target: selectedPeq.form,
|
||||
// filters: selectedPeq.filters,
|
||||
// preamp: selectedPeq.preamp,
|
||||
// }),
|
||||
// });
|
||||
// const data = await res.json();
|
||||
// setShareCode(data.shareCode);
|
||||
|
||||
// 模拟接口延迟
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
// 模拟返回 5 位随机分享码
|
||||
const fakeCode = Array.from(
|
||||
{ length: 5 },
|
||||
() => "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"[Math.floor(Math.random() * 32)],
|
||||
).join("");
|
||||
setShareCode(fakeCode);
|
||||
const selectedPeq = peqItems[shareSelectedIdx];
|
||||
if (!selectedPeq) {
|
||||
toast.error(eqUi.shareSelectHint);
|
||||
return;
|
||||
}
|
||||
const eqData: Record<string, unknown> = {
|
||||
name: selectedPeq.name,
|
||||
brand: selectedPeq.brand ?? "",
|
||||
model: selectedPeq.model ?? "",
|
||||
form: selectedPeq.form ?? "",
|
||||
filters: selectedPeq.filters ?? [],
|
||||
autoPre: selectedPeq.autoPre ?? 0,
|
||||
preamp: selectedPeq.preamp ?? 0,
|
||||
};
|
||||
const res = await createShareCode(mac, eqData);
|
||||
if (res.code !== 200) {
|
||||
toast.error(res.msg || eqUi.shareCreateFail);
|
||||
return;
|
||||
}
|
||||
setShareCode(res.share_code ?? "");
|
||||
setShareCodeExpireAt(res.expire_at ?? null);
|
||||
} catch {
|
||||
// TODO: 错误处理
|
||||
toast.error(eqUi.shareCreateFail);
|
||||
} finally {
|
||||
setShareGenerating(false);
|
||||
}
|
||||
@@ -293,6 +289,10 @@ export function ShareDialog({
|
||||
<Copy size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{/* expiration time */}
|
||||
{shareCodeExpireAt && (
|
||||
<p className="mt-2 text-[11px] text-white/30">{eqUi.shareExpireAt}: {shareCodeExpireAt}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -326,6 +326,8 @@ export function ShareDialog({
|
||||
next[i] = val;
|
||||
setImportCodeInputs(next);
|
||||
setImportedEqData(null);
|
||||
setImportPresetName("");
|
||||
setQueriedShareCode("");
|
||||
// auto-focus next box
|
||||
if (val && i < 4) {
|
||||
const el = e.target.nextElementSibling as HTMLInputElement | null;
|
||||
@@ -339,6 +341,8 @@ export function ShareDialog({
|
||||
next[i - 1] = "";
|
||||
setImportCodeInputs(next);
|
||||
setImportedEqData(null);
|
||||
setImportPresetName("");
|
||||
setQueriedShareCode("");
|
||||
const prev = (e.target as HTMLElement).previousElementSibling as HTMLInputElement | null;
|
||||
prev?.focus();
|
||||
}
|
||||
@@ -353,6 +357,8 @@ export function ShareDialog({
|
||||
}
|
||||
setImportCodeInputs(next);
|
||||
setImportedEqData(null);
|
||||
setImportPresetName("");
|
||||
setQueriedShareCode("");
|
||||
// focus last filled or last box
|
||||
const focusIdx = Math.min(text.length, 4);
|
||||
const inputs = (e.target as HTMLElement).parentElement?.querySelectorAll("input");
|
||||
@@ -380,36 +386,34 @@ export function ShareDialog({
|
||||
const code = importCodeInputs.join("");
|
||||
setImportQuerying(true);
|
||||
setImportedEqData(null);
|
||||
setImportPresetName("");
|
||||
setQueriedShareCode("");
|
||||
try {
|
||||
// ── TODO: 伪代码 — 调用导入查询接口 ──
|
||||
// const res = await fetch(`/api/eq/share/${code}`);
|
||||
// if (!res.ok) {
|
||||
// toast.error(eqUi.importCodeNotFound);
|
||||
// return;
|
||||
// }
|
||||
// const data = await res.json();
|
||||
// setImportedEqData({
|
||||
// name: data.name,
|
||||
// brand: data.brand,
|
||||
// model: data.model,
|
||||
// form: data.target,
|
||||
// filters: data.filters,
|
||||
// preamp: data.preamp,
|
||||
// });
|
||||
|
||||
// 模拟接口延迟
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
// 模拟返回 EQ 数据
|
||||
const res = await queryShareCode(code);
|
||||
if (res.code !== 200) {
|
||||
toast.error(eqUi.importCodeNotFound);
|
||||
return;
|
||||
}
|
||||
const eq = res.eq_data;
|
||||
if (!eq) {
|
||||
toast.error(eqUi.importCodeNotFound);
|
||||
return;
|
||||
}
|
||||
const fetchedName = (eq.name as string) || "";
|
||||
setImportedEqData({
|
||||
name: "Sennheiser HD 600 (AutoEQ)",
|
||||
brand: "Sennheiser",
|
||||
model: "HD 600",
|
||||
form: "over-ear",
|
||||
filters: [],
|
||||
preamp: -5.2,
|
||||
name: fetchedName,
|
||||
brand: (eq.brand as string) || undefined,
|
||||
model: (eq.model as string) || undefined,
|
||||
target: (eq.target as string) || undefined,
|
||||
form: (eq.form as string) || undefined,
|
||||
filters: eq.filters,
|
||||
preamp: eq.preamp as number | undefined,
|
||||
autoPre: eq.autoPre as number | undefined,
|
||||
});
|
||||
setImportPresetName(fetchedName);
|
||||
setQueriedShareCode(code);
|
||||
} catch {
|
||||
// TODO: 错误处理
|
||||
toast.error(eqUi.importCodeNotFound);
|
||||
} finally {
|
||||
setImportQuerying(false);
|
||||
}
|
||||
@@ -427,25 +431,48 @@ export function ShareDialog({
|
||||
|
||||
{/* imported EQ result */}
|
||||
{importedEqData && (
|
||||
<div className="mt-4 rounded-[10px] p-3" style={{ background: "rgba(0,255,246,0.06)", border: "1px solid rgba(0,255,246,0.2)" }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[12px] text-white/40 font-medium mb-0.5">{eqUi.importEqName}</p>
|
||||
<p className="text-[14px] text-white/90 font-semibold truncate">{importedEqData.name}</p>
|
||||
<div className="mt-4 rounded-[10px] p-3 space-y-3" style={{ background: "rgba(0,255,246,0.06)", border: "1px solid rgba(0,255,246,0.2)" }}>
|
||||
<div>
|
||||
<p className="text-[12px] text-white/40 font-medium mb-1.5">{eqUi.importEqName}</p>
|
||||
<input
|
||||
value={importPresetName}
|
||||
onChange={(e) => setImportPresetName(e.target.value)}
|
||||
disabled={importSaving}
|
||||
className="h-10 w-full rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[14px] text-white/90 outline-none placeholder:text-white/30 focus:border-[#00FFF6]/40 disabled:opacity-60"
|
||||
placeholder={eqUi.importEqName}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 ml-3 rounded-full px-4 py-2 text-[13px] font-semibold text-black bg-[#00FFF6] hover:brightness-95 active:scale-[0.98] transition-all"
|
||||
disabled={importSaving || !importPresetName.trim()}
|
||||
className={cn(
|
||||
"w-full rounded-full py-2.5 text-[13px] font-semibold text-black transition-all active:scale-[0.98]",
|
||||
importSaving || !importPresetName.trim()
|
||||
? "bg-[#00FFF6]/40 cursor-not-allowed"
|
||||
: "bg-[#00FFF6] hover:brightness-95",
|
||||
)}
|
||||
onClick={() => {
|
||||
// ── TODO: 伪代码 — 导入 EQ 到预设列表 ──
|
||||
// onImportEq will handle the parent-level state changes
|
||||
onImportEq(importedEqData);
|
||||
if (importSaving || !importedEqData || !queriedShareCode) return;
|
||||
const nextName = importPresetName.trim();
|
||||
if (!nextName) return;
|
||||
setImportSaving(true);
|
||||
void Promise.resolve(
|
||||
onImportEq({ ...importedEqData, name: nextName }, queriedShareCode),
|
||||
).finally(() => {
|
||||
setImportSaving(false);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{eqUi.importButton}
|
||||
{importSaving ? (
|
||||
<span className="flex items-center justify-center gap-1.5">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
{eqUi.importSaving}
|
||||
</span>
|
||||
) : (
|
||||
eqUi.importButton
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -471,7 +498,7 @@ export function ShareDialog({
|
||||
>
|
||||
{mySharesList.map((item, idx) => (
|
||||
<div
|
||||
key={`${item.code}-${idx}`}
|
||||
key={`${item.share_code}-${idx}`}
|
||||
className="flex items-center gap-3 px-3 py-2.5 border-b border-white/[0.06] last:border-b-0"
|
||||
>
|
||||
{/* share code */}
|
||||
@@ -479,7 +506,7 @@ export function ShareDialog({
|
||||
className="shrink-0 flex gap-0.5"
|
||||
title={eqUi.mySharesCode}
|
||||
>
|
||||
{item.code.split("").map((ch, ci) => (
|
||||
{item.share_code.split("").map((ch: string, ci: number) => (
|
||||
<span
|
||||
key={ci}
|
||||
className="w-[18px] h-[22px] rounded-[4px] flex items-center justify-center text-[11px] font-bold text-[#00FFF6]"
|
||||
@@ -489,14 +516,23 @@ export function ShareDialog({
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/* EQ name */}
|
||||
<div className="min-w-0 flex-1 truncate text-[13px] text-white/85">{item.name}</div>
|
||||
{/* EQ name + expiration */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] text-white/85">
|
||||
{(item.eq_data?.name as string) ?? "—"}
|
||||
</div>
|
||||
{item.expire_at && (
|
||||
<p className="mt-0.5 truncate text-[11px] text-white/35">
|
||||
{eqUi.shareExpireAt}: {item.expire_at}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* copy code button */}
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 w-7 h-7 rounded-[6px] flex items-center justify-center text-[#00FFF6]/70 active:scale-95 transition-transform"
|
||||
style={{ background: "rgba(0,255,246,0.08)", border: "1px solid rgba(0,255,246,0.15)" }}
|
||||
onClick={() => void copyToClipboard(item.code)}
|
||||
onClick={() => void copyToClipboard(item.share_code)}
|
||||
>
|
||||
<Copy size={13} />
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PeqFilter, PeqState } from "@/lib/luxsinApi";
|
||||
import { normalizePeqFiltersForSubmit } from "@/lib/luxsinApi";
|
||||
import { getFilterShortName, getFilterType } from "@/lib/peqAudio";
|
||||
import {
|
||||
BAND_FREQ_MIN,
|
||||
@@ -197,6 +198,19 @@ export function getUniquePresetName(
|
||||
return `${base}_${index}`;
|
||||
}
|
||||
|
||||
/** Normalize shared/imported EQ filters into device PeqFilter[]. */
|
||||
export function importedFiltersToPeqFilters(filters: unknown): PeqFilter[] {
|
||||
return normalizePeqFiltersForSubmit(filters).map((f) => {
|
||||
const item = f as Record<string, unknown>;
|
||||
return {
|
||||
fc: Number(item.fc ?? item.freq ?? item.frequency ?? 1000),
|
||||
gain: Number(item.gain ?? 0),
|
||||
q: Number(item.q ?? 1),
|
||||
type: getFilterType(item.type as string | number),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Convert UI band → device PeqFilter ── */
|
||||
export function bandToPeqFilter(b: {
|
||||
freq: number;
|
||||
|
||||
Reference in New Issue
Block a user