修复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;
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,
+33 -1
View File
@@ -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",
+33 -1
View File
@@ -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": "開機音量",
+33 -1
View File
@@ -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": "开机音量",
+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 }) {
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]}
-4
View File
@@ -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>
{/* ── 模拟级 ── */}
+59 -11
View File
@@ -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
View File
@@ -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);
+88 -3
View File
@@ -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>
);
}
+2 -1
View File
@@ -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>