修复bug
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "開機音量",
|
||||||
|
|||||||
@@ -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": "开机音量",
|
||||||
|
|||||||
@@ -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]}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
{/* ── 模拟级 ── */}
|
{/* ── 模拟级 ── */}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user