修复bug
This commit is contained in:
@@ -89,6 +89,7 @@ export interface DeviceState {
|
||||
dacGain: number;
|
||||
dacArc: number;
|
||||
dacImpedance: number;
|
||||
dreMode: number;
|
||||
dacVolumeDirect: number;
|
||||
analogGain: number;
|
||||
effect_enable: number;
|
||||
@@ -213,7 +214,9 @@ export class LuxsinAPI {
|
||||
const response = await fetch(`${this.baseUrl}/dev/info.cgi?action=syncData`);
|
||||
const text = await response.text();
|
||||
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) {
|
||||
this.maybeRedirectForHttpsCert(error);
|
||||
throw error;
|
||||
@@ -333,6 +336,15 @@ export function parseFirmwareVersion(version: unknown): number {
|
||||
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. */
|
||||
export function getBootSoundMaxIndex(firmwareVersion: number): number {
|
||||
return firmwareVersion >= 26 ? 10 : 6;
|
||||
@@ -387,6 +399,7 @@ export const MOCK_DEVICE_STATE: DeviceState = {
|
||||
dacGain: 0,
|
||||
dacArc: 0,
|
||||
dacImpedance: 0,
|
||||
dreMode: 0,
|
||||
dacVolumeDirect: 0,
|
||||
analogGain: 0,
|
||||
effect_enable: 0,
|
||||
|
||||
@@ -24,7 +24,9 @@
|
||||
"volumeMaxConfirmTitle": "Set volume to maximum?",
|
||||
"volumeMaxConfirmDesc": "Volume will be set to the highest level. Continue?",
|
||||
"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.",
|
||||
"doubleClickHint": "Double-click to enter",
|
||||
"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",
|
||||
"bootVolume": {
|
||||
"label": "Boot volume",
|
||||
|
||||
@@ -24,7 +24,9 @@
|
||||
"volumeMaxConfirmTitle": "確認將音量設為最大?",
|
||||
"volumeMaxConfirmDesc": "音量將調至最大,是否繼續?",
|
||||
"volumeMaxConfirmOk": "設為最大",
|
||||
"powerOffToastDemo": "演示模式:已中斷與裝置的連線",
|
||||
"volumePassthroughLockedToast": "目前為音量直通模式,無法調節音量",
|
||||
"volumePassthroughLockedHint": "目前為音量直通模式,無法調節音量",
|
||||
"powerOffToastDemo": "演示模式:已傳送關機指令",
|
||||
"powerOffToastSent": "已傳送關機指令",
|
||||
"doubleClickHint": "按兩下進入",
|
||||
"backToLegacy": "返回舊版"
|
||||
@@ -291,6 +293,36 @@
|
||||
{ "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": "音量級距",
|
||||
"bootVolume": {
|
||||
"label": "開機音量",
|
||||
|
||||
@@ -24,7 +24,9 @@
|
||||
"volumeMaxConfirmTitle": "确认将音量设为最大?",
|
||||
"volumeMaxConfirmDesc": "音量将调至最大,是否继续?",
|
||||
"volumeMaxConfirmOk": "设为最大",
|
||||
"powerOffToastDemo": "演示模式:已断开与设备的连接",
|
||||
"volumePassthroughLockedToast": "当前为音量直通模式,无法调节音量",
|
||||
"volumePassthroughLockedHint": "当前为音量直通模式,无法调节音量",
|
||||
"powerOffToastDemo": "演示模式:已发送关机指令",
|
||||
"powerOffToastSent": "已发送关机指令",
|
||||
"doubleClickHint": "双击进入",
|
||||
"backToLegacy": "返回旧版"
|
||||
@@ -291,6 +293,36 @@
|
||||
{ "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": "音量幅度",
|
||||
"bootVolume": {
|
||||
"label": "开机音量",
|
||||
|
||||
@@ -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 }) {
|
||||
return (
|
||||
<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 = {
|
||||
dac?: {
|
||||
sectionBalanceSensitivity?: string;
|
||||
@@ -90,6 +116,9 @@ type LocaleDac = {
|
||||
sensitivity?: string;
|
||||
filters?: { 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;
|
||||
bootVolume?: string | { 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]);
|
||||
|
||||
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 filterIdx = ds?.pcm ?? 0;
|
||||
const xlrIdx = ds?.xlr ?? 0;
|
||||
@@ -208,6 +240,11 @@ export default function AudioPage() {
|
||||
const XLR_OPTIONS = optionLabels(dacText.xlr?.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 DAC_VOLUME_DIRECT_OPTIONS = optionLabels(dacText.dacVolumeDirect?.options, [
|
||||
"关闭",
|
||||
"0dB",
|
||||
"-12dB",
|
||||
]);
|
||||
|
||||
const goSelect = (title: string, options: string[], selected: number, key: string) => {
|
||||
navigateToSelect(title, options, selected, "/audio", key);
|
||||
@@ -292,6 +329,28 @@ export default function AudioPage() {
|
||||
value={GAIN_OPTIONS[gainIdx] ?? GAIN_OPTIONS[0] ?? "—"}
|
||||
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
|
||||
label={dacText.volumeStep ?? "音量幅度"}
|
||||
value={STEP_OPTIONS[stepIdx] ?? STEP_OPTIONS[0]}
|
||||
|
||||
@@ -69,7 +69,6 @@ export default function DspPage() {
|
||||
|
||||
const dspOn = (ds?.dsp_enable ?? 0) === 0;
|
||||
const peqOn = (ds?.peqEnable ?? 0) === 1;
|
||||
const dacVolDirect = (ds?.dacVolumeDirect ?? 0) === 1;
|
||||
const dacArcIdx = ds?.dacArc ?? 0;
|
||||
const dacGainIdx = ds?.dacGain ?? 0;
|
||||
const dacImpIdx = ds?.dacImpedance ?? 0;
|
||||
@@ -115,9 +114,6 @@ export default function DspPage() {
|
||||
<ListRow label="DAC 阻抗" description="输出阻抗设置"
|
||||
value={DAC_IMP_OPTIONS[dacImpIdx]}
|
||||
onClick={() => goSelect("DAC 阻抗", DAC_IMP_OPTIONS, dacImpIdx, "dacImpedance")} />
|
||||
<ListRow label="直通音量控制" description="DAC 直通音量"
|
||||
toggle checked={dacVolDirect}
|
||||
onToggle={(v) => updateSetting({ dacVolumeDirect: v ? 1 : 0 })} />
|
||||
</div>
|
||||
|
||||
{/* ── 模拟级 ── */}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import BottomNav from "@/components/BottomNav";
|
||||
import { FeatureGate } from "@/components/FeatureGate";
|
||||
import { navigateToSelect } from "./SelectPage";
|
||||
import { cn } from "@/lib/utils";
|
||||
import localeZh from "@/locales/data-zh.json";
|
||||
import localeZhHK from "@/locales/data-zh-HK.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;
|
||||
min: number;
|
||||
max: number;
|
||||
@@ -41,19 +50,36 @@ function CyanSlider({ value, min, max, step = 0.1, onChange, onRelease }: {
|
||||
onChange: (v: number) => void;
|
||||
/** Fires once when the user releases the thumb (pointer/mouse/touch). Read value from DOM to avoid stale React state. */
|
||||
onRelease?: (v: number) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const fillPct = ((value - min) / (max - min)) * 100;
|
||||
const emitRelease = (el: EventTarget | null) => {
|
||||
if (disabled) return;
|
||||
if (!(el instanceof HTMLInputElement)) return;
|
||||
onRelease?.(Number(el.value));
|
||||
};
|
||||
return (
|
||||
<div className="relative flex items-center w-full mt-3">
|
||||
<div className="absolute left-0 h-[4px] rounded-full pointer-events-none"
|
||||
style={{ width: `${fillPct}%`, background: "#00FFF6", boxShadow: "0 0 6px rgba(0,255,246,0.5)" }} />
|
||||
<input type="range" min={min} max={max} step={step} value={value}
|
||||
<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"
|
||||
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"
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
onChange={(e) => {
|
||||
if (disabled) return;
|
||||
onChange(Number(e.target.value));
|
||||
}}
|
||||
onPointerUp={(e) => emitRelease(e.currentTarget)}
|
||||
onPointerCancel={(e) => emitRelease(e.currentTarget)}
|
||||
onKeyUp={(e) => {
|
||||
@@ -190,8 +216,17 @@ export default function EffectsPage() {
|
||||
<IOSToggle checked={sceneOn} onChange={(v) => updateSetting({ effect_enable: v ? 1 : 0 })} />
|
||||
</div>
|
||||
<button
|
||||
className="flex items-center justify-between w-full active:opacity-70 transition-opacity"
|
||||
onClick={() => goSelect(effectText.selectStyleTitle, sceneLabels, sceneVal, "effect_value")}>
|
||||
type="button"
|
||||
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>
|
||||
<ChevronRight size={16} className="ios-chevron" />
|
||||
</button>
|
||||
@@ -211,16 +246,20 @@ export default function EffectsPage() {
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
disabled={!widthOn}
|
||||
onChange={(v) => {
|
||||
if (!widthOn) return;
|
||||
isDragging.current = true;
|
||||
setLocalWidth(Math.round(v));
|
||||
}}
|
||||
onRelease={(v) => {
|
||||
if (!widthOn) return;
|
||||
isDragging.current = false;
|
||||
const w = Math.round(v);
|
||||
setLocalWidth(w);
|
||||
updateSetting({ width_value: w });
|
||||
}} />
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 交叉反馈 ── */}
|
||||
@@ -230,8 +269,17 @@ export default function EffectsPage() {
|
||||
<IOSToggle checked={crossfeedOn} onChange={(v) => updateSetting({ crossfeed_enable: v ? 1 : 0 })} />
|
||||
</div>
|
||||
<button
|
||||
className="flex items-center justify-between w-full active:opacity-70 transition-opacity"
|
||||
onClick={() => goSelect(effectText.selectCrossfeedTitle, crossfeedLabels, crossfeedVal, "crossfeed_value")}>
|
||||
type="button"
|
||||
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>
|
||||
<ChevronRight size={16} className="ios-chevron" />
|
||||
</button>
|
||||
|
||||
+91
-26
@@ -63,6 +63,8 @@ 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 = [
|
||||
@@ -164,7 +166,7 @@ function ListRow({
|
||||
}
|
||||
|
||||
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 powerActionRef = useRef(false);
|
||||
const hpEqLastTapRef = useRef<number | null>(null);
|
||||
@@ -233,23 +235,21 @@ export default function Home() {
|
||||
powerActionRef.current = true;
|
||||
try {
|
||||
if (isDemoMode) {
|
||||
toast.info(homeText.powerOffToastDemo ?? "演示模式:已断开与设备的连接");
|
||||
disconnect();
|
||||
toast.info(homeText.powerOffToastDemo ?? "演示模式:已发送关机指令");
|
||||
return;
|
||||
}
|
||||
if (api) {
|
||||
try {
|
||||
await api.powerOff();
|
||||
} catch {
|
||||
// 设备可能已掉线或未返回响应,仍断开本地会话
|
||||
// 设备可能已关机或未返回响应,仍保留当前页面与会话状态
|
||||
}
|
||||
}
|
||||
disconnect();
|
||||
toast.success(homeText.powerOffToastSent ?? "已发送关机指令");
|
||||
} finally {
|
||||
powerActionRef.current = false;
|
||||
}
|
||||
}, [api, disconnect, isDemoMode, homeText]);
|
||||
}, [api, isDemoMode, homeText]);
|
||||
|
||||
/** HP-EQ 圆钮:单击在短延迟后切换 peqEnable(避免与双击抢);280ms 内第二次点击则取消开关并进入 /eq */
|
||||
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
|
||||
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%)
|
||||
const fillPct = (localVol / 200) * 100;
|
||||
// Knob angle (map 0..200 -> -135..+135 degrees)
|
||||
@@ -413,6 +431,7 @@ export default function Home() {
|
||||
};
|
||||
|
||||
const beginKnobDrag = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (volumeControlsLocked) return;
|
||||
const el = e.currentTarget;
|
||||
const rect = el.getBoundingClientRect();
|
||||
knobDraggingRef.current = true;
|
||||
@@ -442,6 +461,7 @@ export default function Home() {
|
||||
knobDraggingRef.current = false;
|
||||
knobActivePointerIdRef.current = null;
|
||||
isDragging.current = false;
|
||||
if (volumeControlsLocked) return;
|
||||
setVolume(knobLastVolRef.current);
|
||||
try {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
@@ -551,7 +571,10 @@ export default function Home() {
|
||||
{/* Right knob */}
|
||||
<div className="pr-4">
|
||||
<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={{
|
||||
background: "radial-gradient(circle at 35% 35%, #4a4a4e, #1a1a1c)",
|
||||
border: "2px solid rgba(255,255,255,0.12)",
|
||||
@@ -584,11 +607,23 @@ export default function Home() {
|
||||
|
||||
{/* ── 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"
|
||||
onClick={() => setVolumeConfirm("min")}
|
||||
className="text-white/50 active:text-white transition-colors flex-shrink-0"
|
||||
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"/>
|
||||
@@ -596,37 +631,55 @@ export default function Home() {
|
||||
</svg>
|
||||
</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 - 根据按钮位置调整 */}
|
||||
<div className="absolute left-0 h-[4px] rounded-full pointer-events-none"
|
||||
style={{
|
||||
width: `${fillPct}%`,
|
||||
background: "#00FFF6",
|
||||
boxShadow: "0 0 8px rgba(0,255,246,0.5)"
|
||||
}} />
|
||||
style={{
|
||||
width: `${fillPct}%`,
|
||||
background: volumeControlsLocked ? "rgba(148,163,184,0.7)" : "#00FFF6",
|
||||
boxShadow: volumeControlsLocked ? "none" : "0 0 8px rgba(0,255,246,0.5)",
|
||||
}}
|
||||
/>
|
||||
<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"
|
||||
onChange={(e) => {
|
||||
if (volumeControlsLocked) return;
|
||||
const v = Number(e.target.value);
|
||||
setLocalVol(v);
|
||||
}}
|
||||
onPointerDown={() => {
|
||||
if (volumeControlsLocked) return;
|
||||
isDragging.current = true;
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
isDragging.current = false;
|
||||
if (volumeControlsLocked) return;
|
||||
setVolume(Number(e.currentTarget.value));
|
||||
}}
|
||||
onPointerCancel={(e) => {
|
||||
isDragging.current = false;
|
||||
if (volumeControlsLocked) return;
|
||||
setVolume(Number(e.currentTarget.value));
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
isDragging.current = false;
|
||||
if (volumeControlsLocked) {
|
||||
return;
|
||||
}
|
||||
setVolume(Number(e.currentTarget.value));
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
if (volumeControlsLocked) return;
|
||||
const k = e.key;
|
||||
if (
|
||||
k === "ArrowLeft" ||
|
||||
@@ -646,8 +699,12 @@ export default function Home() {
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVolumeConfirm("max")}
|
||||
className="text-white/50 active:text-white transition-colors flex-shrink-0"
|
||||
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"/>
|
||||
@@ -661,7 +718,7 @@ export default function Home() {
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
style={{
|
||||
background: "rgba(44,44,46,0.6)",
|
||||
@@ -669,9 +726,11 @@ export default function Home() {
|
||||
color: "rgba(255,255,255,0.55)",
|
||||
}}
|
||||
onClick={() => {
|
||||
const next = Math.max(0, Math.min(200, localVol - fineVolumeStep));
|
||||
setLocalVol(next);
|
||||
setVolume(next);
|
||||
guardVolumeChange(() => {
|
||||
const next = Math.max(0, Math.min(200, localVol - fineVolumeStep));
|
||||
setLocalVol(next);
|
||||
setVolume(next);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className="text-[20px] leading-none">-</span>
|
||||
@@ -684,7 +743,7 @@ export default function Home() {
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
style={{
|
||||
background: "rgba(44,44,46,0.6)",
|
||||
@@ -692,9 +751,11 @@ export default function Home() {
|
||||
color: "rgba(255,255,255,0.55)",
|
||||
}}
|
||||
onClick={() => {
|
||||
const next = Math.max(0, Math.min(200, localVol + fineVolumeStep));
|
||||
setLocalVol(next);
|
||||
setVolume(next);
|
||||
guardVolumeChange(() => {
|
||||
const next = Math.max(0, Math.min(200, localVol + fineVolumeStep));
|
||||
setLocalVol(next);
|
||||
setVolume(next);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className="text-[20px] leading-none">+</span>
|
||||
@@ -875,6 +936,10 @@ export default function Home() {
|
||||
<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);
|
||||
|
||||
@@ -14,7 +14,24 @@ import { useLocation } from "wouter";
|
||||
import { ChevronLeft, Check } from "lucide-react";
|
||||
import { useDevice } from "@/contexts/DeviceContext";
|
||||
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) =>
|
||||
`${import.meta.env.BASE_URL}IIS/iis_app${index + 1}.png`;
|
||||
@@ -33,6 +50,7 @@ const KEY_TO_SETTING: Record<string, string> = {
|
||||
xlr: "xlr",
|
||||
dacGain: "dacGain",
|
||||
dacImpedance: "dacImpedance",
|
||||
dacVolumeDirect: "dacVolumeDirect",
|
||||
vu: "vu",
|
||||
input: "input",
|
||||
output: "output",
|
||||
@@ -69,7 +87,7 @@ export function navigateToSelect(title: string, options: string[], selected: num
|
||||
|
||||
export default function SelectPage() {
|
||||
const [location, setLocation] = useLocation();
|
||||
const { updateSetting } = useDevice();
|
||||
const { updateSetting, deviceState } = useDevice();
|
||||
const [state, setState] = useState<{
|
||||
title: string;
|
||||
options: string[];
|
||||
@@ -78,6 +96,13 @@ export default function SelectPage() {
|
||||
key: string;
|
||||
} | null>(() => selectPageState);
|
||||
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)
|
||||
useEffect(() => {
|
||||
@@ -92,7 +117,7 @@ export default function SelectPage() {
|
||||
|
||||
const { title, options, selected: selectedIdx, back, key } = state;
|
||||
|
||||
const handleSelect = async (idx: number) => {
|
||||
const commitSelect = async (idx: number) => {
|
||||
if (saving) return;
|
||||
selectPageResult = { key, selected: idx };
|
||||
|
||||
@@ -109,6 +134,35 @@ export default function SelectPage() {
|
||||
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 (
|
||||
<div className="min-h-screen bg-black">
|
||||
{/* ── Header ── */}
|
||||
@@ -185,6 +239,37 @@ export default function SelectPage() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useLocation } from "wouter";
|
||||
import { useEffect } from "react";
|
||||
import BottomNav from "@/components/BottomNav";
|
||||
import { navigateToSelect } from "./SelectPage";
|
||||
import { formatFirmwareVersionDisplay } from "@/lib/luxsinApi";
|
||||
import localeZh from "@/locales/data-zh.json";
|
||||
import localeZhHK from "@/locales/data-zh-HK.json";
|
||||
import localeEn from "@/locales/data-en.json";
|
||||
@@ -215,7 +216,7 @@ export default function SystemPage() {
|
||||
</div>
|
||||
<div className="ios-list-row">
|
||||
<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 className="ios-list-row">
|
||||
<span className="flex-1 text-[16px] text-white">{ui.macAddress}</span>
|
||||
|
||||
Reference in New Issue
Block a user