version>=2008 使用了新的重命名接口、EQ 排序功能

This commit is contained in:
eafonyang
2026-07-07 17:15:01 +08:00
parent 0cb0ca0787
commit bfc4b45ca3
10 changed files with 432 additions and 67 deletions
+6
View File
@@ -11,6 +11,8 @@ export const FIRMWARE_FEATURE_MIN_VERSION = {
subwooferFullRange: 2001, subwooferFullRange: 2001,
/** Hearing compensation section on the Effects page. */ /** Hearing compensation section on the Effects page. */
hearingCompensation: 2007, hearingCompensation: 2007,
/** PEQ preset rename (peqRename) and reorder (peqSoft) in manage dialog. */
peqPresetRenameAndSort: 2008,
} as const; } as const;
export type FirmwareFeatureKey = keyof typeof FIRMWARE_FEATURE_MIN_VERSION; export type FirmwareFeatureKey = keyof typeof FIRMWARE_FEATURE_MIN_VERSION;
@@ -26,3 +28,7 @@ export function isFirmwareFeatureAvailable(
export function getBootSoundMaxIndex(firmwareVersion: number): number { export function getBootSoundMaxIndex(firmwareVersion: number): number {
return isFirmwareFeatureAvailable("bootSoundExtendedSteps", firmwareVersion) ? 10 : 6; return isFirmwareFeatureAvailable("bootSoundExtendedSteps", firmwareVersion) ? 10 : 6;
} }
export function supportsPeqPresetRenameAndSort(firmwareVersion: number): boolean {
return isFirmwareFeatureAvailable("peqPresetRenameAndSort", firmwareVersion);
}
+46
View File
@@ -240,6 +240,20 @@ export interface PeqApplyPayload {
peqApply: PeqPresetBody; peqApply: PeqPresetBody;
} }
/** POST body for `/dev/info.cgi` — rename an existing PEQ preset. */
export interface PeqRenamePayload {
peqRename: {
oldName: string;
newName: string;
};
}
/** POST body for `/dev/info.cgi` — reorder PEQ presets. */
export interface PeqSoftPayload {
peqSoft: string[];
peqSelect: number;
}
// ============================================================ // ============================================================
// Input/Output Labels // Input/Output Labels
// ============================================================ // ============================================================
@@ -383,6 +397,38 @@ export class LuxsinAPI {
} }
} }
/** Rename a headphone PEQ profile. */
async renamePeq(oldName: string, newName: string): Promise<void> {
const payload: PeqRenamePayload = { peqRename: { oldName, newName } };
const encoded = encodeCustomBase64(JSON.stringify(payload));
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: form.toString(),
});
if (!response.ok) {
throw new Error(`PEQ rename failed: ${response.status}`);
}
}
/** Reorder headphone PEQ profiles. */
async sortPeq(names: string[], peqSelect: number): Promise<void> {
const payload: PeqSoftPayload = { peqSoft: names, peqSelect };
const encoded = encodeCustomBase64(JSON.stringify(payload));
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: form.toString(),
});
if (!response.ok) {
throw new Error(`PEQ sort failed: ${response.status}`);
}
}
setVolume(volume: number) { return this.setSetting({ volume }); } setVolume(volume: number) { return this.setSetting({ volume }); }
setInput(input: number) { return this.setSetting({ input }); } setInput(input: number) { return this.setSetting({ input }); }
setOutput(output: number) { return this.setSetting({ output }); } setOutput(output: number) { return this.setSetting({ output }); }
+7
View File
@@ -281,6 +281,13 @@
"batchDelete": "Auswahl löschen", "batchDelete": "Auswahl löschen",
"noPresets": "Keine Voreinstellungen", "noPresets": "Keine Voreinstellungen",
"renamePreset": "Umbenennen", "renamePreset": "Umbenennen",
"movePresetUp": "Nach oben",
"movePresetDown": "Nach unten",
"confirmOrder": "Reihenfolge bestätigen",
"moveToFirst": "Ganz nach oben",
"moveToLast": "Ganz nach unten",
"toastOrderSaved": "Reihenfolge gespeichert",
"toastOrderFail": "Reihenfolge konnte nicht gespeichert werden",
"deletePreset": "Löschen", "deletePreset": "Löschen",
"toastRenamed": "Umbenannt", "toastRenamed": "Umbenannt",
"toastRenameFail": "Umbenennung fehlgeschlagen", "toastRenameFail": "Umbenennung fehlgeschlagen",
+7
View File
@@ -281,6 +281,13 @@
"batchDelete": "Delete selected", "batchDelete": "Delete selected",
"noPresets": "No presets", "noPresets": "No presets",
"renamePreset": "Rename", "renamePreset": "Rename",
"movePresetUp": "Move up",
"movePresetDown": "Move down",
"confirmOrder": "Apply Order",
"moveToFirst": "Move Up To First",
"moveToLast": "Move Down To Last",
"toastOrderSaved": "Order saved",
"toastOrderFail": "Failed to save order",
"deletePreset": "Delete", "deletePreset": "Delete",
"toastRenamed": "Renamed", "toastRenamed": "Renamed",
"toastRenameFail": "Rename failed", "toastRenameFail": "Rename failed",
+7
View File
@@ -281,6 +281,13 @@
"batchDelete": "Eliminar selección", "batchDelete": "Eliminar selección",
"noPresets": "Sin presets", "noPresets": "Sin presets",
"renamePreset": "Renombrar", "renamePreset": "Renombrar",
"movePresetUp": "Subir",
"movePresetDown": "Bajar",
"confirmOrder": "Confirmar orden",
"moveToFirst": "Mover al inicio",
"moveToLast": "Mover al final",
"toastOrderSaved": "Orden guardado",
"toastOrderFail": "No se pudo guardar el orden",
"deletePreset": "Eliminar", "deletePreset": "Eliminar",
"toastRenamed": "Renombrado", "toastRenamed": "Renombrado",
"toastRenameFail": "Error al renombrar", "toastRenameFail": "Error al renombrar",
+7
View File
@@ -281,6 +281,13 @@
"batchDelete": "Supprimer la sélection", "batchDelete": "Supprimer la sélection",
"noPresets": "Aucun préréglage", "noPresets": "Aucun préréglage",
"renamePreset": "Renommer", "renamePreset": "Renommer",
"movePresetUp": "Monter",
"movePresetDown": "Descendre",
"confirmOrder": "Confirmer l'ordre",
"moveToFirst": "Déplacer en premier",
"moveToLast": "Déplacer en dernier",
"toastOrderSaved": "Ordre enregistré",
"toastOrderFail": "Échec de l'enregistrement de l'ordre",
"deletePreset": "Supprimer", "deletePreset": "Supprimer",
"toastRenamed": "Renommé", "toastRenamed": "Renommé",
"toastRenameFail": "Échec du renommage", "toastRenameFail": "Échec du renommage",
+7
View File
@@ -274,6 +274,13 @@
"batchDelete": "批量刪除", "batchDelete": "批量刪除",
"noPresets": "暫無預設", "noPresets": "暫無預設",
"renamePreset": "重新命名", "renamePreset": "重新命名",
"movePresetUp": "上移",
"movePresetDown": "下移",
"confirmOrder": "確認順序",
"moveToFirst": "置頂",
"moveToLast": "置底",
"toastOrderSaved": "順序已儲存",
"toastOrderFail": "儲存順序失敗",
"deletePreset": "刪除", "deletePreset": "刪除",
"toastRenamed": "已重新命名", "toastRenamed": "已重新命名",
"toastRenameFail": "重新命名失敗", "toastRenameFail": "重新命名失敗",
+7
View File
@@ -274,6 +274,13 @@
"batchDelete": "批量删除", "batchDelete": "批量删除",
"noPresets": "暂无预设", "noPresets": "暂无预设",
"renamePreset": "重命名", "renamePreset": "重命名",
"movePresetUp": "上移",
"movePresetDown": "下移",
"confirmOrder": "确认顺序",
"moveToFirst": "置顶",
"moveToLast": "置底",
"toastOrderSaved": "顺序已保存",
"toastOrderFail": "保存顺序失败",
"deletePreset": "删除", "deletePreset": "删除",
"toastRenamed": "已重命名", "toastRenamed": "已重命名",
"toastRenameFail": "重命名失败", "toastRenameFail": "重命名失败",
+61 -26
View File
@@ -30,12 +30,14 @@ import { toast } from "sonner";
import { import {
fetchLuxsinAudioCurve, fetchLuxsinAudioCurve,
normalizePeqFiltersForSubmit, normalizePeqFiltersForSubmit,
parseFirmwareVersion,
type PeqFilter, type PeqFilter,
type PeqApplyPayload, type PeqApplyPayload,
type PeqChangePayload, type PeqChangePayload,
type PeqState, type PeqState,
buildPeqPresetBody, buildPeqPresetBody,
} from "@/lib/luxsinApi"; } from "@/lib/luxsinApi";
import { supportsPeqPresetRenameAndSort } from "@/config/firmwareFeatures";
import { getFilterType, getFilterShortName } from "@/lib/peqAudio"; import { getFilterType, getFilterShortName } from "@/lib/peqAudio";
import localeZh from "@/locales/data-zh.json"; import localeZh from "@/locales/data-zh.json";
import localeEn from "@/locales/data-en.json"; import localeEn from "@/locales/data-en.json";
@@ -113,6 +115,12 @@ export default function EQPage() {
return (peq.eqUi ?? (localeEn.peq as typeof localeEn.peq).eqUi) as PeqEqUi; return (peq.eqUi ?? (localeEn.peq as typeof localeEn.peq).eqUi) as PeqEqUi;
}, [deviceState?.language]); }, [deviceState?.language]);
const firmwareVersion = useMemo(
() => parseFirmwareVersion(deviceState?.version),
[deviceState?.version],
);
const peqReorderEnabled = supportsPeqPresetRenameAndSort(firmwareVersion);
const peqCardLabels = useMemo(() => { const peqCardLabels = useMemo(() => {
const pack = resolveLocalePack(deviceState?.language); const pack = resolveLocalePack(deviceState?.language);
return pack.peq ?? localeEn.peq; return pack.peq ?? localeEn.peq;
@@ -520,19 +528,21 @@ export default function EQPage() {
peqSelect?: number; peqSelect?: number;
filters?: PeqFilter[]; filters?: PeqFilter[];
}) => { }) => {
const resolvedPeqSelect =
remote.peqSelect ?? deviceState?.peqSelect ?? peqState?.peqSelect ?? 0;
if (remote.peq !== undefined) { if (remote.peq !== undefined) {
syncPeqCatalog({ syncPeqCatalog({
filters: remote.filters ?? peqState?.filters ?? [], filters: remote.filters ?? peqState?.filters ?? [],
peq: remote.peq, peq: remote.peq,
peqSelect: peqSelect: resolvedPeqSelect,
remote.peqSelect ?? deviceState?.peqSelect ?? peqState?.peqSelect,
}); });
} }
lastPeqCatalogSyncKeyRef.current = buildPeqCatalogSyncKey( lastPeqCatalogSyncKeyRef.current = buildPeqCatalogSyncKey(
remote, remote,
deviceState?.peqSelect resolvedPeqSelect
); );
syncPeqCatalogFromState(remote, deviceState?.peqSelect); syncPeqCatalogFromState(remote, resolvedPeqSelect);
const items = remote.peq ?? []; const items = remote.peq ?? [];
if (items.length === 0) { if (items.length === 0) {
@@ -542,7 +552,7 @@ export default function EQPage() {
} }
const nextIdx = Math.min( const nextIdx = Math.min(
Math.max(deviceState?.peqSelect ?? remote.peqSelect ?? 0, 0), Math.max(resolvedPeqSelect, 0),
items.length - 1 items.length - 1
); );
const nextBands = normalizeFiltersFromPeq( const nextBands = normalizeFiltersFromPeq(
@@ -901,10 +911,11 @@ export default function EQPage() {
// msgCount 轮询更新 peqState 后,同步耳机列表与当前选中项 // msgCount 轮询更新 peqState 后,同步耳机列表与当前选中项
useEffect(() => { useEffect(() => {
if (!peqState?.peq) return; if (!peqState?.peq) return;
const key = buildPeqCatalogSyncKey(peqState, deviceState?.peqSelect); const resolvedPeqSelect = peqState.peqSelect ?? deviceState?.peqSelect;
const key = buildPeqCatalogSyncKey(peqState, resolvedPeqSelect);
if (!key || key === lastPeqCatalogSyncKeyRef.current) return; if (!key || key === lastPeqCatalogSyncKeyRef.current) return;
lastPeqCatalogSyncKeyRef.current = key; lastPeqCatalogSyncKeyRef.current = key;
syncPeqCatalogFromState(peqState, deviceState?.peqSelect); syncPeqCatalogFromState(peqState, resolvedPeqSelect);
}, [peqState, deviceState?.peqSelect, syncPeqCatalogFromState]); }, [peqState, deviceState?.peqSelect, syncPeqCatalogFromState]);
// 切换耳机时默认回到 A 组对比 // 切换耳机时默认回到 A 组对比
@@ -1968,6 +1979,7 @@ export default function EQPage() {
open={isPresetManageDialogOpen} open={isPresetManageDialogOpen}
items={peqItems} items={peqItems}
selectedIdx={headphoneIdx} selectedIdx={headphoneIdx}
reorderEnabled={peqReorderEnabled}
onClose={() => setIsPresetManageDialogOpen(false)} onClose={() => setIsPresetManageDialogOpen(false)}
eqUi={eqUi} eqUi={eqUi}
onDeletePresets={async (names) => { onDeletePresets={async (names) => {
@@ -1995,9 +2007,25 @@ export default function EQPage() {
}} }}
onRenamePreset={async (oldName, newName, item) => { onRenamePreset={async (oldName, newName, item) => {
try { try {
// normalizePeqFiltersForSubmit returns filters with string type values if (isDemoMode || !api) {
// (e.g. "LOW_SHELF", "PEAKING") — but the device expects numeric type. const nextItems = peqItems.map(it =>
// Must convert with getFilterType() before submitting to info.cgi. it.name === oldName ? { ...it, name: newName } : it
);
forgetPeqPresetCache(oldName);
applyPeqStateToUI({ peq: nextItems, peqSelect: headphoneIdx });
toast.success(eqUi.toastRenamed);
return true;
}
if (supportsPeqPresetRenameAndSort(firmwareVersion)) {
await api.renamePeq(oldName, newName);
forgetPeqPresetCache(oldName);
const latest = await fetchEqSyncPeq(api, "renamePreset");
applyPeqStateToUI(latest);
toast.success(eqUi.toastRenamed);
return true;
}
const rawFilters = normalizePeqFiltersForSubmit(item.filters); const rawFilters = normalizePeqFiltersForSubmit(item.filters);
const filters = rawFilters.map(f => ({ const filters = rawFilters.map(f => ({
...f, ...f,
@@ -2017,28 +2045,12 @@ export default function EQPage() {
}, },
}; };
if (isDemoMode || !api) {
const nextItems = peqItems.map(it =>
it.name === oldName ? { ...it, name: newName } : it
);
forgetPeqPresetCache(oldName);
applyPeqStateToUI({ peq: nextItems, peqSelect: headphoneIdx });
toast.success(eqUi.toastRenamed);
return true;
}
// Step 1: peqChange — create new preset with new name + same EQ data
// Use api.upgradePeqChange directly (NOT the DeviceContext wrapper)
// to avoid applyPeqFiltersToState side-effect that overwrites current UI bands.
await api.upgradePeqChange(payload); await api.upgradePeqChange(payload);
await new Promise(r => setTimeout(r, 300)); await new Promise(r => setTimeout(r, 300));
// Step 2: peqRemove — delete the old preset
forgetPeqPresetCache(oldName); forgetPeqPresetCache(oldName);
await api.removePeq([oldName]); await api.removePeq([oldName]);
await new Promise(r => setTimeout(r, 300)); await new Promise(r => setTimeout(r, 300));
// Step 3: sync latest state from device
const latest = await fetchEqSyncPeq(api, "renamePreset"); const latest = await fetchEqSyncPeq(api, "renamePreset");
applyPeqStateToUI(latest); applyPeqStateToUI(latest);
toast.success(eqUi.toastRenamed); toast.success(eqUi.toastRenamed);
return true; return true;
@@ -2047,6 +2059,29 @@ export default function EQPage() {
return false; return false;
} }
}} }}
onConfirmOrder={async (names, peqSelect) => {
try {
if (isDemoMode || !api) {
const byName = new Map(peqItems.map(it => [it.name, it]));
const nextItems = names
.map(name => byName.get(name))
.filter((it): it is NonNullable<typeof it> => !!it);
applyPeqStateToUI({ peq: nextItems, peqSelect });
toast.success(eqUi.toastOrderSaved);
return true;
}
await api.sortPeq(names, peqSelect);
const latest = await fetchEqSyncPeq(api, "sortPresets");
const nextSelect = latest.peqSelect ?? peqSelect;
applyPeqStateToUI({ ...latest, peqSelect: nextSelect });
updateSetting({ peqSelect: nextSelect });
toast.success(eqUi.toastOrderSaved);
return true;
} catch {
toast.error(eqUi.toastOrderFail);
return false;
}
}}
/> />
<BrandDrawer <BrandDrawer
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { X, Trash2, Pencil, Check, Loader2 } from "lucide-react"; import { X, Trash2, Pencil, Check, Loader2, ArrowBigDown, ArrowBigUp, ArrowBigUpDash, ArrowBigDownDash } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { toast } from "sonner"; import { toast } from "sonner";
import type { PeqEqUi } from "../constants"; import type { PeqEqUi } from "../constants";
@@ -61,21 +62,26 @@ type PeqItem = {
* Behavior: * Behavior:
* - Delete (single / batch): calls onDeletePresets callback with preset names, * - Delete (single / batch): calls onDeletePresets callback with preset names,
* which should call info.cgi with { peqRemove: names } then sync. * which should call info.cgi with { peqRemove: names } then sync.
* - Rename: calls onRenamePreset(oldName, newName, item) callback which * - Rename: calls onRenamePreset(oldName, newName, item) callback.
* should: 1) peqChange with new name + same EQ data 2) peqRemove old name. * Firmware >= threshold: peqRename. Below threshold: peqChange then peqRemove.
* - Reorder (when reorderEnabled): up/down buttons + apply order → peqSoft.
*/ */
export function PeqPresetManageDialog({ export function PeqPresetManageDialog({
open, open,
items, items,
selectedIdx, selectedIdx,
reorderEnabled,
onClose, onClose,
eqUi, eqUi,
onDeletePresets, onDeletePresets,
onRenamePreset, onRenamePreset,
onConfirmOrder,
}: { }: {
open: boolean; open: boolean;
items: PeqItem[]; items: PeqItem[];
selectedIdx: number; selectedIdx: number;
/** When true: show reorder controls and apply-order button (firmware-gated in parent). */
reorderEnabled: boolean;
onClose: () => void; onClose: () => void;
eqUi: PeqEqUi; eqUi: PeqEqUi;
/** /**
@@ -84,24 +90,36 @@ export function PeqPresetManageDialog({
*/ */
onDeletePresets: (names: string[]) => Promise<boolean>; onDeletePresets: (names: string[]) => Promise<boolean>;
/** /**
* Called to rename a preset. Implementation should: * Called to rename a preset. Implementation is version-dependent:
* 1) upgradePeqChange({ peqChange: { name: newName, filters, ...rest } }) * - firmware >= 29: api.renamePeq(oldName, newName) then sync
* 2) api.removePeq([oldName]) * - firmware < 29: peqChange + peqRemove then sync
* 3) fetchEqSyncPeq + applyPeqStateToUI
* Return true on success, false on failure. * Return true on success, false on failure.
*/ */
onRenamePreset: (oldName: string, newName: string, item: PeqItem) => Promise<boolean>; onRenamePreset: (oldName: string, newName: string, item: PeqItem) => Promise<boolean>;
/**
* Called to persist a new preset order (firmware >= 29).
* `names` is the full ordered list; `peqSelect` is the index of the currently selected preset.
*/
onConfirmOrder: (names: string[], peqSelect: number) => Promise<boolean>;
}) { }) {
const [checked, setChecked] = useState<Record<number, boolean>>({}); const [checked, setChecked] = useState<Record<number, boolean>>({});
const [editingIdx, setEditingIdx] = useState<number | null>(null); const [editingIdx, setEditingIdx] = useState<number | null>(null);
const [editingName, setEditingName] = useState(""); const [editingName, setEditingName] = useState("");
const [draftItems, setDraftItems] = useState<PeqItem[]>(items);
const [draftSelectedIdx, setDraftSelectedIdx] = useState(selectedIdx);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [renaming, setRenaming] = useState(false); const [renaming, setRenaming] = useState(false);
const [confirmingOrder, setConfirmingOrder] = useState(false);
const [deleteConfirmIdxs, setDeleteConfirmIdxs] = useState<number[] | null>( const [deleteConfirmIdxs, setDeleteConfirmIdxs] = useState<number[] | null>(
null, null,
); );
const prevOpenRef = useRef(false);
const displayItems = reorderEnabled ? draftItems : items;
const displaySelectedIdx = reorderEnabled ? draftSelectedIdx : selectedIdx;
const checkedIdxs = useMemo( const checkedIdxs = useMemo(
() => () =>
Object.entries(checked) Object.entries(checked)
@@ -110,11 +128,31 @@ export function PeqPresetManageDialog({
[checked] [checked]
); );
const allChecked = items.length > 0 && checkedIdxs.length === items.length; const singleCheckedIdx = checkedIdxs.length === 1 ? checkedIdxs[0] : null;
const allChecked = displayItems.length > 0 && checkedIdxs.length === displayItems.length;
const orderChanged = useMemo(() => {
if (!reorderEnabled) return false;
if (draftItems.length !== items.length) return true;
return draftItems.some((it, i) => it.name !== items[i]?.name);
}, [reorderEnabled, draftItems, items]);
useEffect(() => { useEffect(() => {
if (!open) setDeleteConfirmIdxs(null); if (!open) {
}, [open]); prevOpenRef.current = false;
setDeleteConfirmIdxs(null);
return;
}
if (!prevOpenRef.current) {
setDraftItems(items);
setDraftSelectedIdx(selectedIdx);
setChecked({});
setEditingIdx(null);
setEditingName("");
}
prevOpenRef.current = true;
}, [open, items, selectedIdx]);
if (!open) return null; if (!open) return null;
@@ -124,7 +162,7 @@ export function PeqPresetManageDialog({
return; return;
} }
const next: Record<number, boolean> = {}; const next: Record<number, boolean> = {};
for (let i = 0; i < items.length; i++) next[i] = true; for (let i = 0; i < displayItems.length; i++) next[i] = true;
setChecked(next); setChecked(next);
}; };
@@ -133,11 +171,49 @@ export function PeqPresetManageDialog({
}; };
const beginRename = (idx: number) => { const beginRename = (idx: number) => {
const name = items[idx]?.name ?? ""; const name = displayItems[idx]?.name ?? "";
setEditingIdx(idx); setEditingIdx(idx);
setEditingName(name); setEditingName(name);
}; };
const movePreset = (idx: number, direction: "up" | "down") => {
const targetIdx = direction === "up" ? idx - 1 : idx + 1;
if (targetIdx < 0 || targetIdx >= draftItems.length) return;
setDraftItems(prev => {
const next = [...prev];
[next[idx], next[targetIdx]] = [next[targetIdx], next[idx]];
return next;
});
setDraftSelectedIdx(prev => {
if (prev === idx) return targetIdx;
if (prev === targetIdx) return idx;
return prev;
});
setChecked({});
setEditingIdx(null);
setEditingName("");
};
const movePresetToEdge = (idx: number, edge: "first" | "last") => {
if (idx < 0 || idx >= draftItems.length) return;
const selectedName = draftItems[draftSelectedIdx]?.name ?? null;
const next = [...draftItems];
const [item] = next.splice(idx, 1);
if (edge === "first") next.unshift(item);
else next.push(item);
const newSelectedIdx = selectedName
? next.findIndex(it => it.name === selectedName)
: draftSelectedIdx;
setDraftItems(next);
setDraftSelectedIdx(newSelectedIdx >= 0 ? newSelectedIdx : 0);
setChecked({});
setEditingIdx(null);
setEditingName("");
};
const commitRename = async () => { const commitRename = async () => {
if (editingIdx === null) return; if (editingIdx === null) return;
const nextName = editingName.trim(); const nextName = editingName.trim();
@@ -149,14 +225,14 @@ export function PeqPresetManageDialog({
toast.error(eqUi.addPresetNameTooLong); toast.error(eqUi.addPresetNameTooLong);
return; return;
} }
const oldItem = items[editingIdx]; const oldItem = displayItems[editingIdx];
if (!oldItem) return; if (!oldItem) return;
if (nextName === oldItem.name) { if (nextName === oldItem.name) {
setEditingIdx(null); setEditingIdx(null);
setEditingName(""); setEditingName("");
return; return;
} }
if (items.some((it, i) => i !== editingIdx && it.name === nextName)) { if (displayItems.some((it, i) => i !== editingIdx && it.name === nextName)) {
toast.error(eqUi.renameNameExists); toast.error(eqUi.renameNameExists);
return; return;
} }
@@ -165,6 +241,11 @@ export function PeqPresetManageDialog({
try { try {
const ok = await onRenamePreset(oldItem.name, nextName, oldItem); const ok = await onRenamePreset(oldItem.name, nextName, oldItem);
if (ok) { if (ok) {
if (reorderEnabled) {
setDraftItems(prev =>
prev.map(it => (it.name === oldItem.name ? { ...it, name: nextName } : it))
);
}
setEditingIdx(null); setEditingIdx(null);
setEditingName(""); setEditingName("");
} }
@@ -175,7 +256,7 @@ export function PeqPresetManageDialog({
const requestDelete = (idxs: number[]) => { const requestDelete = (idxs: number[]) => {
const uniq = Array.from(new Set(idxs)).filter( const uniq = Array.from(new Set(idxs)).filter(
i => i >= 0 && i < items.length i => i >= 0 && i < displayItems.length
); );
if (uniq.length === 0) return; if (uniq.length === 0) return;
setDeleteConfirmIdxs(uniq); setDeleteConfirmIdxs(uniq);
@@ -186,13 +267,27 @@ export function PeqPresetManageDialog({
if (!idxs) return; if (!idxs) return;
setDeleteConfirmIdxs(null); setDeleteConfirmIdxs(null);
const names = idxs.map(i => items[i].name); const names = idxs.map(i => displayItems[i].name);
setDeleting(true); setDeleting(true);
try { try {
const ok = await onDeletePresets(names); const ok = await onDeletePresets(names);
if (ok) { if (ok) {
setChecked({}); setChecked({});
if (reorderEnabled) {
setDraftItems(prev => {
const next = prev.filter(it => !names.includes(it.name));
setDraftSelectedIdx(sel => {
let removedBefore = 0;
for (const i of idxs) {
if (i < sel) removedBefore++;
}
const adjusted = sel - removedBefore;
return Math.max(0, Math.min(adjusted, next.length - 1));
});
return next;
});
}
if (editingIdx !== null && idxs.includes(editingIdx)) { if (editingIdx !== null && idxs.includes(editingIdx)) {
setEditingIdx(null); setEditingIdx(null);
setEditingName(""); setEditingName("");
@@ -203,22 +298,63 @@ export function PeqPresetManageDialog({
} }
}; };
const executeConfirmOrder = async () => {
if (!orderChanged || confirmingOrder) return;
const names = draftItems.map(it => it.name);
setConfirmingOrder(true);
try {
const ok = await onConfirmOrder(names, draftSelectedIdx);
if (ok) {
setChecked({});
setEditingIdx(null);
setEditingName("");
}
} finally {
setConfirmingOrder(false);
}
};
const deleteConfirmDescription = const deleteConfirmDescription =
deleteConfirmIdxs === null deleteConfirmIdxs === null
? "" ? ""
: deleteConfirmIdxs.length === 1 : deleteConfirmIdxs.length === 1
? eqInterp(eqUi.deletePresetConfirm, { ? eqInterp(eqUi.deletePresetConfirm, {
name: items[deleteConfirmIdxs[0]]?.name ?? "", name: displayItems[deleteConfirmIdxs[0]]?.name ?? "",
}) })
: eqInterp(eqUi.batchDeleteConfirm, { : eqInterp(eqUi.batchDeleteConfirm, {
count: deleteConfirmIdxs.length, count: deleteConfirmIdxs.length,
}); });
const orderBusy = confirmingOrder || renaming || deleting;
const canMoveToFirst =
reorderEnabled &&
singleCheckedIdx !== null &&
singleCheckedIdx > 0 &&
!orderBusy;
const canMoveToLast =
reorderEnabled &&
singleCheckedIdx !== null &&
singleCheckedIdx < displayItems.length - 1 &&
!orderBusy;
const edgeMoveButtonClass = (enabled: boolean) =>
cn(
"w-8 h-8 rounded-[8px] flex items-center justify-center active:scale-95 transition-transform",
enabled
? "text-white/75 hover:bg-white/[0.08]"
: "opacity-30 cursor-not-allowed",
);
const edgeMoveButtonStyle = {
background: "rgba(255,255,255,0.06)",
border: "1px solid rgba(255,255,255,0.1)",
} as const;
return ( return (
<> <>
<div className="fixed inset-0 z-[140] flex items-center justify-center bg-black/65 px-4"> <div className="fixed inset-0 z-[140] flex items-center justify-center bg-black/65 px-4">
<div <div
className="w-full max-w-[520px] rounded-[14px] p-5" className="w-full max-w-[600px] rounded-[14px] p-5"
style={{ style={{
background: background:
"linear-gradient(180deg, rgba(34,36,40,0.98) 0%, rgba(24,26,30,0.98) 100%)", "linear-gradient(180deg, rgba(34,36,40,0.98) 0%, rgba(24,26,30,0.98) 100%)",
@@ -247,20 +383,78 @@ export function PeqPresetManageDialog({
{eqUi.selectAll} {eqUi.selectAll}
</label> </label>
<button <div className="flex items-center gap-2">
type="button" {reorderEnabled && (
disabled={checkedIdxs.length === 0 || deleting} <>
className={cn( <Tooltip>
"rounded-full px-3 py-1.5 text-[13px] font-semibold transition-all active:scale-[0.98] flex items-center gap-1.5", <TooltipTrigger asChild>
checkedIdxs.length === 0 || deleting <button
? "bg-white/10 text-white/30 cursor-not-allowed" type="button"
: "bg-red-500/15 text-red-300 hover:bg-red-500/20" aria-disabled={!canMoveToFirst}
className={edgeMoveButtonClass(canMoveToFirst)}
style={edgeMoveButtonStyle}
onClick={() => {
if (!canMoveToFirst || singleCheckedIdx === null) return;
movePresetToEdge(singleCheckedIdx, "first");
}}
>
<ArrowBigUpDash size={14} />
</button>
</TooltipTrigger>
<TooltipContent sideOffset={6} className="z-[200]">
{eqUi.moveToFirst}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-disabled={!canMoveToLast}
className={edgeMoveButtonClass(canMoveToLast)}
style={edgeMoveButtonStyle}
onClick={() => {
if (!canMoveToLast || singleCheckedIdx === null) return;
movePresetToEdge(singleCheckedIdx, "last");
}}
>
<ArrowBigDownDash size={14} />
</button>
</TooltipTrigger>
<TooltipContent sideOffset={6} className="z-[200]">
{eqUi.moveToLast}
</TooltipContent>
</Tooltip>
<button
type="button"
disabled={!orderChanged || orderBusy}
className={cn(
"rounded-full px-3 py-1.5 text-[13px] font-semibold transition-all active:scale-[0.98] flex items-center gap-1.5",
!orderChanged || orderBusy
? "bg-[#00FFF6]/15 text-[#00FFF6]/35 cursor-not-allowed"
: "bg-[#00FFF6]/15 text-[#00FFF6] hover:bg-[#00FFF6]/25"
)}
onClick={() => void executeConfirmOrder()}
>
{confirmingOrder && <Loader2 size={13} className="animate-spin" />}
{eqUi.confirmOrder}
</button>
</>
)} )}
onClick={() => requestDelete(checkedIdxs)} <button
> type="button"
{deleting && <Loader2 size={13} className="animate-spin" />} disabled={checkedIdxs.length === 0 || deleting}
{eqUi.batchDelete} className={cn(
</button> "rounded-full px-3 py-1.5 text-[13px] font-semibold transition-all active:scale-[0.98] flex items-center gap-1.5",
checkedIdxs.length === 0 || deleting
? "bg-white/10 text-white/30 cursor-not-allowed"
: "bg-red-500/15 text-red-300 hover:bg-red-500/20"
)}
onClick={() => requestDelete(checkedIdxs)}
>
{deleting && <Loader2 size={13} className="animate-spin" />}
{eqUi.batchDelete}
</button>
</div>
</div> </div>
<div <div
@@ -271,21 +465,18 @@ export function PeqPresetManageDialog({
boxShadow: "0 8px 20px rgba(0,0,0,0.35)", boxShadow: "0 8px 20px rgba(0,0,0,0.35)",
}} }}
> >
{items.length === 0 ? ( {displayItems.length === 0 ? (
<div className="py-10 text-center text-[13px] text-white/30"> <div className="py-10 text-center text-[13px] text-white/30">
{eqUi.noPresets} {eqUi.noPresets}
</div> </div>
) : ( ) : (
items.map((item, idx) => { displayItems.map((item, idx) => {
const active = idx === selectedIdx;
const isEditing = idx === editingIdx; const isEditing = idx === editingIdx;
const isSelected = idx === displaySelectedIdx;
return ( return (
<div <div
key={`${item.name}-${idx}`} key={`${item.name}-${idx}`}
className={cn( className="flex items-center gap-2 pl-3 pr-2 py-2.5 border-b border-white/[0.06] last:border-b-0"
"flex items-center gap-2 px-3 py-2.5 border-b border-white/[0.06] last:border-b-0",
active ? "bg-white/[0.06]" : ""
)}
> >
<Checkbox <Checkbox
checked={!!checked[idx]} checked={!!checked[idx]}
@@ -335,7 +526,10 @@ export function PeqPresetManageDialog({
</div> </div>
) : ( ) : (
<div <div
className="truncate text-[13px] text-white/85" className={cn(
"truncate text-[13px]",
isSelected ? "text-[#00FFF6]" : "text-white/85",
)}
title={item.name} title={item.name}
> >
{item.name} {item.name}
@@ -344,7 +538,49 @@ export function PeqPresetManageDialog({
</div> </div>
{!isEditing && ( {!isEditing && (
<div className="flex items-center gap-2"> <div className="ml-2 flex shrink-0 items-center gap-1.5">
{reorderEnabled && displayItems.length > 1 && (
<>
<button
type="button"
disabled={idx === 0 || orderBusy}
className={cn(
"w-8 h-8 rounded-[8px] flex items-center justify-center active:scale-95 transition-transform",
idx === 0 || orderBusy
? "opacity-30 cursor-not-allowed"
: "text-white/75 hover:bg-white/[0.08]",
)}
style={{
background: "rgba(255,255,255,0.06)",
border: "1px solid rgba(255,255,255,0.1)",
}}
title={eqUi.movePresetUp}
aria-label={eqUi.movePresetUp}
onClick={() => movePreset(idx, "up")}
>
<ArrowBigUp size={14} />
</button>
<button
type="button"
disabled={idx === displayItems.length - 1 || orderBusy}
className={cn(
"w-8 h-8 rounded-[8px] flex items-center justify-center active:scale-95 transition-transform",
idx === displayItems.length - 1 || orderBusy
? "opacity-30 cursor-not-allowed"
: "text-white/75 hover:bg-white/[0.08]",
)}
style={{
background: "rgba(255,255,255,0.06)",
border: "1px solid rgba(255,255,255,0.1)",
}}
title={eqUi.movePresetDown}
aria-label={eqUi.movePresetDown}
onClick={() => movePreset(idx, "down")}
>
<ArrowBigDown size={14} />
</button>
</>
)}
<button <button
type="button" type="button"
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-white/70 active:scale-95 transition-transform" className="w-8 h-8 rounded-[8px] flex items-center justify-center text-white/70 active:scale-95 transition-transform"