更新依赖项和配置以支持新版本的pnpm,优化PostCSS和Tailwind配置,添加延迟输入功能到EffectsPage,提升用户体验
This commit is contained in:
@@ -43,10 +43,6 @@ body,
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.min-h-dvh {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.x9-app-shell-outer {
|
||||
min-height: 100vh;
|
||||
min-height: -webkit-fill-available;
|
||||
@@ -90,6 +86,11 @@ body {
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.flex {
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.x9-app-shell-outer {
|
||||
min-height: 100vh;
|
||||
min-height: -webkit-fill-available;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
All dropdowns → navigate to /select page
|
||||
============================================================ */
|
||||
import { useDevice } from "@/contexts/DeviceContext";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||
import { useLocation } from "wouter";
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import BottomNav from "@/components/BottomNav";
|
||||
@@ -18,6 +18,7 @@ import { parseFirmwareVersion } from "@/lib/luxsinApi";
|
||||
import localeZh from "@/locales/data-zh.json";
|
||||
import localeZhHK from "@/locales/data-zh-HK.json";
|
||||
import localeEn from "@/locales/data-en.json";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type EffectLocale = NonNullable<(typeof localeZh)["effect"]>;
|
||||
|
||||
@@ -48,6 +49,44 @@ function formatSubwooferDelayValue(value: number) {
|
||||
return `${ms.toFixed(2)}ms(${cm.toFixed(1)}cm)`;
|
||||
}
|
||||
|
||||
function parseSubwooferDelayInput(raw: string, english: boolean) {
|
||||
const trimmed = raw.trim().toLowerCase();
|
||||
if (!trimmed) {
|
||||
return {
|
||||
ok: false as const,
|
||||
message: english ? "Enter a value" : "请输入数值",
|
||||
};
|
||||
}
|
||||
|
||||
const cmMatch = trimmed.match(/^([\d.]+)\s*cm$/);
|
||||
if (cmMatch) {
|
||||
const cm = Number(cmMatch[1]);
|
||||
if (!Number.isFinite(cm)) {
|
||||
return { ok: false as const, message: english ? "Invalid value" : "无效数值" };
|
||||
}
|
||||
const value = Math.round((cm * SUBWOOFER_DELAY_MS_DIVISOR) / SUBWOOFER_DELAY_CM_NUMERATOR);
|
||||
return { ok: true as const, value: clampSubwooferDelayValue(value) };
|
||||
}
|
||||
|
||||
const msMatch = trimmed.match(/^([\d.]+)\s*ms?$/);
|
||||
const plainMatch = trimmed.match(/^([\d.]+)$/);
|
||||
const msStr = msMatch?.[1] ?? plainMatch?.[1];
|
||||
if (msStr) {
|
||||
const ms = Number(msStr);
|
||||
if (!Number.isFinite(ms)) {
|
||||
return { ok: false as const, message: english ? "Invalid value" : "无效数值" };
|
||||
}
|
||||
const value = Math.round(ms * SUBWOOFER_DELAY_MS_DIVISOR);
|
||||
return { ok: true as const, value: clampSubwooferDelayValue(value) };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false as const,
|
||||
message: english ? "Use e.g. 10.5, 10.5ms, or 7.4cm" : "格式如 10.5、10.5ms 或 7.4cm",
|
||||
};
|
||||
}
|
||||
|
||||
type DelayInputTarget = "main" | "sub";
|
||||
type DelayChannel = "L" | "R";
|
||||
|
||||
function DelaySpeakerIcon({ direction }: { direction: "left" | "right" }) {
|
||||
@@ -292,6 +331,48 @@ export default function EffectsPage() {
|
||||
const isDraggingSubDelayMain = useRef(false);
|
||||
const isDraggingSubDelaySub = useRef(false);
|
||||
|
||||
const [delayInputTarget, setDelayInputTarget] = useState<DelayInputTarget | null>(null);
|
||||
const [delayInputValue, setDelayInputValue] = useState("");
|
||||
|
||||
const delayInputEnglish = deviceState?.language === 0;
|
||||
const maxDelayMs = SUBWOOFER_DELAY_MAX / SUBWOOFER_DELAY_MS_DIVISOR;
|
||||
|
||||
const openDelayInput = (target: DelayInputTarget) => {
|
||||
const current = target === "main" ? localSubDelayMain : localSubDelaySub;
|
||||
setDelayInputValue(subwooferDelayToMs(current).toFixed(2));
|
||||
setDelayInputTarget(target);
|
||||
};
|
||||
|
||||
const closeDelayInput = () => {
|
||||
setDelayInputTarget(null);
|
||||
setDelayInputValue("");
|
||||
};
|
||||
|
||||
const applyDelayInput = () => {
|
||||
if (!delayInputTarget) return;
|
||||
const parsed = parseSubwooferDelayInput(delayInputValue, delayInputEnglish);
|
||||
if (!parsed.ok) {
|
||||
toast.error(parsed.message);
|
||||
return;
|
||||
}
|
||||
const delayVal = parsed.value;
|
||||
if (delayInputTarget === "main") {
|
||||
setLocalSubDelayMain(delayVal);
|
||||
updateSetting({ [subwooferDelayMainField]: delayVal });
|
||||
} else {
|
||||
setLocalSubDelaySub(delayVal);
|
||||
updateSetting({ [subwooferDelaySubField]: delayVal });
|
||||
}
|
||||
closeDelayInput();
|
||||
};
|
||||
|
||||
const delayInputTitle =
|
||||
delayInputTarget === "main"
|
||||
? subwooferDelayText?.mainSpeaker ?? "主机箱"
|
||||
: delayInputTarget === "sub"
|
||||
? subwooferDelayText?.subwoofer ?? "低音炮"
|
||||
: "";
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging.current) setLocalWidth(Math.round(widthVal));
|
||||
}, [widthVal]);
|
||||
@@ -727,9 +808,14 @@ export default function EffectsPage() {
|
||||
<span className="text-[14px] text-white/60">
|
||||
{subwooferDelayText?.mainSpeaker ?? "主机箱"}
|
||||
</span>
|
||||
<span className="text-[14px] font-bold" style={{ color: "#00FFF6" }}>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[14px] font-bold active:opacity-70 transition-opacity"
|
||||
style={{ color: "#00FFF6" }}
|
||||
onClick={() => openDelayInput("main")}
|
||||
>
|
||||
{formatSubwooferDelayValue(localSubDelayMain)}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<CyanSlider
|
||||
value={localSubDelayMain}
|
||||
@@ -754,9 +840,14 @@ export default function EffectsPage() {
|
||||
<span className="text-[14px] text-white/60">
|
||||
{subwooferDelayText?.subwoofer ?? "低音炮"}
|
||||
</span>
|
||||
<span className="text-[14px] font-bold" style={{ color: "#00FFF6" }}>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[14px] font-bold active:opacity-70 transition-opacity"
|
||||
style={{ color: "#00FFF6" }}
|
||||
onClick={() => openDelayInput("sub")}
|
||||
>
|
||||
{formatSubwooferDelayValue(localSubDelaySub)}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<CyanSlider
|
||||
value={localSubDelaySub}
|
||||
@@ -782,6 +873,72 @@ export default function EffectsPage() {
|
||||
</FeatureGate>
|
||||
</div>
|
||||
|
||||
{delayInputTarget && (
|
||||
<div
|
||||
className="fixed inset-0 z-[120] flex items-center justify-center bg-black/65 px-4"
|
||||
onClick={closeDelayInput}
|
||||
>
|
||||
<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-2">
|
||||
<h3 className="text-[18px] font-semibold leading-tight text-white/90">
|
||||
{delayInputTitle} · {subwooferDelayText?.label ?? "延时"}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-1.5 text-white/45 transition-colors hover:bg-white/10 hover:text-white/80"
|
||||
onClick={closeDelayInput}
|
||||
aria-label={effectText.enableConfirmCancel ?? "取消"}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="mb-4 text-[13px] leading-relaxed text-white/45">
|
||||
{delayInputEnglish
|
||||
? `Enter delay in ms (0–${maxDelayMs.toFixed(2)}), or cm, e.g. 10.5, 10.5ms, 7.4cm`
|
||||
: `输入延时(ms,0–${maxDelayMs.toFixed(2)}),也可输入 cm,如 10.5、10.5ms、7.4cm`}
|
||||
</p>
|
||||
<input
|
||||
value={delayInputValue}
|
||||
onChange={(e) => setDelayInputValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
applyDelayInput();
|
||||
}
|
||||
}}
|
||||
inputMode="decimal"
|
||||
placeholder={delayInputEnglish ? "e.g. 10.50" : "如 10.50"}
|
||||
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-[#00FFF6]/40"
|
||||
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={closeDelayInput}
|
||||
>
|
||||
{effectText.enableConfirmCancel ?? "取消"}
|
||||
</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={applyDelayInput}
|
||||
>
|
||||
{delayInputEnglish ? "OK" : "确定"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<BottomNav />
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user