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:
yangy
2026-05-18 13:42:29 +08:00
parent b5c0606972
commit ba5f5d074a
8 changed files with 463 additions and 59 deletions
+211 -16
View File
@@ -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