Refactor ConnectScreen to use logo image instead of SVG, enhance EQPage with batch editing functionality for PEQ settings, and implement power off confirmation dialog in Home component. Update localization files for new power off features.
This commit is contained in:
+182
-8
@@ -458,6 +458,8 @@ export default function EQPage() {
|
||||
const [addPresetMode, setAddPresetMode] = useState<"copy" | "flat">("copy");
|
||||
const [copyPresetName, setCopyPresetName] = useState("");
|
||||
const [flatPresetName, setFlatPresetName] = useState("");
|
||||
const [isBatchEditDialogOpen, setIsBatchEditDialogOpen] = useState(false);
|
||||
const [batchEditText, setBatchEditText] = useState("");
|
||||
type BrandDrawerTab = "brands" | "models" | "target";
|
||||
const [isBrandDrawerOpen, setIsBrandDrawerOpen] = useState(false);
|
||||
const [brandDrawerTab, setBrandDrawerTab] = useState<BrandDrawerTab>("brands");
|
||||
@@ -624,6 +626,124 @@ export default function EQPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const toCompactNumber = (value: number) => Number(value.toFixed(3)).toString();
|
||||
|
||||
const buildBatchEditText = useCallback(() => {
|
||||
const preamp = Number(currentPeq?.preamp ?? 0);
|
||||
const source = bands.length > 0 ? bands : DEFAULT_BANDS;
|
||||
const lines = [`Preamp:${toCompactNumber(preamp)}dB`];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const band = source[i] ?? DEFAULT_BANDS[i] ?? DEFAULT_BANDS[0];
|
||||
lines.push(
|
||||
`Filter ${i + 1}: ${band.enabled ? "ON" : "OFF"} ${normalizeFilterType(band.type)} Fc ${toCompactNumber(band.freq)} Hz Gain ${toCompactNumber(band.gain)} dB Q ${toCompactNumber(band.q)}`
|
||||
);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}, [bands, currentPeq?.preamp]);
|
||||
|
||||
const openBatchEditDialog = () => {
|
||||
setBatchEditText(buildBatchEditText());
|
||||
setIsBatchEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const parseBatchEditText = (raw: string) => {
|
||||
const lines = raw
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
if (lines.length < 11) {
|
||||
throw new Error("格式错误:至少需要 11 行(Preamp + 10 个滤波器)");
|
||||
}
|
||||
|
||||
const preampMatch = lines[0].match(/^Preamp\s*:\s*([+-]?\d+(?:\.\d+)?)\s*dB$/i);
|
||||
if (!preampMatch) {
|
||||
throw new Error("格式错误:第 1 行应为 Preamp:-5.96dB");
|
||||
}
|
||||
const preamp = Number(preampMatch[1]);
|
||||
if (!Number.isFinite(preamp)) {
|
||||
throw new Error("格式错误:Preamp 数值无效");
|
||||
}
|
||||
|
||||
const parsedFilters = new Array<{
|
||||
enabled: boolean;
|
||||
type: string;
|
||||
freq: number;
|
||||
gain: number;
|
||||
q: number;
|
||||
}>(10);
|
||||
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
const line = lines[i];
|
||||
const match = line.match(
|
||||
/^Filter\s+(\d+)\s*:\s*(ON|OFF)\s+([A-Za-z]+)\s+Fc\s+([+-]?\d+(?:\.\d+)?)\s+Hz\s+Gain\s+([+-]?\d+(?:\.\d+)?)\s+dB\s+Q\s+([+-]?\d+(?:\.\d+)?)$/i
|
||||
);
|
||||
if (!match) {
|
||||
throw new Error(`格式错误:第 ${i + 1} 行不符合 Filter 格式`);
|
||||
}
|
||||
|
||||
const filterNo = Number(match[1]);
|
||||
if (filterNo < 1 || filterNo > 10) {
|
||||
throw new Error(`格式错误:Filter 编号 ${filterNo} 超出范围`);
|
||||
}
|
||||
|
||||
const type = normalizeFilterType(match[3].toUpperCase());
|
||||
if (!FILTER_TYPES.includes(type)) {
|
||||
throw new Error(`格式错误:Filter ${filterNo} 的类型无效(${match[3]})`);
|
||||
}
|
||||
|
||||
const freq = Number(match[4]);
|
||||
const gain = Number(match[5]);
|
||||
const q = Number(match[6]);
|
||||
if (![freq, gain, q].every(Number.isFinite)) {
|
||||
throw new Error(`格式错误:Filter ${filterNo} 的数值无效`);
|
||||
}
|
||||
|
||||
parsedFilters[filterNo - 1] = {
|
||||
enabled: match[2].toUpperCase() === "ON",
|
||||
type,
|
||||
freq: Number(freq.toFixed(2)),
|
||||
gain: Number(gain.toFixed(2)),
|
||||
q: Number(q.toFixed(2)),
|
||||
};
|
||||
}
|
||||
|
||||
if (parsedFilters.some((item) => !item)) {
|
||||
throw new Error("格式错误:Filter 1-10 必须全部提供");
|
||||
}
|
||||
|
||||
return {
|
||||
preamp: Number(preamp.toFixed(2)),
|
||||
filters: parsedFilters as typeof bands,
|
||||
};
|
||||
};
|
||||
|
||||
const handleSaveBatchEdit = () => {
|
||||
try {
|
||||
const parsed = parseBatchEditText(batchEditText);
|
||||
let updatedPeq: { name: string; autoPre?: number; preamp?: number; canDel?: number } | undefined;
|
||||
setPeqItems((prev) =>
|
||||
prev.map((item, i) => {
|
||||
if (i !== headphoneIdx) return item;
|
||||
const merged = {
|
||||
...item,
|
||||
autoPre: 0,
|
||||
preamp: parsed.preamp,
|
||||
filters: parsed.filters.map(bandToPeqFilter),
|
||||
};
|
||||
updatedPeq = merged;
|
||||
return merged;
|
||||
})
|
||||
);
|
||||
setBands(parsed.filters);
|
||||
schedulePeqSync(updatedPeq, parsed.filters, 0);
|
||||
setSelectedBand(0);
|
||||
setIsBatchEditDialogOpen(false);
|
||||
toast.success("批量编辑已应用");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "批量编辑格式错误");
|
||||
}
|
||||
};
|
||||
|
||||
const applyPeqStateToUI = (
|
||||
peqState: { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number },
|
||||
) => {
|
||||
@@ -1069,7 +1189,7 @@ export default function EQPage() {
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
className="ios-list-group flex flex-col items-center justify-center py-3 gap-2 active:opacity-70 transition-opacity"
|
||||
onClick={() => toast.info("批量编辑功能即将推出")}>
|
||||
onClick={openBatchEditDialog}>
|
||||
<div className="w-10 h-10 rounded-[12px] flex items-center justify-center"
|
||||
style={{ background: "rgba(0,255,246,0.12)", border: "1px solid rgba(0,255,246,0.2)" }}>
|
||||
<Edit3 size={20} style={{ color: "#00FFF6" }} />
|
||||
@@ -1394,6 +1514,56 @@ export default function EQPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isBatchEditDialogOpen && (
|
||||
<div className="fixed inset-0 z-[121] flex items-center justify-center bg-black/65 px-4">
|
||||
<div
|
||||
className="w-full max-w-[760px] 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-[28px] text-white/90 font-semibold">批量编辑</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="text-white/45 hover:text-white/75 transition-colors"
|
||||
onClick={() => setIsBatchEditDialogOpen(false)}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="mb-3 text-[13px] text-white/55">
|
||||
共 11 行:第 1 行 Preamp,第 2-11 行 Filter 1-10。保存后将覆盖当前耳机参数。
|
||||
</p>
|
||||
<textarea
|
||||
value={batchEditText}
|
||||
onChange={(e) => setBatchEditText(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="h-[360px] w-full resize-none overflow-auto rounded-[10px] border border-white/12 bg-[#15171b] p-3 font-mono text-[13px] leading-6 text-white/90 outline-none focus:border-[#00FFF6]/55"
|
||||
/>
|
||||
<div className="mt-5 flex items-center justify-center gap-16">
|
||||
<button
|
||||
type="button"
|
||||
className="px-5 py-2 rounded-full text-[24px] text-white/80 bg-[#3f4349] hover:bg-[#4a4f56] transition-colors"
|
||||
onClick={() => setIsBatchEditDialogOpen(false)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="px-5 py-2 rounded-full text-[24px] text-black bg-[#00FFF6] hover:brightness-95 transition-all"
|
||||
onClick={handleSaveBatchEdit}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isBrandDrawerOpen && (
|
||||
<div className="fixed inset-0 z-[110] flex flex-col justify-end">
|
||||
<button
|
||||
@@ -1403,7 +1573,7 @@ export default function EQPage() {
|
||||
onClick={() => setIsBrandDrawerOpen(false)}
|
||||
/>
|
||||
<div
|
||||
className="relative z-10 flex max-h-[88vh] flex-col rounded-t-[18px] bg-white shadow-[0_-8px_32px_rgba(0,0,0,0.35)]"
|
||||
className="relative z-10 flex h-[58dvh] flex-shrink-0 flex-col overflow-hidden rounded-t-[18px] bg-white shadow-[0_-8px_32px_rgba(0,0,0,0.35)]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-center pt-2 pb-1">
|
||||
@@ -1428,9 +1598,9 @@ export default function EQPage() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-white">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-white">
|
||||
{brandDrawerTab === "brands" && (
|
||||
<>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="shrink-0 border-b border-black/8 px-3 py-2.5">
|
||||
<div className="flex items-center gap-2 rounded-[10px] border border-black/12 bg-[#f5f5f7] px-3 py-2">
|
||||
<Search size={18} className="shrink-0 text-black/35" />
|
||||
@@ -1443,7 +1613,7 @@ export default function EQPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain touch-pan-y">
|
||||
{brandSearchQuery.trim() && catalogSearchLoading && (
|
||||
<div className="py-10 text-center text-[14px] text-black/45">搜索中…</div>
|
||||
)}
|
||||
@@ -1511,10 +1681,11 @@ export default function EQPage() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
{brandDrawerTab === "models" && (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain touch-pan-y">
|
||||
<div className="sticky top-0 z-10 border-b border-black/8 bg-white px-4 py-2.5 text-[13px] text-black/55">
|
||||
品牌:<span className="font-semibold text-black/80">{selectedCatalogBrand || "—"}</span>
|
||||
{selectedCatalogModelFromSearch && (
|
||||
@@ -1553,9 +1724,11 @@ export default function EQPage() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{brandDrawerTab === "target" && (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain touch-pan-y">
|
||||
<div className="sticky top-0 z-10 border-b border-black/8 bg-white px-4 py-2.5 text-[13px] text-black/55 space-y-0.5">
|
||||
<div>品牌:<span className="font-semibold text-black/80">{selectedCatalogBrand || "—"}</span></div>
|
||||
<div>型号:<span className="font-semibold text-black/80">{selectedCatalogModelName || "—"}</span></div>
|
||||
@@ -1715,6 +1888,7 @@ export default function EQPage() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,16 @@
|
||||
import { useDevice } from "@/contexts/DeviceContext";
|
||||
import ConnectScreen from "@/components/ConnectScreen";
|
||||
import BottomNav from "@/components/BottomNav";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { INPUT_LABELS, OUTPUT_LABELS } from "@/lib/luxsinApi";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
@@ -27,12 +37,14 @@ import {
|
||||
Bluetooth,
|
||||
} from "lucide-react";
|
||||
import { useLocation } from "wouter";
|
||||
import { useRef, useState, useEffect, useCallback } from "react";
|
||||
import { useRef, useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import localeZh from "@/locales/data-zh.json";
|
||||
import localeZhHK from "@/locales/data-zh-HK.json";
|
||||
import localeEn from "@/locales/data-en.json";
|
||||
|
||||
type HomeLocale = NonNullable<(typeof localeZh)["home"]>;
|
||||
|
||||
// ── Volume in dB (0-200 → -100dB to 0dB) ──
|
||||
function volToDB(v: number) {
|
||||
if (v === 0) return "-∞";
|
||||
@@ -86,8 +98,17 @@ function ListRow({
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const { isConnected, deviceState, setVolume, updateSetting } = useDevice();
|
||||
const { isConnected, deviceState, setVolume, updateSetting, api, disconnect, isDemoMode } = useDevice();
|
||||
const [, setLocation] = useLocation();
|
||||
const powerActionRef = useRef(false);
|
||||
const [powerConfirmOpen, setPowerConfirmOpen] = useState(false);
|
||||
|
||||
const homeText = useMemo((): HomeLocale => {
|
||||
if (!deviceState) return {} as HomeLocale;
|
||||
const loc =
|
||||
deviceState.language === 0 ? localeEn : deviceState.language === 1 ? localeZhHK : localeZh;
|
||||
return (loc.home ?? {}) as HomeLocale;
|
||||
}, [deviceState]);
|
||||
|
||||
const [localVol, setLocalVol] = useState(deviceState?.volume ?? 100);
|
||||
const isDragging = useRef(false);
|
||||
@@ -112,11 +133,32 @@ export default function Home() {
|
||||
setVolume(v);
|
||||
}, [setVolume]);
|
||||
|
||||
const handlePowerOff = useCallback(async () => {
|
||||
if (powerActionRef.current) return;
|
||||
powerActionRef.current = true;
|
||||
try {
|
||||
if (isDemoMode) {
|
||||
toast.info(homeText.powerOffToastDemo ?? "演示模式:已断开与设备的连接");
|
||||
disconnect();
|
||||
return;
|
||||
}
|
||||
if (api) {
|
||||
try {
|
||||
await api.powerOff();
|
||||
} catch {
|
||||
// 设备可能已掉线或未返回响应,仍断开本地会话
|
||||
}
|
||||
}
|
||||
disconnect();
|
||||
toast.success(homeText.powerOffToastSent ?? "已发送关机指令");
|
||||
} finally {
|
||||
powerActionRef.current = false;
|
||||
}
|
||||
}, [api, disconnect, isDemoMode, homeText]);
|
||||
|
||||
if (!isConnected) return <ConnectScreen />;
|
||||
|
||||
const ds = deviceState!;
|
||||
const localeData = ds.language === 0 ? localeEn : ds.language === 1 ? localeZhHK : localeZh;
|
||||
const homeText = localeData.home ?? {};
|
||||
const inputLabel = INPUT_LABELS[ds.input] ?? "USB-C";
|
||||
const outputLabel = OUTPUT_LABELS[ds.output] ?? "Headset";
|
||||
const effectOn = ds.audio_enable === 1;
|
||||
@@ -381,8 +423,9 @@ export default function Home() {
|
||||
{/* Power */}
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-circle-btn"
|
||||
onClick={() => toast.info("远程待机功能即将推出")}
|
||||
onClick={() => setPowerConfirmOpen(true)}
|
||||
>
|
||||
<Power size={22} className="text-white/60" />
|
||||
</button>
|
||||
@@ -460,6 +503,31 @@ export default function Home() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={powerConfirmOpen} onOpenChange={setPowerConfirmOpen}>
|
||||
<AlertDialogContent className="border-white/10 bg-zinc-900 text-white sm:max-w-md">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-white">
|
||||
{homeText.powerOffConfirmTitle ?? "确认关闭电源?"}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-white/60">
|
||||
{homeText.powerOffConfirmDesc ??
|
||||
"设备将关机并断开与本应用的连接,是否继续?"}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="border-white/20 bg-transparent text-white hover:bg-white/10">
|
||||
{homeText.powerOffConfirmCancel ?? "取消"}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-600"
|
||||
onClick={() => void handlePowerOff()}
|
||||
>
|
||||
{homeText.powerOffConfirmOk ?? "关闭电源"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Bottom nav */}
|
||||
<BottomNav />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user