962 lines
38 KiB
TypeScript
962 lines
38 KiB
TypeScript
/* ============================================================
|
||
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/Bypass;HP-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,
|
||
}: {
|
||
icon: React.ReactNode;
|
||
label: string;
|
||
value?: string;
|
||
onClick?: () => void;
|
||
toggle?: boolean;
|
||
checked?: boolean;
|
||
onToggle?: (v: boolean) => void;
|
||
}) {
|
||
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">
|
||
{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_enable;280ms 内第二次点击则进入 /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 的 soundStep:0->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}
|
||
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>
|
||
);
|
||
}
|