修复bug

This commit is contained in:
yangy
2026-05-27 15:10:04 +08:00
parent fe2a0f6b3d
commit b59a7bb583
10 changed files with 412 additions and 49 deletions
+14 -1
View File
@@ -89,6 +89,7 @@ export interface DeviceState {
dacGain: number; dacGain: number;
dacArc: number; dacArc: number;
dacImpedance: number; dacImpedance: number;
dreMode: number;
dacVolumeDirect: number; dacVolumeDirect: number;
analogGain: number; analogGain: number;
effect_enable: number; effect_enable: number;
@@ -213,7 +214,9 @@ export class LuxsinAPI {
const response = await fetch(`${this.baseUrl}/dev/info.cgi?action=syncData`); const response = await fetch(`${this.baseUrl}/dev/info.cgi?action=syncData`);
const text = await response.text(); const text = await response.text();
const decoded = decodeCustomBase64(text.trim()); const decoded = decodeCustomBase64(text.trim());
return JSON.parse(decoded) as DeviceState; const state = JSON.parse(decoded) as DeviceState;
console.log("[syncData]", state);
return state;
} catch (error) { } catch (error) {
this.maybeRedirectForHttpsCert(error); this.maybeRedirectForHttpsCert(error);
throw error; throw error;
@@ -333,6 +336,15 @@ export function parseFirmwareVersion(version: unknown): number {
return Number(parts[parts.length - 1]); return Number(parts[parts.length - 1]);
} }
/** UI display: build number 26 → `1.0.0.26`. Already-prefixed values are unchanged. */
export function formatFirmwareVersionDisplay(version: unknown): string {
if (version === null || version === undefined) return "—";
const raw = String(version).trim();
if (!raw) return "—";
if (/^1\.0\.0\./i.test(raw)) return raw;
return `1.0.0.${raw}`;
}
/** Max `bootSound` index available for the current firmware. */ /** Max `bootSound` index available for the current firmware. */
export function getBootSoundMaxIndex(firmwareVersion: number): number { export function getBootSoundMaxIndex(firmwareVersion: number): number {
return firmwareVersion >= 26 ? 10 : 6; return firmwareVersion >= 26 ? 10 : 6;
@@ -387,6 +399,7 @@ export const MOCK_DEVICE_STATE: DeviceState = {
dacGain: 0, dacGain: 0,
dacArc: 0, dacArc: 0,
dacImpedance: 0, dacImpedance: 0,
dreMode: 0,
dacVolumeDirect: 0, dacVolumeDirect: 0,
analogGain: 0, analogGain: 0,
effect_enable: 0, effect_enable: 0,
+33 -1
View File
@@ -24,7 +24,9 @@
"volumeMaxConfirmTitle": "Set volume to maximum?", "volumeMaxConfirmTitle": "Set volume to maximum?",
"volumeMaxConfirmDesc": "Volume will be set to the highest level. Continue?", "volumeMaxConfirmDesc": "Volume will be set to the highest level. Continue?",
"volumeMaxConfirmOk": "Set to maximum", "volumeMaxConfirmOk": "Set to maximum",
"powerOffToastDemo": "Demo mode: disconnected from the device.", "volumePassthroughLockedToast": "Volume is in passthrough mode and cannot be adjusted.",
"volumePassthroughLockedHint": "Volume is in passthrough mode and cannot be adjusted.",
"powerOffToastDemo": "Demo mode: shutdown command sent.",
"powerOffToastSent": "Shutdown command sent.", "powerOffToastSent": "Shutdown command sent.",
"doubleClickHint": "Double-click to enter", "doubleClickHint": "Double-click to enter",
"backToLegacy": "Legacy version" "backToLegacy": "Legacy version"
@@ -382,6 +384,36 @@
} }
] ]
}, },
"autoImpedance": {
"label": "Auto Impedance Detection",
"options": [
{ "index": 0, "label": "Off" },
{ "index": 1, "label": "On" }
]
},
"dreMode": {
"label": "Dynamic Range Enhancement",
"options": [
{ "index": 0, "label": "Off" },
{ "index": 1, "label": "On" }
]
},
"dacVolumeDirect": {
"label": "Pre-out Volume passthrough",
"options": [
{ "index": 0, "label": "Off" },
{ "index": 1, "label": "0dB" },
{ "index": 2, "label": "-12dB" }
]
},
"dacVolumeDirectConfirm": {
"title0db": "Enable 0 dB passthrough?",
"desc0db": "Passthrough mode sets volume to maximum and disables volume adjustment.",
"title12db": "Enable -12 dB passthrough?",
"desc12db": "Passthrough mode sets volume to -12 dB and disables volume adjustment.",
"confirm": "Confirm",
"cancel": "Cancel"
},
"volumeStep": "Volume step", "volumeStep": "Volume step",
"bootVolume": { "bootVolume": {
"label": "Boot volume", "label": "Boot volume",
+33 -1
View File
@@ -24,7 +24,9 @@
"volumeMaxConfirmTitle": "確認將音量設為最大?", "volumeMaxConfirmTitle": "確認將音量設為最大?",
"volumeMaxConfirmDesc": "音量將調至最大,是否繼續?", "volumeMaxConfirmDesc": "音量將調至最大,是否繼續?",
"volumeMaxConfirmOk": "設為最大", "volumeMaxConfirmOk": "設為最大",
"powerOffToastDemo": "演示模式:已中斷與裝置的連線", "volumePassthroughLockedToast": "目前為音量直通模式,無法調節音量",
"volumePassthroughLockedHint": "目前為音量直通模式,無法調節音量",
"powerOffToastDemo": "演示模式:已傳送關機指令",
"powerOffToastSent": "已傳送關機指令", "powerOffToastSent": "已傳送關機指令",
"doubleClickHint": "按兩下進入", "doubleClickHint": "按兩下進入",
"backToLegacy": "返回舊版" "backToLegacy": "返回舊版"
@@ -291,6 +293,36 @@
{ "index": 2, "label": "高" } { "index": 2, "label": "高" }
] ]
}, },
"autoImpedance": {
"label": "自動檢測耳機阻抗",
"options": [
{ "index": 0, "label": "關閉" },
{ "index": 1, "label": "打開" }
]
},
"dreMode": {
"label": "動態範圍增強",
"options": [
{ "index": 0, "label": "關閉" },
{ "index": 1, "label": "打開" }
]
},
"dacVolumeDirect": {
"label": "前級輸出音量直通模式",
"options": [
{ "index": 0, "label": "關閉" },
{ "index": 1, "label": "0dB" },
{ "index": 2, "label": "-12dB" }
]
},
"dacVolumeDirectConfirm": {
"title0db": "開啟 0dB 直通模式?",
"desc0db": "直通模式會將音量調到最大,並且不可調節。",
"title12db": "開啟 -12dB 直通模式?",
"desc12db": "直通模式會將音量調到 -12dB,並且不可調節。",
"confirm": "確認",
"cancel": "取消"
},
"volumeStep": "音量級距", "volumeStep": "音量級距",
"bootVolume": { "bootVolume": {
"label": "開機音量", "label": "開機音量",
+33 -1
View File
@@ -24,7 +24,9 @@
"volumeMaxConfirmTitle": "确认将音量设为最大?", "volumeMaxConfirmTitle": "确认将音量设为最大?",
"volumeMaxConfirmDesc": "音量将调至最大,是否继续?", "volumeMaxConfirmDesc": "音量将调至最大,是否继续?",
"volumeMaxConfirmOk": "设为最大", "volumeMaxConfirmOk": "设为最大",
"powerOffToastDemo": "演示模式:已断开与设备的连接", "volumePassthroughLockedToast": "当前为音量直通模式,无法调节音量",
"volumePassthroughLockedHint": "当前为音量直通模式,无法调节音量",
"powerOffToastDemo": "演示模式:已发送关机指令",
"powerOffToastSent": "已发送关机指令", "powerOffToastSent": "已发送关机指令",
"doubleClickHint": "双击进入", "doubleClickHint": "双击进入",
"backToLegacy": "返回旧版" "backToLegacy": "返回旧版"
@@ -291,6 +293,36 @@
{ "index": 2, "label": "高" } { "index": 2, "label": "高" }
] ]
}, },
"autoImpedance": {
"label": "自动检测耳机阻抗",
"options": [
{ "index": 0, "label": "关闭" },
{ "index": 1, "label": "打开" }
]
},
"dreMode": {
"label": "动态范围增强",
"options": [
{ "index": 0, "label": "关闭" },
{ "index": 1, "label": "打开" }
]
},
"dacVolumeDirect": {
"label": "前级输出音量直通模式",
"options": [
{ "index": 0, "label": "关闭" },
{ "index": 1, "label": "0dB" },
{ "index": 2, "label": "-12dB" }
]
},
"dacVolumeDirectConfirm": {
"title0db": "开启 0dB 直通模式?",
"desc0db": "直通模式会将音量调到最大,并且不可调节。",
"title12db": "开启 -12dB 直通模式?",
"desc12db": "直通模式会将音量调到 -12dB,并且不可调节。",
"confirm": "确认",
"cancel": "取消"
},
"volumeStep": "音量幅度", "volumeStep": "音量幅度",
"bootVolume": { "bootVolume": {
"label": "开机音量", "label": "开机音量",
+59
View File
@@ -70,6 +70,15 @@ function SectionHeader({ title }: { title: string }) {
); );
} }
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>
);
}
function ListRow({ label, value, onClick }: { label: string; value: string; onClick?: () => void }) { function ListRow({ label, value, onClick }: { label: string; value: string; onClick?: () => void }) {
return ( return (
<button className="ios-list-row w-full text-left active:bg-white/5 transition-colors" onClick={onClick}> <button className="ios-list-row w-full text-left active:bg-white/5 transition-colors" onClick={onClick}>
@@ -80,6 +89,23 @@ function ListRow({ label, value, onClick }: { label: string; value: string; onCl
); );
} }
function ToggleRow({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<div className="ios-list-row">
<span className="flex-1 text-[16px] text-white">{label}</span>
<IOSToggle checked={checked} onChange={onChange} />
</div>
);
}
type LocaleDac = { type LocaleDac = {
dac?: { dac?: {
sectionBalanceSensitivity?: string; sectionBalanceSensitivity?: string;
@@ -90,6 +116,9 @@ type LocaleDac = {
sensitivity?: string; sensitivity?: string;
filters?: { label?: string; options?: Array<{ index: number; label: string }> }; filters?: { label?: string; options?: Array<{ index: number; label: string }> };
dacGain?: { label?: string; options?: Array<{ index: number; label: string }> }; dacGain?: { label?: string; options?: Array<{ index: number; label: string }> };
autoImpedance?: { label?: string; options?: Array<{ index: number; label: string }> };
dreMode?: { label?: string; options?: Array<{ index: number; label: string }> };
dacVolumeDirect?: { label?: string; options?: Array<{ index: number; label: string }> };
volumeStep?: string; volumeStep?: string;
bootVolume?: string | { label?: string; options?: Array<{ index: number; label: string }> }; bootVolume?: string | { label?: string; options?: Array<{ index: number; label: string }> };
xlr?: { label?: string; options?: Array<{ index: number; label: string }> }; xlr?: { label?: string; options?: Array<{ index: number; label: string }> };
@@ -192,6 +221,9 @@ export default function AudioPage() {
}, [location, isConnected, api, refresh]); }, [location, isConnected, api, refresh]);
const gainIdx = ds?.dacGain ?? 0; const gainIdx = ds?.dacGain ?? 0;
const autoImpedanceOn = (ds?.dacImpedance ?? 0) === 1;
const dreModeOn = (ds?.dreMode ?? 0) === 1;
const dacVolumeDirectIdx = Math.min(Math.max(ds?.dacVolumeDirect ?? 0, 0), 2);
const stepIdx = ds?.soundStep ?? 1; const stepIdx = ds?.soundStep ?? 1;
const filterIdx = ds?.pcm ?? 0; const filterIdx = ds?.pcm ?? 0;
const xlrIdx = ds?.xlr ?? 0; const xlrIdx = ds?.xlr ?? 0;
@@ -208,6 +240,11 @@ export default function AudioPage() {
const XLR_OPTIONS = optionLabels(dacText.xlr?.options, ["正常", "反转"]); const XLR_OPTIONS = optionLabels(dacText.xlr?.options, ["正常", "反转"]);
const MUTE_POLAR_OPTIONS = optionLabels(dacText.mutePolar?.options, ["低电平", "高电平"]); const MUTE_POLAR_OPTIONS = optionLabels(dacText.mutePolar?.options, ["低电平", "高电平"]);
const IIS_MODE_OPTIONS = optionLabels(dacText.IISMode?.options, ["模式1", "模式2", "模式3", "模式4", "模式5", "模式6", "模式7", "模式8"]); const IIS_MODE_OPTIONS = optionLabels(dacText.IISMode?.options, ["模式1", "模式2", "模式3", "模式4", "模式5", "模式6", "模式7", "模式8"]);
const DAC_VOLUME_DIRECT_OPTIONS = optionLabels(dacText.dacVolumeDirect?.options, [
"关闭",
"0dB",
"-12dB",
]);
const goSelect = (title: string, options: string[], selected: number, key: string) => { const goSelect = (title: string, options: string[], selected: number, key: string) => {
navigateToSelect(title, options, selected, "/audio", key); navigateToSelect(title, options, selected, "/audio", key);
@@ -292,6 +329,28 @@ export default function AudioPage() {
value={GAIN_OPTIONS[gainIdx] ?? GAIN_OPTIONS[0] ?? "—"} value={GAIN_OPTIONS[gainIdx] ?? GAIN_OPTIONS[0] ?? "—"}
onClick={() => goSelect(dacText.dacGain?.label ?? "选择耳机增益", GAIN_OPTIONS, gainIdx, "dacGain")} onClick={() => goSelect(dacText.dacGain?.label ?? "选择耳机增益", GAIN_OPTIONS, gainIdx, "dacGain")}
/> />
<ToggleRow
label={dacText.autoImpedance?.label ?? "自动检测耳机阻抗"}
checked={autoImpedanceOn}
onChange={(v) => updateSetting({ dacImpedance: v ? 1 : 0 })}
/>
<ToggleRow
label={dacText.dreMode?.label ?? "动态范围增强"}
checked={dreModeOn}
onChange={(v) => updateSetting({ dreMode: v ? 1 : 0 })}
/>
<ListRow
label={dacText.dacVolumeDirect?.label ?? "前级输出音量直通模式"}
value={DAC_VOLUME_DIRECT_OPTIONS[dacVolumeDirectIdx] ?? DAC_VOLUME_DIRECT_OPTIONS[0] ?? "—"}
onClick={() =>
goSelect(
dacText.dacVolumeDirect?.label ?? "前级输出音量直通模式",
DAC_VOLUME_DIRECT_OPTIONS,
dacVolumeDirectIdx,
"dacVolumeDirect",
)
}
/>
<ListRow <ListRow
label={dacText.volumeStep ?? "音量幅度"} label={dacText.volumeStep ?? "音量幅度"}
value={STEP_OPTIONS[stepIdx] ?? STEP_OPTIONS[0]} value={STEP_OPTIONS[stepIdx] ?? STEP_OPTIONS[0]}
-4
View File
@@ -69,7 +69,6 @@ export default function DspPage() {
const dspOn = (ds?.dsp_enable ?? 0) === 0; const dspOn = (ds?.dsp_enable ?? 0) === 0;
const peqOn = (ds?.peqEnable ?? 0) === 1; const peqOn = (ds?.peqEnable ?? 0) === 1;
const dacVolDirect = (ds?.dacVolumeDirect ?? 0) === 1;
const dacArcIdx = ds?.dacArc ?? 0; const dacArcIdx = ds?.dacArc ?? 0;
const dacGainIdx = ds?.dacGain ?? 0; const dacGainIdx = ds?.dacGain ?? 0;
const dacImpIdx = ds?.dacImpedance ?? 0; const dacImpIdx = ds?.dacImpedance ?? 0;
@@ -115,9 +114,6 @@ export default function DspPage() {
<ListRow label="DAC 阻抗" description="输出阻抗设置" <ListRow label="DAC 阻抗" description="输出阻抗设置"
value={DAC_IMP_OPTIONS[dacImpIdx]} value={DAC_IMP_OPTIONS[dacImpIdx]}
onClick={() => goSelect("DAC 阻抗", DAC_IMP_OPTIONS, dacImpIdx, "dacImpedance")} /> onClick={() => goSelect("DAC 阻抗", DAC_IMP_OPTIONS, dacImpIdx, "dacImpedance")} />
<ListRow label="直通音量控制" description="DAC 直通音量"
toggle checked={dacVolDirect}
onToggle={(v) => updateSetting({ dacVolumeDirect: v ? 1 : 0 })} />
</div> </div>
{/* ── 模拟级 ── */} {/* ── 模拟级 ── */}
+59 -11
View File
@@ -13,6 +13,7 @@ import { useState, useEffect, useRef, useMemo } from "react";
import BottomNav from "@/components/BottomNav"; import BottomNav from "@/components/BottomNav";
import { FeatureGate } from "@/components/FeatureGate"; import { FeatureGate } from "@/components/FeatureGate";
import { navigateToSelect } from "./SelectPage"; import { navigateToSelect } from "./SelectPage";
import { cn } from "@/lib/utils";
import localeZh from "@/locales/data-zh.json"; import localeZh from "@/locales/data-zh.json";
import localeZhHK from "@/locales/data-zh-HK.json"; import localeZhHK from "@/locales/data-zh-HK.json";
import localeEn from "@/locales/data-en.json"; import localeEn from "@/locales/data-en.json";
@@ -33,7 +34,15 @@ function IOSToggle({ checked, onChange }: { checked: boolean; onChange: (v: bool
); );
} }
function CyanSlider({ value, min, max, step = 0.1, onChange, onRelease }: { function CyanSlider({
value,
min,
max,
step = 0.1,
onChange,
onRelease,
disabled = false,
}: {
value: number; value: number;
min: number; min: number;
max: number; max: number;
@@ -41,19 +50,36 @@ function CyanSlider({ value, min, max, step = 0.1, onChange, onRelease }: {
onChange: (v: number) => void; onChange: (v: number) => void;
/** Fires once when the user releases the thumb (pointer/mouse/touch). Read value from DOM to avoid stale React state. */ /** Fires once when the user releases the thumb (pointer/mouse/touch). Read value from DOM to avoid stale React state. */
onRelease?: (v: number) => void; onRelease?: (v: number) => void;
disabled?: boolean;
}) { }) {
const fillPct = ((value - min) / (max - min)) * 100; const fillPct = ((value - min) / (max - min)) * 100;
const emitRelease = (el: EventTarget | null) => { const emitRelease = (el: EventTarget | null) => {
if (disabled) return;
if (!(el instanceof HTMLInputElement)) return; if (!(el instanceof HTMLInputElement)) return;
onRelease?.(Number(el.value)); onRelease?.(Number(el.value));
}; };
return ( return (
<div className="relative flex items-center w-full mt-3"> <div className={cn("relative flex items-center w-full mt-3", disabled && "opacity-60")}>
<div className="absolute left-0 h-[4px] rounded-full pointer-events-none" <div
style={{ width: `${fillPct}%`, background: "#00FFF6", boxShadow: "0 0 6px rgba(0,255,246,0.5)" }} /> className="absolute left-0 h-[4px] rounded-full pointer-events-none"
<input type="range" min={min} max={max} step={step} value={value} style={{
width: `${fillPct}%`,
background: disabled ? "rgba(148,163,184,0.7)" : "#00FFF6",
boxShadow: disabled ? "none" : "0 0 6px rgba(0,255,246,0.5)",
}}
/>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
disabled={disabled}
className="cyan-slider relative z-10" className="cyan-slider relative z-10"
onChange={(e) => onChange(Number(e.target.value))} onChange={(e) => {
if (disabled) return;
onChange(Number(e.target.value));
}}
onPointerUp={(e) => emitRelease(e.currentTarget)} onPointerUp={(e) => emitRelease(e.currentTarget)}
onPointerCancel={(e) => emitRelease(e.currentTarget)} onPointerCancel={(e) => emitRelease(e.currentTarget)}
onKeyUp={(e) => { onKeyUp={(e) => {
@@ -190,8 +216,17 @@ export default function EffectsPage() {
<IOSToggle checked={sceneOn} onChange={(v) => updateSetting({ effect_enable: v ? 1 : 0 })} /> <IOSToggle checked={sceneOn} onChange={(v) => updateSetting({ effect_enable: v ? 1 : 0 })} />
</div> </div>
<button <button
className="flex items-center justify-between w-full active:opacity-70 transition-opacity" type="button"
onClick={() => goSelect(effectText.selectStyleTitle, sceneLabels, sceneVal, "effect_value")}> disabled={!sceneOn}
className={cn(
"flex items-center justify-between w-full transition-opacity",
sceneOn ? "active:opacity-70" : "opacity-45 cursor-not-allowed",
)}
onClick={() => {
if (!sceneOn) return;
goSelect(effectText.selectStyleTitle, sceneLabels, sceneVal, "effect_value");
}}
>
<span className="text-[15px] text-white/55">{sceneDisplay}</span> <span className="text-[15px] text-white/55">{sceneDisplay}</span>
<ChevronRight size={16} className="ios-chevron" /> <ChevronRight size={16} className="ios-chevron" />
</button> </button>
@@ -211,16 +246,20 @@ export default function EffectsPage() {
min={0} min={0}
max={100} max={100}
step={1} step={1}
disabled={!widthOn}
onChange={(v) => { onChange={(v) => {
if (!widthOn) return;
isDragging.current = true; isDragging.current = true;
setLocalWidth(Math.round(v)); setLocalWidth(Math.round(v));
}} }}
onRelease={(v) => { onRelease={(v) => {
if (!widthOn) return;
isDragging.current = false; isDragging.current = false;
const w = Math.round(v); const w = Math.round(v);
setLocalWidth(w); setLocalWidth(w);
updateSetting({ width_value: w }); updateSetting({ width_value: w });
}} /> }}
/>
</div> </div>
{/* ── 交叉反馈 ── */} {/* ── 交叉反馈 ── */}
@@ -230,8 +269,17 @@ export default function EffectsPage() {
<IOSToggle checked={crossfeedOn} onChange={(v) => updateSetting({ crossfeed_enable: v ? 1 : 0 })} /> <IOSToggle checked={crossfeedOn} onChange={(v) => updateSetting({ crossfeed_enable: v ? 1 : 0 })} />
</div> </div>
<button <button
className="flex items-center justify-between w-full active:opacity-70 transition-opacity" type="button"
onClick={() => goSelect(effectText.selectCrossfeedTitle, crossfeedLabels, crossfeedVal, "crossfeed_value")}> disabled={!crossfeedOn}
className={cn(
"flex items-center justify-between w-full transition-opacity",
crossfeedOn ? "active:opacity-70" : "opacity-45 cursor-not-allowed",
)}
onClick={() => {
if (!crossfeedOn) return;
goSelect(effectText.selectCrossfeedTitle, crossfeedLabels, crossfeedVal, "crossfeed_value");
}}
>
<span className="text-[14px] text-white/45">{crossfeedDisplay}</span> <span className="text-[14px] text-white/45">{crossfeedDisplay}</span>
<ChevronRight size={16} className="ios-chevron" /> <ChevronRight size={16} className="ios-chevron" />
</button> </button>
+83 -18
View File
@@ -63,6 +63,8 @@ const EGG_WAVE_DURATION_MS = 5000;
const EGG_WAVE_HALF_MS = EGG_WAVE_DURATION_MS / 2; const EGG_WAVE_HALF_MS = EGG_WAVE_DURATION_MS / 2;
const EGG_BEAT_MS = 400; const EGG_BEAT_MS = 400;
const JACK_SEQUENCE_TIMEOUT_MS = 4000; const JACK_SEQUENCE_TIMEOUT_MS = 4000;
/** 与 IOPage 输出选项 index 2(耳机)一致 */
const HEADPHONE_OUTPUT_INDEX = 2;
/** 旋律主音在 20 段 VU 上的位置(约 5s) */ /** 旋律主音在 20 段 VU 上的位置(约 5s) */
const EGG_MELODY_LEAD = [ const EGG_MELODY_LEAD = [
@@ -164,7 +166,7 @@ function ListRow({
} }
export default function Home() { export default function Home() {
const { isConnected, deviceState, setVolume, updateSetting, api, disconnect, isDemoMode, ip } = useDevice(); const { isConnected, deviceState, setVolume, updateSetting, api, isDemoMode, ip } = useDevice();
const [, setLocation] = useLocation(); const [, setLocation] = useLocation();
const powerActionRef = useRef(false); const powerActionRef = useRef(false);
const hpEqLastTapRef = useRef<number | null>(null); const hpEqLastTapRef = useRef<number | null>(null);
@@ -233,23 +235,21 @@ export default function Home() {
powerActionRef.current = true; powerActionRef.current = true;
try { try {
if (isDemoMode) { if (isDemoMode) {
toast.info(homeText.powerOffToastDemo ?? "演示模式:已断开与设备的连接"); toast.info(homeText.powerOffToastDemo ?? "演示模式:已发送关机指令");
disconnect();
return; return;
} }
if (api) { if (api) {
try { try {
await api.powerOff(); await api.powerOff();
} catch { } catch {
// 设备可能已掉线或未返回响应,仍断开本地会话 // 设备可能已关机或未返回响应,仍保留当前页面与会话状态
} }
} }
disconnect();
toast.success(homeText.powerOffToastSent ?? "已发送关机指令"); toast.success(homeText.powerOffToastSent ?? "已发送关机指令");
} finally { } finally {
powerActionRef.current = false; powerActionRef.current = false;
} }
}, [api, disconnect, isDemoMode, homeText]); }, [api, isDemoMode, homeText]);
/** HP-EQ 圆钮:单击在短延迟后切换 peqEnable(避免与双击抢);280ms 内第二次点击则取消开关并进入 /eq */ /** HP-EQ 圆钮:单击在短延迟后切换 peqEnable(避免与双击抢);280ms 内第二次点击则取消开关并进入 /eq */
const HP_EQ_DOUBLE_CLICK_MS = 280; const HP_EQ_DOUBLE_CLICK_MS = 280;
@@ -393,6 +393,24 @@ export default function Home() {
// 换算为 volume 单位:0.5/1/2/3 dB -> 1/2/4/6 // 换算为 volume 单位:0.5/1/2/3 dB -> 1/2/4/6
const fineVolumeStep = [1, 2, 4, 6][ds.soundStep ?? 1] ?? 1; const fineVolumeStep = [1, 2, 4, 6][ds.soundStep ?? 1] ?? 1;
const volumePassthroughActive =
(ds.dacVolumeDirect ?? 0) === 1 || (ds.dacVolumeDirect ?? 0) === 2;
const volumeControlsLocked =
volumePassthroughActive && ds.output !== HEADPHONE_OUTPUT_INDEX;
const volumePassthroughHint =
homeText.volumePassthroughLockedHint ??
homeText.volumePassthroughLockedToast ??
"当前为音量直通模式,无法调节音量";
const guardVolumeChange = useCallback(
(action: () => void) => {
if (volumeControlsLocked) return;
action();
},
[volumeControlsLocked],
);
// Slider fill % (0-200 → 0-100%) // Slider fill % (0-200 → 0-100%)
const fillPct = (localVol / 200) * 100; const fillPct = (localVol / 200) * 100;
// Knob angle (map 0..200 -> -135..+135 degrees) // Knob angle (map 0..200 -> -135..+135 degrees)
@@ -413,6 +431,7 @@ export default function Home() {
}; };
const beginKnobDrag = (e: React.PointerEvent<HTMLDivElement>) => { const beginKnobDrag = (e: React.PointerEvent<HTMLDivElement>) => {
if (volumeControlsLocked) return;
const el = e.currentTarget; const el = e.currentTarget;
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
knobDraggingRef.current = true; knobDraggingRef.current = true;
@@ -442,6 +461,7 @@ export default function Home() {
knobDraggingRef.current = false; knobDraggingRef.current = false;
knobActivePointerIdRef.current = null; knobActivePointerIdRef.current = null;
isDragging.current = false; isDragging.current = false;
if (volumeControlsLocked) return;
setVolume(knobLastVolRef.current); setVolume(knobLastVolRef.current);
try { try {
e.currentTarget.releasePointerCapture(e.pointerId); e.currentTarget.releasePointerCapture(e.pointerId);
@@ -551,7 +571,10 @@ export default function Home() {
{/* Right knob */} {/* Right knob */}
<div className="pr-4"> <div className="pr-4">
<div <div
className="w-12 h-12 rounded-full flex items-center justify-center touch-none select-none" className={cn(
"w-12 h-12 rounded-full flex items-center justify-center touch-none select-none",
volumeControlsLocked && "opacity-45 cursor-not-allowed",
)}
style={{ style={{
background: "radial-gradient(circle at 35% 35%, #4a4a4e, #1a1a1c)", background: "radial-gradient(circle at 35% 35%, #4a4a4e, #1a1a1c)",
border: "2px solid rgba(255,255,255,0.12)", border: "2px solid rgba(255,255,255,0.12)",
@@ -584,11 +607,23 @@ export default function Home() {
{/* ── Volume slider ── */} {/* ── Volume slider ── */}
<div className="px-4 mb-5"> <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"> <div className="flex items-center gap-3">
<button <button
type="button" type="button"
onClick={() => setVolumeConfirm("min")} disabled={volumeControlsLocked}
className="text-white/50 active:text-white transition-colors flex-shrink-0" 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"> <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"/> <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
@@ -596,37 +631,55 @@ export default function Home() {
</svg> </svg>
</button> </button>
<div className="flex-1 relative flex items-center touch-pan-y"> <div
className={cn(
"flex-1 relative flex items-center touch-pan-y",
volumeControlsLocked && "opacity-50",
)}
>
{/* Track fill - 根据按钮位置调整 */} {/* Track fill - 根据按钮位置调整 */}
<div className="absolute left-0 h-[4px] rounded-full pointer-events-none" <div className="absolute left-0 h-[4px] rounded-full pointer-events-none"
style={{ style={{
width: `${fillPct}%`, width: `${fillPct}%`,
background: "#00FFF6", background: volumeControlsLocked ? "rgba(148,163,184,0.7)" : "#00FFF6",
boxShadow: "0 0 8px rgba(0,255,246,0.5)" boxShadow: volumeControlsLocked ? "none" : "0 0 8px rgba(0,255,246,0.5)",
}} /> }}
/>
<input <input
type="range" min={0} max={200} value={localVol} type="range"
min={0}
max={200}
value={localVol}
disabled={volumeControlsLocked}
className="cyan-slider relative z-10" className="cyan-slider relative z-10"
onChange={(e) => { onChange={(e) => {
if (volumeControlsLocked) return;
const v = Number(e.target.value); const v = Number(e.target.value);
setLocalVol(v); setLocalVol(v);
}} }}
onPointerDown={() => { onPointerDown={() => {
if (volumeControlsLocked) return;
isDragging.current = true; isDragging.current = true;
}} }}
onPointerUp={(e) => { onPointerUp={(e) => {
isDragging.current = false; isDragging.current = false;
if (volumeControlsLocked) return;
setVolume(Number(e.currentTarget.value)); setVolume(Number(e.currentTarget.value));
}} }}
onPointerCancel={(e) => { onPointerCancel={(e) => {
isDragging.current = false; isDragging.current = false;
if (volumeControlsLocked) return;
setVolume(Number(e.currentTarget.value)); setVolume(Number(e.currentTarget.value));
}} }}
onBlur={(e) => { onBlur={(e) => {
isDragging.current = false; isDragging.current = false;
if (volumeControlsLocked) {
return;
}
setVolume(Number(e.currentTarget.value)); setVolume(Number(e.currentTarget.value));
}} }}
onKeyUp={(e) => { onKeyUp={(e) => {
if (volumeControlsLocked) return;
const k = e.key; const k = e.key;
if ( if (
k === "ArrowLeft" || k === "ArrowLeft" ||
@@ -646,8 +699,12 @@ export default function Home() {
<button <button
type="button" type="button"
onClick={() => setVolumeConfirm("max")} disabled={volumeControlsLocked}
className="text-white/50 active:text-white transition-colors flex-shrink-0" 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"> <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"/> <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
@@ -661,7 +718,7 @@ export default function Home() {
<button <button
type="button" type="button"
aria-label="Decrease volume" aria-label="Decrease volume"
disabled={localVol <= 0} 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" 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={{ style={{
background: "rgba(44,44,46,0.6)", background: "rgba(44,44,46,0.6)",
@@ -669,9 +726,11 @@ export default function Home() {
color: "rgba(255,255,255,0.55)", color: "rgba(255,255,255,0.55)",
}} }}
onClick={() => { onClick={() => {
guardVolumeChange(() => {
const next = Math.max(0, Math.min(200, localVol - fineVolumeStep)); const next = Math.max(0, Math.min(200, localVol - fineVolumeStep));
setLocalVol(next); setLocalVol(next);
setVolume(next); setVolume(next);
});
}} }}
> >
<span className="text-[20px] leading-none">-</span> <span className="text-[20px] leading-none">-</span>
@@ -684,7 +743,7 @@ export default function Home() {
<button <button
type="button" type="button"
aria-label="Increase volume" aria-label="Increase volume"
disabled={localVol >= 200} 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" 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={{ style={{
background: "rgba(44,44,46,0.6)", background: "rgba(44,44,46,0.6)",
@@ -692,9 +751,11 @@ export default function Home() {
color: "rgba(255,255,255,0.55)", color: "rgba(255,255,255,0.55)",
}} }}
onClick={() => { onClick={() => {
guardVolumeChange(() => {
const next = Math.max(0, Math.min(200, localVol + fineVolumeStep)); const next = Math.max(0, Math.min(200, localVol + fineVolumeStep));
setLocalVol(next); setLocalVol(next);
setVolume(next); setVolume(next);
});
}} }}
> >
<span className="text-[20px] leading-none">+</span> <span className="text-[20px] leading-none">+</span>
@@ -875,6 +936,10 @@ export default function Home() {
<AlertDialogAction <AlertDialogAction
className="bg-[#00FFF6] text-black hover:bg-[#00FFF6]/90 focus-visible:ring-[#00FFF6]" className="bg-[#00FFF6] text-black hover:bg-[#00FFF6]/90 focus-visible:ring-[#00FFF6]"
onClick={() => { onClick={() => {
if (volumeControlsLocked) {
setVolumeConfirm(null);
return;
}
if (volumeConfirm === "min") { if (volumeConfirm === "min") {
setLocalVol(0); setLocalVol(0);
setVolume(0); setVolume(0);
+88 -3
View File
@@ -14,7 +14,24 @@ import { useLocation } from "wouter";
import { ChevronLeft, Check } from "lucide-react"; import { ChevronLeft, Check } from "lucide-react";
import { useDevice } from "@/contexts/DeviceContext"; import { useDevice } from "@/contexts/DeviceContext";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import localeZh from "@/locales/data-zh.json";
import localeZhHK from "@/locales/data-zh-HK.json";
import localeEn from "@/locales/data-en.json";
type DacVolumeDirectConfirmLocale = NonNullable<
NonNullable<(typeof localeZh)["dac"]>["dacVolumeDirectConfirm"]
>;
const iisModeImageUrl = (index: number) => const iisModeImageUrl = (index: number) =>
`${import.meta.env.BASE_URL}IIS/iis_app${index + 1}.png`; `${import.meta.env.BASE_URL}IIS/iis_app${index + 1}.png`;
@@ -33,6 +50,7 @@ const KEY_TO_SETTING: Record<string, string> = {
xlr: "xlr", xlr: "xlr",
dacGain: "dacGain", dacGain: "dacGain",
dacImpedance: "dacImpedance", dacImpedance: "dacImpedance",
dacVolumeDirect: "dacVolumeDirect",
vu: "vu", vu: "vu",
input: "input", input: "input",
output: "output", output: "output",
@@ -69,7 +87,7 @@ export function navigateToSelect(title: string, options: string[], selected: num
export default function SelectPage() { export default function SelectPage() {
const [location, setLocation] = useLocation(); const [location, setLocation] = useLocation();
const { updateSetting } = useDevice(); const { updateSetting, deviceState } = useDevice();
const [state, setState] = useState<{ const [state, setState] = useState<{
title: string; title: string;
options: string[]; options: string[];
@@ -78,6 +96,13 @@ export default function SelectPage() {
key: string; key: string;
} | null>(() => selectPageState); } | null>(() => selectPageState);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [volumeDirectConfirmIdx, setVolumeDirectConfirmIdx] = useState<1 | 2 | null>(null);
const volumeDirectConfirmText = useMemo((): DacVolumeDirectConfirmLocale => {
const lang = deviceState?.language;
const pack = lang === 0 ? localeEn : lang === 1 ? localeZhHK : localeZh;
return (pack.dac?.dacVolumeDirectConfirm ?? localeZh.dac!.dacVolumeDirectConfirm) as DacVolumeDirectConfirmLocale;
}, [deviceState?.language]);
// Re-read global state whenever we enter /select (avoids stale key/options after remount) // Re-read global state whenever we enter /select (avoids stale key/options after remount)
useEffect(() => { useEffect(() => {
@@ -92,7 +117,7 @@ export default function SelectPage() {
const { title, options, selected: selectedIdx, back, key } = state; const { title, options, selected: selectedIdx, back, key } = state;
const handleSelect = async (idx: number) => { const commitSelect = async (idx: number) => {
if (saving) return; if (saving) return;
selectPageResult = { key, selected: idx }; selectPageResult = { key, selected: idx };
@@ -109,6 +134,35 @@ export default function SelectPage() {
setLocation(back); setLocation(back);
}; };
const handleSelect = async (idx: number) => {
if (
key === "dacVolumeDirect" &&
idx !== selectedIdx &&
(idx === 1 || idx === 2)
) {
setVolumeDirectConfirmIdx(idx);
return;
}
await commitSelect(idx);
};
const volumeDirectDialogCopy =
volumeDirectConfirmIdx === 1
? {
title: volumeDirectConfirmText.title0db ?? "开启 0dB 直通模式?",
desc:
volumeDirectConfirmText.desc0db ??
"直通模式会将音量调到最大,并且不可调节。",
}
: volumeDirectConfirmIdx === 2
? {
title: volumeDirectConfirmText.title12db ?? "开启 -12dB 直通模式?",
desc:
volumeDirectConfirmText.desc12db ??
"直通模式会将音量调到 -12dB,并且不可调节。",
}
: null;
return ( return (
<div className="min-h-screen bg-black"> <div className="min-h-screen bg-black">
{/* ── Header ── */} {/* ── Header ── */}
@@ -185,6 +239,37 @@ export default function SelectPage() {
</div> </div>
)} )}
</div> </div>
<AlertDialog
open={volumeDirectConfirmIdx !== null}
onOpenChange={(open) => {
if (!open) setVolumeDirectConfirmIdx(null);
}}
>
<AlertDialogContent className="top-[42%] border-white/10 bg-zinc-900 text-white sm:max-w-md">
<AlertDialogHeader>
<AlertDialogTitle className="text-white">{volumeDirectDialogCopy?.title}</AlertDialogTitle>
<AlertDialogDescription className="text-white/60">
{volumeDirectDialogCopy?.desc}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="border-white/20 bg-transparent text-white hover:bg-white/10">
{volumeDirectConfirmText.cancel ?? "取消"}
</AlertDialogCancel>
<AlertDialogAction
className="bg-[#00FFF6] text-black hover:bg-[#00FFF6]/90 focus-visible:ring-[#00FFF6]"
onClick={() => {
const idx = volumeDirectConfirmIdx;
setVolumeDirectConfirmIdx(null);
if (idx !== null) void commitSelect(idx);
}}
>
{volumeDirectConfirmText.confirm ?? "确认"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
} }
+2 -1
View File
@@ -14,6 +14,7 @@ import { useLocation } from "wouter";
import { useEffect } from "react"; import { useEffect } from "react";
import BottomNav from "@/components/BottomNav"; import BottomNav from "@/components/BottomNav";
import { navigateToSelect } from "./SelectPage"; import { navigateToSelect } from "./SelectPage";
import { formatFirmwareVersionDisplay } from "@/lib/luxsinApi";
import localeZh from "@/locales/data-zh.json"; import localeZh from "@/locales/data-zh.json";
import localeZhHK from "@/locales/data-zh-HK.json"; import localeZhHK from "@/locales/data-zh-HK.json";
import localeEn from "@/locales/data-en.json"; import localeEn from "@/locales/data-en.json";
@@ -215,7 +216,7 @@ export default function SystemPage() {
</div> </div>
<div className="ios-list-row"> <div className="ios-list-row">
<span className="flex-1 text-[16px] text-white">{ui.firmwareVersion}</span> <span className="flex-1 text-[16px] text-white">{ui.firmwareVersion}</span>
<span className="ios-row-value">{ds?.version ?? "—"}</span> <span className="ios-row-value">{formatFirmwareVersionDisplay(ds?.version)}</span>
</div> </div>
<div className="ios-list-row"> <div className="ios-list-row">
<span className="flex-1 text-[16px] text-white">{ui.macAddress}</span> <span className="flex-1 text-[16px] text-white">{ui.macAddress}</span>