Enhance DeviceContext and LuxsinAPI to support new PEQ apply functionality; update locale files for English and Chinese to include new UI strings for applying and saving PEQ settings. Refactor AudioPage and EQPage to integrate boot sound management and improve state handling.
This commit is contained in:
+211
-16
@@ -28,7 +28,9 @@ import {
|
||||
type LuxsinAudioModelListItem,
|
||||
type LuxsinAudioModel,
|
||||
type PeqFilter,
|
||||
type PeqApplyPayload,
|
||||
type PeqChangePayload,
|
||||
type PeqPresetBody,
|
||||
} from "@/lib/luxsinApi";
|
||||
import * as echarts from "echarts";
|
||||
import {
|
||||
@@ -115,6 +117,8 @@ function FreqChart({
|
||||
abMode,
|
||||
onAbToggle,
|
||||
onCopyMode,
|
||||
onApplyB,
|
||||
onSaveB,
|
||||
onBandDrag,
|
||||
onBandSelect,
|
||||
eqUi,
|
||||
@@ -125,6 +129,8 @@ function FreqChart({
|
||||
abMode: "A" | "B";
|
||||
onAbToggle: (m: "A" | "B") => void;
|
||||
onCopyMode: (from: "A" | "B", to: "A" | "B") => void;
|
||||
onApplyB: () => void;
|
||||
onSaveB: () => void;
|
||||
onBandDrag: (idx: number, patch: Partial<{ freq: number; gain: number }>) => void;
|
||||
onBandSelect: (idx: number) => void;
|
||||
eqUi: PeqEqUi;
|
||||
@@ -321,7 +327,8 @@ function FreqChart({
|
||||
</div>
|
||||
|
||||
{/* A/B + DIFF controls */}
|
||||
<div className="flex items-center gap-2 mb-2 px-1">
|
||||
<div className="flex w-full items-center gap-2 mb-2 px-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{/* A/B toggle pill */}
|
||||
<div className="flex items-center rounded-[8px] overflow-hidden"
|
||||
style={{ background: "rgba(44,44,46,0.7)", border: "1px solid rgba(255,255,255,0.08)" }}>
|
||||
@@ -350,13 +357,25 @@ function FreqChart({
|
||||
}}>
|
||||
{copyButtonText}
|
||||
</button>
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
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={() => toast.info(eqUi.toastChartLoadedDevice)}
|
||||
onClick={() => onApplyB()}
|
||||
>
|
||||
{eqUi.chartLoadToDevice}
|
||||
{eqUi.chartApplyB}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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={() => onSaveB()}
|
||||
>
|
||||
{eqUi.chartSaveB}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SVG chart */}
|
||||
@@ -573,7 +592,7 @@ function bandToPeqFilter(b: { freq: number; gain: number; q: number; type: strin
|
||||
/* ── Main Component ── */
|
||||
export default function EQPage() {
|
||||
const [, setLocation] = useLocation();
|
||||
const { deviceState, updateSetting, api, isDemoMode, upgradePeqChange } = useDevice();
|
||||
const { deviceState, updateSetting, api, isDemoMode, upgradePeqChange, upgradePeqApply } = useDevice();
|
||||
const eqOn = (deviceState?.peqEnable ?? 0) === 1;
|
||||
|
||||
const eqUi = useMemo((): PeqEqUi => {
|
||||
@@ -617,6 +636,8 @@ export default function EQPage() {
|
||||
const [addPresetMode, setAddPresetMode] = useState<"copy" | "flat">("copy");
|
||||
const [copyPresetName, setCopyPresetName] = useState("");
|
||||
const [flatPresetName, setFlatPresetName] = useState("");
|
||||
const [isSaveBDialogOpen, setIsSaveBDialogOpen] = useState(false);
|
||||
const [saveBPresetName, setSaveBPresetName] = useState("");
|
||||
const [isBatchEditDialogOpen, setIsBatchEditDialogOpen] = useState(false);
|
||||
const [batchEditText, setBatchEditText] = useState("");
|
||||
type BrandDrawerTab = "brands" | "models" | "target";
|
||||
@@ -642,6 +663,7 @@ export default function EQPage() {
|
||||
const allowPeqRemoteSyncRef = useRef(false);
|
||||
const syncingHeadphoneRef = useRef(false);
|
||||
const peqSyncTimerRef = useRef<number | null>(null);
|
||||
const skipPeqAutoSyncRef = useRef(false);
|
||||
const headphoneMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const filterMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const bands = bandsByMode[abMode];
|
||||
@@ -675,6 +697,7 @@ export default function EQPage() {
|
||||
setBandsByMode((prev) => ({ ...prev, [to]: cloneBands(prev[from]) }));
|
||||
setSelectedBandByMode((prev) => ({ ...prev, [to]: prev[from] }));
|
||||
}, []);
|
||||
|
||||
const currentPeq = peqItems[headphoneIdx];
|
||||
const preampValue = Number(currentPeq?.preamp ?? 0);
|
||||
const autoPreOn = (currentPeq?.autoPre ?? 0) === 1;
|
||||
@@ -995,6 +1018,13 @@ export default function EQPage() {
|
||||
setIsAddPresetDialogOpen(true);
|
||||
};
|
||||
|
||||
const openSaveBDialog = () => {
|
||||
const currentName = peqItems[headphoneIdx]?.name ?? headphoneModels[headphoneIdx] ?? "Preset";
|
||||
const defaultName = getUniquePresetName(`${currentName}_B`, headphoneModels);
|
||||
setSaveBPresetName(defaultName);
|
||||
setIsSaveBDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveAddPreset = async () => {
|
||||
const nextName = addPresetMode === "copy" ? copyPresetName.trim() : flatPresetName.trim();
|
||||
if (!nextName) {
|
||||
@@ -1062,6 +1092,60 @@ export default function EQPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveBAsPreset = async () => {
|
||||
const nextName = saveBPresetName.trim();
|
||||
if (!nextName) {
|
||||
toast.error(eqUi.addPresetNameEmpty);
|
||||
return;
|
||||
}
|
||||
if (headphoneModels.includes(nextName)) {
|
||||
toast.error(eqUi.addPresetNameExists);
|
||||
return;
|
||||
}
|
||||
|
||||
const bBands = cloneBands(bandsByMode.B);
|
||||
const filters = bBands.map(bandToPeqFilter);
|
||||
const payload: PeqChangePayload = {
|
||||
peqChange: {
|
||||
name: nextName,
|
||||
filters,
|
||||
autoPre: currentPeq?.autoPre ?? 0,
|
||||
preamp: currentPeq?.preamp ?? 0,
|
||||
canDel: currentPeq?.canDel ?? 1,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await upgradePeqChange(payload);
|
||||
if (api && !isDemoMode) {
|
||||
const latest = await api.getPeqState();
|
||||
applyPeqStateToUI(latest as { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number });
|
||||
const createdIndex = latest.peq?.findIndex((item) => item.name === nextName) ?? -1;
|
||||
if (createdIndex >= 0) {
|
||||
setHeadphoneIdx(createdIndex);
|
||||
updateSetting({ peqSelect: createdIndex });
|
||||
}
|
||||
} else {
|
||||
setHeadphoneModels((prev) => [...prev, nextName]);
|
||||
setPeqItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
name: nextName,
|
||||
filters,
|
||||
autoPre: currentPeq?.autoPre ?? 0,
|
||||
preamp: currentPeq?.preamp ?? 0,
|
||||
canDel: currentPeq?.canDel ?? 1,
|
||||
},
|
||||
]);
|
||||
setHeadphoneIdx(headphoneModels.length);
|
||||
}
|
||||
setIsSaveBDialogOpen(false);
|
||||
toast.success(eqUi.toastSaveBOk);
|
||||
} catch {
|
||||
toast.error(eqUi.toastSaveBFail);
|
||||
}
|
||||
};
|
||||
|
||||
// 同步 peqSelect 变化
|
||||
useEffect(() => {
|
||||
if (deviceState?.peqSelect !== undefined) {
|
||||
@@ -1261,7 +1345,7 @@ export default function EQPage() {
|
||||
const schedulePeqSync = (
|
||||
peq: { name: string; autoPre?: number; preamp?: number; canDel?: number } | undefined,
|
||||
filtersSource: typeof bands,
|
||||
delay = 320
|
||||
delay = 320,
|
||||
) => {
|
||||
if (!allowPeqRemoteSyncRef.current) return;
|
||||
if (!isDemoMode && !api) return;
|
||||
@@ -1271,33 +1355,40 @@ export default function EQPage() {
|
||||
window.clearTimeout(peqSyncTimerRef.current);
|
||||
}
|
||||
|
||||
const useApply = abMode === "B";
|
||||
|
||||
peqSyncTimerRef.current = window.setTimeout(() => {
|
||||
const filters = filtersSource.map(bandToPeqFilter);
|
||||
const payload: PeqChangePayload = {
|
||||
peqChange: {
|
||||
name: peq.name,
|
||||
filters,
|
||||
autoPre: peq.autoPre ?? 0,
|
||||
preamp: peq.preamp ?? 0,
|
||||
canDel: peq.canDel ?? 1,
|
||||
},
|
||||
const body: PeqPresetBody = {
|
||||
name: peq.name,
|
||||
filters,
|
||||
autoPre: peq.autoPre ?? 0,
|
||||
preamp: peq.preamp ?? 0,
|
||||
canDel: peq.canDel ?? 1,
|
||||
};
|
||||
upgradePeqChange(payload).catch((err) => {
|
||||
const request = useApply
|
||||
? upgradePeqApply({ peqApply: body } satisfies PeqApplyPayload)
|
||||
: upgradePeqChange({ peqChange: body } satisfies PeqChangePayload);
|
||||
request.catch(() => {
|
||||
toast.error("EQ 保存失败");
|
||||
});
|
||||
}, delay);
|
||||
};
|
||||
|
||||
// 参数变更(含拖动曲线)→ POST peqChange(防抖)
|
||||
// A 模式编辑 → peqChange;B 模式编辑(含切换后展示)→ peqApply(防抖)
|
||||
useEffect(() => {
|
||||
if (syncingHeadphoneRef.current) return;
|
||||
if (skipPeqAutoSyncRef.current) {
|
||||
skipPeqAutoSyncRef.current = false;
|
||||
return;
|
||||
}
|
||||
schedulePeqSync(peqItems[headphoneIdx], bands, 320);
|
||||
return () => {
|
||||
if (peqSyncTimerRef.current !== null) {
|
||||
window.clearTimeout(peqSyncTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [bands, headphoneIdx, peqItems, api, isDemoMode, upgradePeqChange]);
|
||||
}, [bands, abMode, headphoneIdx, peqItems, api, isDemoMode, upgradePeqChange, upgradePeqApply]);
|
||||
|
||||
useEffect(() => {
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
@@ -1395,6 +1486,58 @@ export default function EQPage() {
|
||||
myChart.setOption(ops, true);
|
||||
};
|
||||
|
||||
const handleApplyB = useCallback(async () => {
|
||||
const peq = peqItems[headphoneIdx];
|
||||
if (!peq?.name) {
|
||||
toast.error(eqUi.toastApplyBFail);
|
||||
return;
|
||||
}
|
||||
|
||||
const bBands = cloneBands(bandsByMode.B);
|
||||
const filters = bBands.map(bandToPeqFilter);
|
||||
const payload: PeqChangePayload = {
|
||||
peqChange: {
|
||||
name: peq.name,
|
||||
filters,
|
||||
autoPre: peq.autoPre ?? 0,
|
||||
preamp: peq.preamp ?? 0,
|
||||
canDel: peq.canDel ?? 1,
|
||||
},
|
||||
};
|
||||
|
||||
skipPeqAutoSyncRef.current = true;
|
||||
setBandsByMode((prev) => ({ ...prev, A: cloneBands(bBands) }));
|
||||
setSelectedBandByMode((prev) => ({ ...prev, A: prev.B }));
|
||||
setPeqItems((prev) =>
|
||||
prev.map((item, i) => (i !== headphoneIdx ? item : { ...item, filters })),
|
||||
);
|
||||
setAbMode("A");
|
||||
|
||||
if (peqSyncTimerRef.current !== null) {
|
||||
window.clearTimeout(peqSyncTimerRef.current);
|
||||
peqSyncTimerRef.current = null;
|
||||
}
|
||||
|
||||
try {
|
||||
await upgradePeqChange(payload);
|
||||
if (peqItems.length > 0 && headphoneIdx < peqItems.length) {
|
||||
renderCharts(bBands, currentRawCurve, false);
|
||||
}
|
||||
toast.success(eqUi.toastApplyBSuccess);
|
||||
} catch {
|
||||
skipPeqAutoSyncRef.current = false;
|
||||
toast.error(eqUi.toastApplyBFail);
|
||||
}
|
||||
}, [
|
||||
bandsByMode.B,
|
||||
currentRawCurve,
|
||||
eqUi.toastApplyBFail,
|
||||
eqUi.toastApplyBSuccess,
|
||||
headphoneIdx,
|
||||
peqItems,
|
||||
upgradePeqChange,
|
||||
]);
|
||||
|
||||
const updateBand = (idx: number, patch: Partial<typeof bands[0]>) => {
|
||||
const nextBands = bands.map((b, i) => (i === idx ? { ...b, ...patch } : b));
|
||||
setBands(nextBands);
|
||||
@@ -1540,6 +1683,8 @@ export default function EQPage() {
|
||||
abMode={abMode}
|
||||
onAbToggle={setAbMode}
|
||||
onCopyMode={copyModeParams}
|
||||
onApplyB={() => void handleApplyB()}
|
||||
onSaveB={openSaveBDialog}
|
||||
onBandDrag={(idx, patch) => setBands((prev) => prev.map((b, i) => i === idx ? { ...b, ...patch } : b))}
|
||||
onBandSelect={setSelectedBand}
|
||||
eqUi={eqUi}
|
||||
@@ -1696,6 +1841,56 @@ export default function EQPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSaveBDialogOpen && (
|
||||
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/65 px-4">
|
||||
<div
|
||||
className="w-full max-w-[420px] rounded-[14px] p-5"
|
||||
style={{
|
||||
background: "linear-gradient(180deg, rgba(34,36,40,0.98) 0%, rgba(24,26,30,0.98) 100%)",
|
||||
border: "1px solid rgba(255,255,255,0.12)",
|
||||
boxShadow: "0 18px 48px rgba(0,0,0,0.55)",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-[18px] font-semibold leading-tight text-white/90">{eqUi.saveBTitle}</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-1.5 text-white/45 transition-colors hover:bg-white/10 hover:text-white/80"
|
||||
onClick={() => setIsSaveBDialogOpen(false)}
|
||||
aria-label={eqUi.closeDrawer}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
value={saveBPresetName}
|
||||
onChange={(e) => setSaveBPresetName(e.target.value)}
|
||||
className="h-11 w-full rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[15px] text-white/90 outline-none placeholder:text-white/30 focus:border-white/15"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<div className="mt-6 flex items-center justify-center gap-4 sm:gap-8">
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-[112px] rounded-full px-5 py-2.5 text-[15px] font-medium text-white/85 transition-colors bg-[#3f4349] hover:bg-[#4a4f56] active:scale-[0.98]"
|
||||
onClick={() => setIsSaveBDialogOpen(false)}
|
||||
>
|
||||
{eqUi.cancel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-[112px] rounded-full px-5 py-2.5 text-[15px] font-semibold text-black transition-all bg-[#00FFF6] hover:brightness-95 active:scale-[0.98]"
|
||||
onClick={() => void handleSaveBAsPreset()}
|
||||
>
|
||||
{eqUi.save}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAddPresetDialogOpen && (
|
||||
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/65 px-4">
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user