Refactor BottomNav to use localized labels for navigation items; added support for dynamic language selection based on device state. Updated locale files for English, Traditional Chinese, and Simplified Chinese to include bottom navigation labels.

This commit is contained in:
yangy
2026-05-11 17:42:18 +08:00
parent 68f06344dc
commit ae4ce8bb07
7 changed files with 552 additions and 192 deletions
+125 -38
View File
@@ -2,12 +2,12 @@
HOME — Luxsin X9 Controller (Main Dashboard)
Design: Reference luxsin_x8_首页.png
Layout (top to bottom):
1. TopBar: device name + settings icon
1. Device area
2. Device status card (运行中/待机/充电 + 电量/模式/环保参数)
3. Device render image (hardware photo)
4. Volume slider with dB display
5. 4 quick-action circle buttons (电源/EQ/HP-EQ/Bypass)
6. iOS list rows (输入源/输出端口/Effect/音频设置/VU表)
5. 4 quick-action circle buttons (电源/HP-EQ/Effect/BypassHP-EQ/Effect 支持双击开关)
6. iOS list rows (输入源/输出端口/音频设置/系统设置/VU表)
============================================================ */
import { useDevice } from "@/contexts/DeviceContext";
import ConnectScreen from "@/components/ConnectScreen";
@@ -28,6 +28,7 @@ import {
ChevronRight,
Power,
Settings,
Settings2,
Sliders,
Activity,
Gauge,
@@ -100,6 +101,10 @@ export default function Home() {
const { isConnected, deviceState, setVolume, updateSetting, api, disconnect, isDemoMode } = useDevice();
const [, setLocation] = useLocation();
const powerActionRef = useRef(false);
const hpEqLastTapRef = useRef<number | null>(null);
const hpEqNavigateTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const effectLastTapRef = useRef<number | null>(null);
const effectNavigateTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [powerConfirmOpen, setPowerConfirmOpen] = useState(false);
const homeText = useMemo((): HomeLocale => {
@@ -112,6 +117,7 @@ export default function Home() {
const [localVol, setLocalVol] = useState(deviceState?.volume ?? 100);
const isDragging = useRef(false);
const knobDraggingRef = useRef(false);
const knobActivePointerIdRef = useRef<number | null>(null);
const knobStartAngleRef = useRef(0);
const knobStartVolRef = useRef(0);
const knobLastVolRef = useRef<number>(localVol);
@@ -126,12 +132,6 @@ export default function Home() {
knobLastVolRef.current = localVol;
}, [localVol]);
const handleVolChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const v = Number(e.target.value);
setLocalVol(v);
setVolume(v);
}, [setVolume]);
const handlePowerOff = useCallback(async () => {
if (powerActionRef.current) return;
powerActionRef.current = true;
@@ -155,13 +155,66 @@ export default function Home() {
}
}, [api, disconnect, isDemoMode, homeText]);
/** HP-EQ 圆钮:单击进入 /eq;双击在 280ms 内第二次点击则切换 peqEnable(与 info.cgi?action=setting&peqEnable=0|1 一致) */
const HP_EQ_DOUBLE_CLICK_MS = 280;
const handleHpEqCircleClick = useCallback(() => {
const now = Date.now();
const last = hpEqLastTapRef.current;
if (last !== null && now - last < HP_EQ_DOUBLE_CLICK_MS) {
hpEqLastTapRef.current = null;
if (hpEqNavigateTimerRef.current) {
clearTimeout(hpEqNavigateTimerRef.current);
hpEqNavigateTimerRef.current = null;
}
const on = (deviceState?.peqEnable ?? 0) === 1;
void updateSetting({ peqEnable: on ? 0 : 1 });
return;
}
hpEqLastTapRef.current = now;
if (hpEqNavigateTimerRef.current) clearTimeout(hpEqNavigateTimerRef.current);
hpEqNavigateTimerRef.current = setTimeout(() => {
hpEqNavigateTimerRef.current = null;
hpEqLastTapRef.current = null;
setLocation("/eq");
}, HP_EQ_DOUBLE_CLICK_MS);
}, [deviceState?.peqEnable, updateSetting, setLocation]);
/** Effect 圆钮:单击进入 /effects;双击切换 audio_enable */
const handleEffectCircleClick = useCallback(() => {
const now = Date.now();
const last = effectLastTapRef.current;
if (last !== null && now - last < HP_EQ_DOUBLE_CLICK_MS) {
effectLastTapRef.current = null;
if (effectNavigateTimerRef.current) {
clearTimeout(effectNavigateTimerRef.current);
effectNavigateTimerRef.current = null;
}
const on = (deviceState?.audio_enable ?? 0) === 1;
void updateSetting({ audio_enable: on ? 0 : 1 });
return;
}
effectLastTapRef.current = now;
if (effectNavigateTimerRef.current) clearTimeout(effectNavigateTimerRef.current);
effectNavigateTimerRef.current = setTimeout(() => {
effectNavigateTimerRef.current = null;
effectLastTapRef.current = null;
setLocation("/effects");
}, HP_EQ_DOUBLE_CLICK_MS);
}, [deviceState?.audio_enable, updateSetting, setLocation]);
useEffect(() => {
return () => {
if (hpEqNavigateTimerRef.current) clearTimeout(hpEqNavigateTimerRef.current);
if (effectNavigateTimerRef.current) clearTimeout(effectNavigateTimerRef.current);
};
}, []);
if (!isConnected) return <ConnectScreen />;
const ds = deviceState!;
const inputLabel = INPUT_LABELS[ds.input] ?? "USB-C";
const outputLabel = OUTPUT_LABELS[ds.output] ?? "Headset";
const effectOn = ds.audio_enable === 1;
const audioOn = ds.audio_enable === 1;
const vuLabel = `${homeText.vu ?? "VU表"}${(ds.vu ?? 0) + 1}`;
const bypassOn = (ds.dsp_enable ?? 0) === 0;
@@ -194,14 +247,16 @@ export default function Home() {
const el = e.currentTarget;
const rect = el.getBoundingClientRect();
knobDraggingRef.current = true;
knobActivePointerIdRef.current = e.pointerId;
knobStartAngleRef.current = pointAngle(e.clientX, e.clientY, rect);
knobStartVolRef.current = knobLastVolRef.current;
isDragging.current = true;
el.setPointerCapture(e.pointerId);
};
/** 拖动中只更新本地 UI,不调用 setVolume(避免来回旋时频繁打接口) */
const moveKnobDrag = (e: React.PointerEvent<HTMLDivElement>) => {
if (!knobDraggingRef.current) return;
if (!knobDraggingRef.current || knobActivePointerIdRef.current !== e.pointerId) return;
const el = e.currentTarget;
const rect = el.getBoundingClientRect();
const a = pointAngle(e.clientX, e.clientY, rect);
@@ -210,13 +265,15 @@ export default function Home() {
const stepsPerRad = 200 / (Math.PI * 1.5);
const next = Math.max(0, Math.min(200, Math.round(knobStartVolRef.current + delta * stepsPerRad)));
setLocalVol(next);
setVolume(next);
knobLastVolRef.current = next;
};
const endKnobDrag = (e: React.PointerEvent<HTMLDivElement>) => {
if (!knobDraggingRef.current) return;
const commitKnobDragEnd = (e: React.PointerEvent<HTMLDivElement>) => {
if (!knobDraggingRef.current || knobActivePointerIdRef.current !== e.pointerId) return;
knobDraggingRef.current = false;
knobActivePointerIdRef.current = null;
isDragging.current = false;
setVolume(knobLastVolRef.current);
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {
@@ -224,17 +281,18 @@ export default function Home() {
}
};
const endKnobDrag = (e: React.PointerEvent<HTMLDivElement>) => {
commitKnobDragEnd(e);
};
/** 异常丢失 capture 时仍提交一次,避免只松手未触发 pointerup 时漏提交 */
const lostKnobPointerCapture = (e: React.PointerEvent<HTMLDivElement>) => {
if (!knobDraggingRef.current || knobActivePointerIdRef.current !== e.pointerId) return;
commitKnobDragEnd(e);
};
return (
<div className="min-h-screen bg-black flex flex-col">
{/* ── Top bar ── */}
<div className="flex items-center justify-end px-4 pt-4 pb-2">
<button onClick={() => setLocation("/system")}
className="w-9 h-9 rounded-full flex items-center justify-center text-white/60 active:text-white transition-colors"
style={{ background: "rgba(44,44,46,0.6)" }}>
<Settings size={18} />
</button>
</div>
{/* ── Device render image ── */}
<div className="px-4 pb-4">
<div className="w-full rounded-2xl overflow-hidden flex items-center justify-center"
@@ -322,6 +380,7 @@ export default function Home() {
onPointerMove={moveKnobDrag}
onPointerUp={endKnobDrag}
onPointerCancel={endKnobDrag}
onLostPointerCapture={lostKnobPointerCapture}
>
<div className="w-1 h-4 rounded-full bg-white/40" style={{ transform: "rotate(-30deg)", transformOrigin: "bottom center" }} />
</div>
@@ -357,11 +416,40 @@ export default function Home() {
<input
type="range" min={0} max={200} value={localVol}
className="cyan-slider relative z-10"
onMouseDown={() => { isDragging.current = true; }}
onMouseUp={() => { isDragging.current = false; }}
onTouchStart={() => { isDragging.current = true; }}
onTouchEnd={() => { isDragging.current = false; }}
onChange={handleVolChange}
onChange={(e) => {
const v = Number(e.target.value);
setLocalVol(v);
}}
onPointerDown={() => {
isDragging.current = true;
}}
onPointerUp={(e) => {
isDragging.current = false;
setVolume(Number(e.currentTarget.value));
}}
onPointerCancel={(e) => {
isDragging.current = false;
setVolume(Number(e.currentTarget.value));
}}
onBlur={(e) => {
isDragging.current = false;
setVolume(Number(e.currentTarget.value));
}}
onKeyUp={(e) => {
const k = e.key;
if (
k === "ArrowLeft" ||
k === "ArrowRight" ||
k === "ArrowUp" ||
k === "ArrowDown" ||
k === "Home" ||
k === "End" ||
k === "PageUp" ||
k === "PageDown"
) {
setVolume(Number(e.currentTarget.value));
}
}}
/>
</div>
@@ -480,8 +568,9 @@ export default function Home() {
{/* EQ */}
<div className="flex flex-col items-center gap-2">
<button
type="button"
className={cn("icon-circle-btn", !bypassOn && ds.peqEnable === 1 && "active")}
onClick={() => setLocation("/eq")}
onClick={handleHpEqCircleClick}
>
<Sliders size={22} className={(!bypassOn && ds.peqEnable === 1) ? "text-[#00FFF6]" : "text-white/60"} />
</button>
@@ -491,8 +580,9 @@ export default function Home() {
{/* Effect */}
<div className="flex flex-col items-center gap-2">
<button
type="button"
className={cn("icon-circle-btn", !bypassOn && effectOn && "active")}
onClick={() => setLocation("/effects")}
onClick={handleEffectCircleClick}
>
<Activity size={22} className={(!bypassOn && effectOn) ? "text-[#00FFF6]" : "text-white/60"} />
</button>
@@ -531,19 +621,16 @@ export default function Home() {
value={outputLabel}
onClick={() => setLocation("/io")}
/>
<ListRow
icon={<Sliders size={16} />}
label="Effect"
value={effectOn ? "已开启" : undefined}
toggle
checked={effectOn}
onToggle={(v) => updateSetting({ audio_enable: v ? 1 : 0 })}
/>
<ListRow
icon={<Settings size={16} />}
label={homeText.audioSet ?? "音频设置"}
onClick={() => setLocation("/audio")}
/>
<ListRow
icon={<Settings2 size={16} />}
label={homeText.systemSet ?? "系统设置"}
onClick={() => setLocation("/system")}
/>
<ListRow
icon={<Gauge size={16} />}
label={homeText.vu ?? "VU表"}