Files
controller-v2/client/src/pages/Home.tsx
T
eafonyang ccc404b75a style(appShell): 优化应用外壳及底部导航样式与结构
- 在 tailwind 配置中添加应用外壳相关的 safelist 类名
- 替换 App.tsx 中应用外壳容器的类名,去除多余的内边距容器
- 在现代和遗留样式文件中新增移动端全屏、桌面居中列的样式规则
- 新增底部导航外层和内层的样式及响应式布局支持
- appShell.ts 中将样式类改为统一的 x8- 前缀类名
- BottomNav 组件使用新的样式类名以匹配样式调整
- Home 页面 ListRow 组件支持可选缩略图展示,增加界面展示丰富度
2026-06-03 11:52:57 +08:00

973 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
HOME — Luxsin X9 Controller (Main Dashboard)
Design: Reference luxsin_x8_首页.png
Layout (top to bottom):
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 (电源/HP-EQ/Effect/BypassHP-EQ/Effect 支持双击开关)
6. iOS list rows (输入源/输出端口/音频设置/系统设置/VU表)
============================================================ */
import { useDevice } from "@/contexts/DeviceContext";
import ConnectionPlaceholder from "@/components/ConnectionPlaceholder";
import BottomNav from "@/components/BottomNav";
import ThumbOnlyRangeSlider from "@/components/ThumbOnlyRangeSlider";
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 {
ChevronRight,
Power,
Settings,
Settings2,
Sliders,
Activity,
Gauge,
Undo2,
ChevronDown,
Wifi,
Bluetooth,
} from "lucide-react";
import { useLocation } from "wouter";
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 "-100dB";
const db = (v - 200) / 2;
return `${db >= 0 ? "+" : ""}${db.toFixed(1)}dB`;
}
/** 耳机插孔彩蛋:大孔 → 上小孔 → 下小孔 */
const JACK_EGG_SEQUENCE = ["lg", "smTop", "smBot"] as const;
type JackId = (typeof JACK_EGG_SEQUENCE)[number];
const VU_BAR_COUNT = 20;
const EGG_DANCE_DURATION_MS = 5000;
const EGG_WAVE_DURATION_MS = 5000;
const EGG_WAVE_HALF_MS = EGG_WAVE_DURATION_MS / 2;
const EGG_BEAT_MS = 400;
const JACK_SEQUENCE_TIMEOUT_MS = 4000;
/** 与 IOPage 输出选项 index 2(耳机)一致 */
const HEADPHONE_OUTPUT_INDEX = 2;
/** 旋律主音在 20 段 VU 上的位置(约 5s) */
const EGG_MELODY_LEAD = [
0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 19, 17, 15, 13, 11, 9, 7, 5, 3, 1,
0, 4, 8, 12, 16, 19, 16, 12, 8, 4, 0, 2, 5, 9, 13, 17, 19, 14, 10, 6, 2,
0, 3, 7, 11, 15, 18, 15, 11, 7, 3,
];
function idleVuBarHeights(): number[] {
return Array.from({ length: VU_BAR_COUNT }, (_, i) =>
Math.max(0.12, Math.sin((i / VU_BAR_COUNT) * Math.PI) * 0.65 + 0.2),
);
}
/** 彩蛋律动:主峰随节拍移动,每拍起落,全柱叠加波纹 */
function vuHeightsForDance(elapsedMs: number): number[] {
const t = elapsedMs / 1000;
const beatIdx = Math.floor(elapsedMs / EGG_BEAT_MS) % EGG_MELODY_LEAD.length;
const lead = EGG_MELODY_LEAD[beatIdx];
const beatPhase = (elapsedMs % EGG_BEAT_MS) / EGG_BEAT_MS;
const kick = Math.sin(beatPhase * Math.PI);
return Array.from({ length: VU_BAR_COUNT }, (_, i) => {
const dist = Math.abs(i - lead);
let peak = 0.06;
if (dist === 0) peak = 1;
else if (dist === 1) peak = 0.82;
else if (dist === 2) peak = 0.62;
else if (dist === 3) peak = 0.42;
else if (dist === 4) peak = 0.26;
const ripple = 0.35 * Math.max(0, Math.sin(t * 2.75 + i * 0.62));
const sway = 0.1 * Math.sin(t * 1.4 + i * 0.35);
const h = 0.05 + peak * kick + ripple * (0.55 + kick * 0.45) + sway;
return Math.min(1, Math.max(0.05, h));
});
}
/** 彩蛋律动二:海浪形波从左→右,再右→左 */
function vuHeightsForWave(phaseElapsedMs: number): number[] {
const half = EGG_WAVE_HALF_MS;
const ltr = phaseElapsedMs < half;
const localMs = ltr ? phaseElapsedMs : phaseElapsedMs - half;
const t = localMs / 1000;
const direction = ltr ? 1 : -1;
const speed = 0.75;
return Array.from({ length: VU_BAR_COUNT }, (_, i) => {
const phase = t * speed * Math.PI * 2 - direction * i * 0.72;
const wave = (Math.sin(phase) + 1) / 2;
const foam = 0.06 + 0.05 * Math.sin(i * 0.28 + t * 0.5);
return Math.min(1, Math.max(0.06, foam + wave * 0.9));
});
}
// ── iOS-style Toggle ──
function IOSToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<label className="ios-toggle" onClick={(e) => e.stopPropagation()}>
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
<span className="ios-toggle-track">
<span className="ios-toggle-thumb" />
</span>
</label>
);
}
// ── List row ──
function ListRow({
icon, label, value, onClick, toggle, checked, onToggle, thumbnailSrc,
}: {
icon: React.ReactNode;
label: string;
value?: string;
onClick?: () => void;
toggle?: boolean;
checked?: boolean;
onToggle?: (v: boolean) => void;
/** Optional small thumbnail shown between value text and chevron. */
thumbnailSrc?: string;
}) {
return (
<div className="ios-list-row cursor-pointer" onClick={onClick}>
<div className="w-8 h-8 rounded-[8px] flex items-center justify-center mr-3 flex-shrink-0"
style={{ background: "rgba(0,255,246,0.1)", border: "1px solid rgba(0,255,246,0.15)" }}>
<span className="text-[#00FFF6]">{icon}</span>
</div>
<div className="flex-1 min-w-0">
<span className="text-[16px] text-white font-medium">{label}</span>
</div>
{toggle ? (
<IOSToggle checked={!!checked} onChange={onToggle ?? (() => {})} />
) : (
<div className="flex items-center gap-1.5">
{thumbnailSrc && (
<img
src={thumbnailSrc}
alt=""
className="w-16 h-12 rounded-[8px] object-cover flex-shrink-0"
style={{ border: "1px solid rgba(255,255,255,0.12)" }}
/>
)}
{value && <span className="ios-row-value">{value}</span>}
<ChevronRight size={16} className="ios-chevron" />
</div>
)}
</div>
);
}
export default function Home() {
const { isConnected, deviceState, setVolume, updateSetting, api, isDemoMode, ip } = 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 [volumeConfirm, setVolumeConfirm] = useState<null | "min" | "max">(null);
const [easterEggActive, setEasterEggActive] = useState(false);
const [vuHeights, setVuHeights] = useState<number[]>(idleVuBarHeights);
const jackProgressRef = useRef(0);
const jackResetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const eggRafRef = useRef<number | null>(null);
/** 下次三连击成功时播放的律动:1=旋律,2=海浪(各需单独三连击触发) */
const jackEggNextRef = useRef<1 | 2>(1);
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 volumeConfirmCopy = useMemo(() => {
if (volumeConfirm === "min") {
return {
title: homeText.volumeMinConfirmTitle ?? "确认将音量设为最小?",
desc:
homeText.volumeMinConfirmDesc ??
"耳机音量将调至最低(静音),是否继续?",
ok: homeText.volumeMinConfirmOk ?? "设为最小",
};
}
if (volumeConfirm === "max") {
return {
title: homeText.volumeMaxConfirmTitle ?? "确认将音量设为最大?",
desc:
homeText.volumeMaxConfirmDesc ??
"耳机音量将调至最大,是否继续?",
ok: homeText.volumeMaxConfirmOk ?? "设为最大",
};
}
return null;
}, [volumeConfirm, homeText]);
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);
useEffect(() => {
if (!isDragging.current && deviceState?.volume !== undefined) {
setLocalVol(deviceState.volume);
}
}, [deviceState?.volume]);
useEffect(() => {
knobLastVolRef.current = localVol;
}, [localVol]);
const handlePowerOff = useCallback(async () => {
if (powerActionRef.current) return;
powerActionRef.current = true;
try {
if (isDemoMode) {
toast.info(homeText.powerOffToastDemo ?? "演示模式:已发送关机指令");
return;
}
if (api) {
try {
await api.powerOff();
} catch {
// 设备可能已关机或未返回响应,仍保留当前页面与会话状态
}
}
toast.success(homeText.powerOffToastSent ?? "已发送关机指令");
} finally {
powerActionRef.current = false;
}
}, [api, isDemoMode, homeText]);
/** HP-EQ 圆钮:单击在短延迟后切换 peqEnable(避免与双击抢);280ms 内第二次点击则取消开关并进入 /eq */
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;
}
setLocation("/eq");
return;
}
hpEqLastTapRef.current = now;
if (hpEqNavigateTimerRef.current) clearTimeout(hpEqNavigateTimerRef.current);
hpEqNavigateTimerRef.current = setTimeout(() => {
hpEqNavigateTimerRef.current = null;
hpEqLastTapRef.current = null;
const bypassOn = (deviceState?.dsp_enable ?? 0) === 0;
if (bypassOn) {
void updateSetting({ peqEnable: 1, dsp_enable: 1 });
} else {
const on = (deviceState?.peqEnable ?? 0) === 1;
void updateSetting({ peqEnable: on ? 0 : 1 });
}
}, HP_EQ_DOUBLE_CLICK_MS);
}, [deviceState?.peqEnable, deviceState?.dsp_enable, updateSetting, setLocation]);
/** Effect 圆钮:单击短延迟后切换 audio_enable280ms 内第二次点击则进入 /effects */
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;
}
setLocation("/effects");
return;
}
effectLastTapRef.current = now;
if (effectNavigateTimerRef.current) clearTimeout(effectNavigateTimerRef.current);
effectNavigateTimerRef.current = setTimeout(() => {
effectNavigateTimerRef.current = null;
effectLastTapRef.current = null;
const bypassOn = (deviceState?.dsp_enable ?? 0) === 0;
if (bypassOn) {
void updateSetting({ audio_enable: 1, dsp_enable: 1 });
} else {
const on = (deviceState?.audio_enable ?? 0) === 1;
void updateSetting({ audio_enable: on ? 0 : 1 });
}
}, HP_EQ_DOUBLE_CLICK_MS);
}, [deviceState?.audio_enable, deviceState?.dsp_enable, updateSetting, setLocation]);
useEffect(() => {
return () => {
if (hpEqNavigateTimerRef.current) clearTimeout(hpEqNavigateTimerRef.current);
if (effectNavigateTimerRef.current) clearTimeout(effectNavigateTimerRef.current);
};
}, []);
const runJackEggAnimation = useCallback(
(mode: "dance" | "wave") => {
setEasterEggActive(true);
setVuHeights(mode === "dance" ? vuHeightsForDance(0) : vuHeightsForWave(0));
jackProgressRef.current = 0;
const duration = mode === "dance" ? EGG_DANCE_DURATION_MS : EGG_WAVE_DURATION_MS;
const start = performance.now();
const tick = (now: number) => {
const elapsed = now - start;
if (elapsed >= duration) {
setEasterEggActive(false);
setVuHeights(idleVuBarHeights());
eggRafRef.current = null;
return;
}
setVuHeights(
mode === "dance" ? vuHeightsForDance(elapsed) : vuHeightsForWave(elapsed),
);
eggRafRef.current = requestAnimationFrame(tick);
};
if (eggRafRef.current !== null) cancelAnimationFrame(eggRafRef.current);
eggRafRef.current = requestAnimationFrame(tick);
},
[],
);
const onJackClick = useCallback(
(jack: JackId) => {
if (easterEggActive) return;
const expected = JACK_EGG_SEQUENCE[jackProgressRef.current];
if (jack === expected) {
jackProgressRef.current += 1;
if (jackProgressRef.current >= JACK_EGG_SEQUENCE.length) {
if (jackEggNextRef.current === 1) {
runJackEggAnimation("dance");
jackEggNextRef.current = 2;
} else {
runJackEggAnimation("wave");
jackEggNextRef.current = 1;
}
}
} else {
jackProgressRef.current = jack === JACK_EGG_SEQUENCE[0] ? 1 : 0;
}
if (jackResetTimerRef.current) clearTimeout(jackResetTimerRef.current);
jackResetTimerRef.current = setTimeout(() => {
jackProgressRef.current = 0;
}, JACK_SEQUENCE_TIMEOUT_MS);
},
[easterEggActive, runJackEggAnimation],
);
useEffect(() => {
return () => {
if (jackResetTimerRef.current) clearTimeout(jackResetTimerRef.current);
if (eggRafRef.current !== null) cancelAnimationFrame(eggRafRef.current);
};
}, []);
const volumePassthroughActive =
(deviceState?.dacVolumeDirect ?? 0) === 1 || (deviceState?.dacVolumeDirect ?? 0) === 2;
const volumeControlsLocked =
volumePassthroughActive &&
(deviceState?.output ?? 0) !== HEADPHONE_OUTPUT_INDEX;
const volumePassthroughHint =
homeText.volumePassthroughLockedHint ??
homeText.volumePassthroughLockedToast ??
"当前为音量直通模式,无法调节音量";
const guardVolumeChange = useCallback(
(action: () => void) => {
if (volumeControlsLocked) return;
action();
},
[volumeControlsLocked],
);
if (!isConnected) return <ConnectionPlaceholder />;
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 vuLabel = `${homeText.vu ?? "VU表"}${(ds.vu ?? 0) + 1}`;
const bypassOn = (ds.dsp_enable ?? 0) === 0;
// Home 的 volume 取值为 0..200,对应 dB = (v - 200) / 2
// 因此 1 个 volume 单位 = 0.5dB
// AudioPage 的 soundStep0->0.5dB, 1->1dB, 2->2dB, 3->3dB
// 换算为 volume 单位:0.5/1/2/3 dB -> 1/2/4/6
const fineVolumeStep = [1, 2, 4, 6][ds.soundStep ?? 1] ?? 1;
// Slider fill % (0-200 → 0-100%)
const fillPct = (localVol / 200) * 100;
// Knob angle (map 0..200 -> -135..+135 degrees)
const knobAngle = -135 + (localVol / 200) * 270;
const pointAngle = (clientX: number, clientY: number, rect: DOMRect) => {
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
return Math.atan2(clientY - cy, clientX - cx);
};
const normalizeDelta = (delta: number) => {
// Wrap to [-PI, PI] to avoid jumps across the -PI/PI boundary.
const pi2 = Math.PI * 2;
let d = ((delta + Math.PI) % pi2) - Math.PI;
if (d < -Math.PI) d += pi2;
return d;
};
const beginKnobDrag = (e: React.PointerEvent<HTMLDivElement>) => {
if (volumeControlsLocked) return;
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 || knobActivePointerIdRef.current !== e.pointerId) return;
const el = e.currentTarget;
const rect = el.getBoundingClientRect();
const a = pointAngle(e.clientX, e.clientY, rect);
const delta = normalizeDelta(a - knobStartAngleRef.current);
// 270deg travel -> full scale; 1 rad maps to ~200/(1.5*pi) volume steps
const stepsPerRad = 200 / (Math.PI * 1.5);
const next = Math.max(0, Math.min(200, Math.round(knobStartVolRef.current + delta * stepsPerRad)));
setLocalVol(next);
knobLastVolRef.current = next;
};
const commitKnobDragEnd = (e: React.PointerEvent<HTMLDivElement>) => {
if (!knobDraggingRef.current || knobActivePointerIdRef.current !== e.pointerId) return;
knobDraggingRef.current = false;
knobActivePointerIdRef.current = null;
isDragging.current = false;
if (volumeControlsLocked) return;
setVolume(knobLastVolRef.current);
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {
// ignore
}
};
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-dvh bg-black pb-[calc(5.5rem+env(safe-area-inset-bottom,0px))]">
{/* ── Device render image ── */}
<div className="px-4 pb-4">
<div className="w-full rounded-2xl overflow-hidden flex items-center justify-center"
style={{ background: "rgba(18,18,20,0.8)", minHeight: 140 }}>
{/* Stylized device illustration */}
<div className="w-full relative" style={{ height: 140 }}>
{/* Device body */}
<div className="absolute inset-x-4 inset-y-3 rounded-xl flex items-center"
style={{ background: "linear-gradient(135deg, #1a1a1c 0%, #2a2a2e 50%, #1a1a1c 100%)", border: "1px solid rgba(255,255,255,0.08)" }}>
{/* Left ports */}
<div className="px-4">
<div className="flex items-center gap-3">
{/* Big hole (left) — 彩蛋第 1 步 */}
<button
type="button"
className="w-7 h-7 rounded-full border-2 bg-black/25 flex items-center justify-center cursor-pointer active:scale-95 transition-transform"
style={{
borderColor: "rgba(245, 158, 11, 0.75)",
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.35)",
}}
aria-label="Headphone jack"
onClick={() => onJackClick("lg")}
>
<div
className="w-2 h-2 rounded-full pointer-events-none"
style={{ background: "rgba(196, 126, 9, 0.9)" }}
/>
</button>
{/* Two small holes (right, stacked) */}
<div className="flex flex-col gap-2">
<button
type="button"
className="w-5 h-5 rounded-full border-2 bg-black/25 cursor-pointer active:scale-95 transition-transform"
style={{
borderColor: "rgba(245, 158, 11, 0.65)",
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.35)",
}}
aria-label="Headphone jack"
onClick={() => onJackClick("smTop")}
/>
<button
type="button"
className="w-5 h-5 rounded-full border-2 bg-black/25 cursor-pointer active:scale-95 transition-transform"
style={{
borderColor: "rgba(245, 158, 11, 0.65)",
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.35)",
}}
aria-label="Headphone jack"
onClick={() => onJackClick("smBot")}
/>
</div>
</div>
</div>
{/* VU display area */}
<div className="flex-1 mx-3 rounded-lg overflow-hidden"
style={{ background: "#0d1208", border: "1px solid rgba(255,255,255,0.1)", height: 90 }}>
<div className="flex items-center justify-between px-2 pt-1">
<span className="text-[9px] text-white/50">{ds.audioFormat}</span>
<span className="text-[9px] text-white/50">{volToDB(localVol)}</span>
</div>
{/* Mini VU bars */}
<div
className={cn(
"flex gap-0.5 px-2 pt-1 pb-2 h-16 items-end",
easterEggActive && "brightness-110",
)}
>
{vuHeights.map((h, i) => {
const isHot = i > 16;
return (
<div
key={i}
className="flex-1 rounded-md"
style={{
height: `${h * 100}%`,
background: isHot ? "#ef4444" : i > 13 ? "#f59e0b" : "#00FFF6",
opacity: easterEggActive ? 0.95 : 0.7,
transition: easterEggActive
? "none"
: "height 200ms ease-out, opacity 200ms ease-out",
}}
/>
);
})}
</div>
</div>
{/* Right knob */}
<div className="pr-4">
<div
className={cn(
"w-12 h-12 rounded-full flex items-center justify-center touch-none select-none",
volumeControlsLocked && "opacity-45 cursor-not-allowed",
)}
style={{
background: "radial-gradient(circle at 35% 35%, #4a4a4e, #1a1a1c)",
border: "2px solid rgba(255,255,255,0.12)",
boxShadow: "inset 0 2px 4px rgba(0,0,0,0.5)",
transform: `rotate(${knobAngle}deg)`,
transition: knobDraggingRef.current ? "none" : "transform 120ms ease-out",
}}
role="slider"
aria-label="Volume"
aria-valuemin={0}
aria-valuemax={200}
aria-valuenow={localVol}
onPointerDown={beginKnobDrag}
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>
</div>
</div>
{/* Brand label */}
<div className="absolute top-5 left-8">
<span className="text-[11px] font-bold tracking-widest text-white/30">LUXSIN</span>
</div>
</div>
</div>
</div>
{/* ── Volume slider ── */}
<div className="px-4 mb-5">
{volumeControlsLocked && (
<p
className="mb-2.5 rounded-[10px] border border-amber-400/25 bg-amber-500/10 px-3 py-2 text-center text-[12px] leading-relaxed text-amber-100/90"
role="status"
>
{volumePassthroughHint}
</p>
)}
<div className="flex items-center gap-3">
<button
type="button"
disabled={volumeControlsLocked}
onClick={() => guardVolumeChange(() => setVolumeConfirm("min"))}
className={cn(
"text-white/50 active:text-white transition-colors flex-shrink-0",
volumeControlsLocked && "opacity-40 cursor-not-allowed",
)}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-6 h-6">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
<line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/>
</svg>
</button>
<div
className={cn(
"flex-1 relative flex items-center touch-pan-y",
volumeControlsLocked && "opacity-50",
)}
>
{/* Track fill - 根据按钮位置调整 */}
<div className="absolute left-0 h-[4px] rounded-full pointer-events-none"
style={{
width: `${fillPct}%`,
background: volumeControlsLocked ? "rgba(148,163,184,0.7)" : "#00FFF6",
boxShadow: volumeControlsLocked ? "none" : "0 0 8px rgba(0,255,246,0.5)",
}}
/>
<ThumbOnlyRangeSlider
min={0}
max={200}
value={localVol}
disabled={volumeControlsLocked}
onChange={(v) => {
if (volumeControlsLocked) return;
setLocalVol(v);
}}
onDragStart={() => {
if (volumeControlsLocked) return;
isDragging.current = true;
}}
onDragEnd={() => {
isDragging.current = false;
}}
onRelease={(v) => {
if (volumeControlsLocked) return;
setVolume(v);
}}
/>
</div>
<button
type="button"
disabled={volumeControlsLocked}
onClick={() => guardVolumeChange(() => setVolumeConfirm("max"))}
className={cn(
"text-white/50 active:text-white transition-colors flex-shrink-0",
volumeControlsLocked && "opacity-40 cursor-not-allowed",
)}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-6 h-6">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
</svg>
</button>
</div>
{/* dB value */}
<div className="mt-[6px] flex items-center justify-center gap-3">
<button
type="button"
aria-label="Decrease volume"
disabled={volumeControlsLocked || localVol <= 0}
className="w-10 h-10 rounded-full flex items-center justify-center transition-all active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed"
style={{
background: "rgba(44,44,46,0.6)",
border: "1px solid rgba(255,255,255,0.08)",
color: "rgba(255,255,255,0.55)",
}}
onClick={() => {
guardVolumeChange(() => {
const next = Math.max(0, Math.min(200, localVol - fineVolumeStep));
setLocalVol(next);
setVolume(next);
});
}}
>
<span className="text-[20px] leading-none">-</span>
</button>
<span className="text-[22px] font-bold tracking-tight" style={{ color: "#00FFF6" }}>
{volToDB(localVol)}
</span>
<button
type="button"
aria-label="Increase volume"
disabled={volumeControlsLocked || localVol >= 200}
className="w-10 h-10 rounded-full flex items-center justify-center transition-all active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed"
style={{
background: "rgba(44,44,46,0.6)",
border: "1px solid rgba(255,255,255,0.08)",
color: "rgba(255,255,255,0.55)",
}}
onClick={() => {
guardVolumeChange(() => {
const next = Math.max(0, Math.min(200, localVol + fineVolumeStep));
setLocalVol(next);
setVolume(next);
});
}}
>
<span className="text-[20px] leading-none">+</span>
</button>
</div>
{/* Bluetooth info */}
{ds.bt_status !== 0 && (
<div className="mt-3 px-3 py-3 text-center">
<div className="flex items-center justify-center gap-1.5 mb-1">
<Bluetooth size={14} className="text-[#00FFF6]" />
<span className="text-[11px] text-white/50">{ds.bt_srcname}</span>
</div>
{ds.bt_title && (
<p className="text-[13px] text-white font-medium truncate mb-2">
{ds.bt_title}
{ds.bt_artist && <span className="text-white/50"> - {ds.bt_artist}</span>}
</p>
)}
{/* Playback controls */}
<div className="flex items-center justify-center gap-4 mt-2">
<button
onClick={() => updateSetting({ bt_next: 0 })}
className="w-10 h-10 rounded-full flex items-center justify-center transition-all active:scale-95"
style={{ background: "rgba(0,255,246,0.15)", border: "1px solid rgba(0,255,246,0.3)" }}>
<img src={`${import.meta.env.BASE_URL}player/skip-forward.png`} alt="上一首" className="w-5 h-5" />
</button>
<button
onClick={() => updateSetting({ bt_play: 1 })}
className="w-12 h-12 rounded-full flex items-center justify-center transition-all active:scale-95"
style={{ background: "transparent", border: "1px solid rgba(0,255,246,0.5)", boxShadow: "0 0 12px rgba(0,255,246,0.4)" }}>
<img
src={ds.bt_status === 1
? `${import.meta.env.BASE_URL}player/play.png`
: `${import.meta.env.BASE_URL}player/pause.png`}
alt={ds.bt_status === 1 ? "播放" : "暂停"}
className="w-6 h-6"
/>
</button>
<button
onClick={() => updateSetting({ bt_next: 1 })}
className="w-10 h-10 rounded-full flex items-center justify-center transition-all active:scale-95"
style={{ background: "rgba(0,255,246,0.15)", border: "1px solid rgba(0,255,246,0.3)" }}>
<img src={`${import.meta.env.BASE_URL}player/skip-back.png`} alt="下一首" className="w-5 h-5" />
</button>
</div>
</div>
)}
</div>
{/* ── 4 quick-action circle buttons ── */}
<div className="flex justify-around px-6 mb-6">
{/* Power */}
<div className="flex flex-col items-center gap-2">
<button
type="button"
className="icon-circle-btn"
onClick={() => setPowerConfirmOpen(true)}
>
<Power size={22} className="text-white/60" />
</button>
<span className="text-[11px] text-white/35">{homeText.power ?? "电源"}</span>
</div>
{/* EQ */}
<div className="flex flex-col items-center gap-2">
<button
type="button"
className={cn("icon-circle-btn", !bypassOn && ds.peqEnable === 1 && "active")}
onClick={handleHpEqCircleClick}
>
<Sliders size={22} className={(!bypassOn && ds.peqEnable === 1) ? "text-[#00FFF6]" : "text-white/60"} />
</button>
<div className="flex flex-col items-center gap-0.5">
<span className={cn("text-[11px]", (!bypassOn && ds.peqEnable === 1) ? "text-[#00FFF6]" : "text-white/35")}>HP-EQ</span>
<span className="text-[9px] text-white/25 text-center leading-snug max-w-[76px]">
{homeText.doubleClickHint ?? "Double-click to enter"}
</span>
</div>
</div>
{/* Effect */}
<div className="flex flex-col items-center gap-2">
<button
type="button"
className={cn("icon-circle-btn", !bypassOn && effectOn && "active")}
onClick={handleEffectCircleClick}
>
<Activity size={22} className={(!bypassOn && effectOn) ? "text-[#00FFF6]" : "text-white/60"} />
</button>
<div className="flex flex-col items-center gap-0.5">
<span className={cn("text-[11px]", (!bypassOn && effectOn) ? "text-[#00FFF6]" : "text-white/35")}>Effect</span>
<span className="text-[9px] text-white/25 text-center leading-snug max-w-[76px]">
{homeText.doubleClickHint ?? "Double-click to enter"}
</span>
</div>
</div>
{/* Bypass (DSP) */}
<div className="flex flex-col items-center gap-2">
<button
className={cn("icon-circle-btn", bypassOn && "active")}
onClick={() => updateSetting({ dsp_enable: bypassOn ? 1 : 0 })}
>
<img
src={`${import.meta.env.BASE_URL}home/bypass.png`}
alt="Bypass"
className="w-[25px] h-[25px] object-contain"
style={{ opacity: bypassOn ? 1 : 0.6, transform: "translateX(1.5px)" }}
/>
</button>
<span className={cn("text-[11px]", bypassOn ? "text-[#00FFF6]" : "text-white/35")}>Bypass</span>
</div>
</div>
{/* ── iOS list rows ── */}
<div className="px-4">
<div className="ios-list-group">
<ListRow
icon={<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-4 h-4"><path d="M9 3H5a2 2 0 0 0-2 2v4m6-6h10a2 2 0 0 1 2 2v4M9 3v18m0 0h10a2 2 0 0 0 2-2V9M9 21H5a2 2 0 0 1-2-2V9m0 0h18"/></svg>}
label={homeText.input ?? "输入源"}
value={inputLabel}
onClick={() => setLocation("/io")}
/>
<ListRow
icon={<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-4 h-4"><path d="M15 3H9a2 2 0 0 0-2 2v4m8-6h4a2 2 0 0 1 2 2v4M15 3v18m0 0H9a2 2 0 0 1-2-2V9m8 12h4a2 2 0 0 0 2-2V9M7 9H3"/></svg>}
label={homeText.output ?? "输出端口"}
value={outputLabel}
onClick={() => setLocation("/io")}
/>
<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表"}
value={vuLabel}
thumbnailSrc={`${import.meta.env.BASE_URL}vu/vu${(ds.vu ?? 0) + 1}.png`}
onClick={() => setLocation("/vu")}
/>
<ListRow
icon={<Undo2 size={16} />}
label={homeText.backToLegacy ?? "返回旧版"}
onClick={() => {
const target = ip.trim();
if (!target) return;
window.location.href = `https://am.luxsinaudio.com/x8/i.html?ip=${encodeURIComponent(target)}#/home`;
}}
/>
</div>
</div>
<AlertDialog
open={volumeConfirm !== null}
onOpenChange={(open) => {
if (!open) setVolumeConfirm(null);
}}
>
<AlertDialogContent className="top-[42%] border-white/10 bg-zinc-900 text-white sm:max-w-md">
<AlertDialogHeader>
<AlertDialogTitle className="text-white">
{volumeConfirmCopy?.title}
</AlertDialogTitle>
<AlertDialogDescription className="text-white/60">
{volumeConfirmCopy?.desc}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="border-white/20 bg-transparent text-white hover:bg-white/10">
{homeText.powerOffConfirmCancel ?? "取消"}
</AlertDialogCancel>
<AlertDialogAction
className="bg-[#00FFF6] text-black hover:bg-[#00FFF6]/90 focus-visible:ring-[#00FFF6]"
onClick={() => {
if (volumeControlsLocked) {
setVolumeConfirm(null);
return;
}
if (volumeConfirm === "min") {
setLocalVol(0);
setVolume(0);
} else if (volumeConfirm === "max") {
setLocalVol(200);
setVolume(200);
}
setVolumeConfirm(null);
}}
>
{volumeConfirmCopy?.ok}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={powerConfirmOpen} onOpenChange={setPowerConfirmOpen}>
<AlertDialogContent className="top-[42%] 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>
);
}