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:
+125
-38
@@ -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/Bypass;HP-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表"}
|
||||
|
||||
Reference in New Issue
Block a user