新增 bd9 对接 webui

This commit is contained in:
eafonyang
2026-09-24 18:35:12 +08:00
parent 6b3dea2f05
commit e5fae70adc
13 changed files with 246 additions and 112 deletions
+2 -2
View File
@@ -5,8 +5,8 @@
* Adjust thresholds here instead of hardcoding in page components.
*/
export const FIRMWARE_FEATURE_MIN_VERSION = {
/** Boot sound: indices 7–10 (-35…-50 dB); legacy firmware caps at index 6. */
bootSoundExtendedSteps: 26,
/** BD9 默认提供开机音量索引 7–10(-35…-50 dB),不按固件版本隐藏。 */
bootSoundExtendedSteps: 0,
/** Subwoofer LPF/HPF full-range toggles on the Effects page. */
subwooferFullRange: 2001,
/** PEQ preset rename (peqRename) and reorder (peqSoft) in manage dialog. */
+23
View File
@@ -132,6 +132,10 @@ export interface DeviceState {
bt_title: string;
bt_artist: string;
msgCount: number;
led_enable: number;
led_red: number;
led_green: number;
led_blue: number;
}
export interface HearingProfile {
@@ -517,6 +521,14 @@ export function formatFirmwareVersionDisplay(version: unknown): string {
return `${major}.${minor}.${patch}.${build}`;
}
/** BD9 的 soundStep 档位按索引对应的 dB 幅度。 */
export const VOLUME_STEP_DB = [0.5, 1, 1.5, 2] as const;
/** 将音量幅度档位换算为 volume 单位(1 单位 = 0.5dB)。 */
export function getVolumeStep(soundStep: number | undefined): number {
return (VOLUME_STEP_DB[soundStep ?? 1] ?? VOLUME_STEP_DB[0]) * 2;
}
/** Pre-out volume passthrough mode selected (0dB or -12dB). */
export function isVolumePassthroughActive(dacVolumeDirect: number | undefined): boolean {
return dacVolumeDirect === 1 || dacVolumeDirect === 2;
@@ -533,6 +545,13 @@ export function isHomeVolumeLockedByPassthrough(
export { getBootSoundMaxIndex } from "@/config/firmwareFeatures";
/** 将氛围灯 RGB 通道值归一化为 0–255 的整数。 */
export function clampLedChannel(value: unknown): number {
const n = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(n)) return 0;
return Math.max(0, Math.min(255, Math.round(n)));
}
/** Normalize `bootSound` from syncData (0 = default … 10 = -50 dB). */
export function normalizeBootSound(value: unknown): number {
const n = typeof value === "number" ? value : Number(value);
@@ -623,6 +642,10 @@ export const MOCK_DEVICE_STATE: DeviceState = {
bt_title: "Bohemian Rhapsody",
bt_artist: "Queen",
msgCount: 42,
led_enable: 1,
led_red: 128,
led_green: 64,
led_blue: 200,
};
export const MOCK_PEQ_STATE: PeqState = {
+8 -3
View File
@@ -56,8 +56,8 @@
"pageTitle": "I/O",
"inputSection": "Eingang",
"outputSection": "Ausgang",
"inputOptions": ["USB-B", "USB-C", "Koaxial", "Optisch", "Bluetooth", "IIS", "RCA", "Lokaler Eingang"],
"outputOptions": ["XLR", "RCA", "XLR/RCA", "Kopfhörer"],
"inputOptions": ["USB-B", "USB-C", "Koaxial", "Optisch", "Bluetooth", "IIS", "AES", "USB-Stick"],
"outputOptions": ["XLR", "RCA", "XLR/RCA"],
"input": {
"coaxial": "KOAXIAL",
"optical": "OPTISCH",
@@ -580,7 +580,12 @@
"deviceName": "Gerätename",
"firmwareVersion": "Firmware-Version",
"macAddress": "MAC-Adresse",
"deviceIp": "Geräte-IP"
"deviceIp": "Geräte-IP",
"sectionAmbientLight": "Ambientebeleuchtung",
"ambientLight": "Hauptschalter",
"ledRed": "Rot",
"ledGreen": "Grün",
"ledBlue": "Blau"
}
},
"prompt": {
+8 -3
View File
@@ -56,8 +56,8 @@
"pageTitle": "I/O",
"inputSection": "Input",
"outputSection": "Output",
"inputOptions": ["USB-B", "USB-C", "Coaxial", "Optical", "Bluetooth", "IIS", "RCA", "Local Input"],
"outputOptions": ["XLR", "RCA", "XLR/RCA", "Headset"],
"inputOptions": ["USB-B", "USB-C", "Coaxial", "Optical", "Bluetooth", "IIS", "AES", "USB Drive"],
"outputOptions": ["XLR", "RCA", "XLR/RCA"],
"input": {
"coaxial": "COAXIAL",
"optical": "OPTICAL",
@@ -580,7 +580,12 @@
"deviceName": "Device name",
"firmwareVersion": "Firmware version",
"macAddress": "MAC address",
"deviceIp": "Device IP"
"deviceIp": "Device IP",
"sectionAmbientLight": "Ambient light",
"ambientLight": "Master switch",
"ledRed": "Red",
"ledGreen": "Green",
"ledBlue": "Blue"
}
},
"prompt": {
+8 -3
View File
@@ -56,8 +56,8 @@
"pageTitle": "E/S",
"inputSection": "Entrada",
"outputSection": "Salida",
"inputOptions": ["USB-B", "USB-C", "Coaxial", "Óptico", "Bluetooth", "IIS", "RCA", "Entrada local"],
"outputOptions": ["XLR", "RCA", "XLR/RCA", "Auriculares"],
"inputOptions": ["USB-B", "USB-C", "Coaxial", "Óptico", "Bluetooth", "IIS", "AES", "Memoria USB"],
"outputOptions": ["XLR", "RCA", "XLR/RCA"],
"input": {
"coaxial": "COAXIAL",
"optical": "ÓPTICO",
@@ -580,7 +580,12 @@
"deviceName": "Nombre del dispositivo",
"firmwareVersion": "Versión de firmware",
"macAddress": "Dirección MAC",
"deviceIp": "IP del dispositivo"
"deviceIp": "IP del dispositivo",
"sectionAmbientLight": "Iluminación ambiental",
"ambientLight": "Interruptor principal",
"ledRed": "Rojo",
"ledGreen": "Verde",
"ledBlue": "Azul"
}
},
"prompt": {
+8 -3
View File
@@ -56,8 +56,8 @@
"pageTitle": "E/S",
"inputSection": "Entrée",
"outputSection": "Sortie",
"inputOptions": ["USB-B", "USB-C", "Coaxial", "Optique", "Bluetooth", "IIS", "RCA", "Entrée locale"],
"outputOptions": ["XLR", "RCA", "XLR/RCA", "Casque"],
"inputOptions": ["USB-B", "USB-C", "Coaxial", "Optique", "Bluetooth", "IIS", "AES", "Clé USB"],
"outputOptions": ["XLR", "RCA", "XLR/RCA"],
"input": {
"coaxial": "COAXIAL",
"optical": "OPTIQUE",
@@ -580,7 +580,12 @@
"deviceName": "Nom de l'appareil",
"firmwareVersion": "Version du firmware",
"macAddress": "Adresse MAC",
"deviceIp": "IP de l'appareil"
"deviceIp": "IP de l'appareil",
"sectionAmbientLight": "Éclairage d'ambiance",
"ambientLight": "Interrupteur principal",
"ledRed": "Rouge",
"ledGreen": "Vert",
"ledBlue": "Bleu"
}
},
"prompt": {
+8 -3
View File
@@ -56,8 +56,8 @@
"pageTitle": "輸入/輸出",
"inputSection": "輸入",
"outputSection": "輸出",
"inputOptions": ["USB-B", "USB-C", "同軸", "光纖", "藍牙", "IIS", "類比RCA", "本地輸入"],
"outputOptions": ["XLR", "RCA", "XLR/RCA", "耳機"],
"inputOptions": ["USB-B", "USB-C", "同軸", "光纖", "藍牙", "IIS", "AES", "U 盤"],
"outputOptions": ["XLR", "RCA", "XLR/RCA"],
"input": { "coaxial": "同軸", "optical": "光纖", "bluetooth": "藍牙", "rca": "類比RCA" },
"output": { "headset": "耳機" }
},
@@ -574,7 +574,12 @@
"deviceName": "裝置名稱",
"firmwareVersion": "韌體版本",
"macAddress": "MAC 位址",
"deviceIp": "裝置 IP"
"deviceIp": "裝置 IP",
"sectionAmbientLight": "氛圍燈",
"ambientLight": "總開關",
"ledRed": "紅",
"ledGreen": "綠",
"ledBlue": "藍"
}
},
"prompt": {
+8 -3
View File
@@ -56,8 +56,8 @@
"pageTitle": "输入输出",
"inputSection": "输入",
"outputSection": "输出",
"inputOptions": ["USB-B", "USB-C", "同轴", "光纤", "蓝牙", "IIS", "模拟RCA", "本地输入"],
"outputOptions": ["XLR", "RCA", "XLR/RCA", "耳机"],
"inputOptions": ["USB-B", "USB-C", "同轴", "光纤", "蓝牙", "IIS", "AES", "U 盘"],
"outputOptions": ["XLR", "RCA", "XLR/RCA"],
"input": { "coaxial": "同轴", "optical": "光纤", "bluetooth": "蓝牙", "rca": "模拟RCA" },
"output": { "headset": "耳机" }
},
@@ -575,7 +575,12 @@
"deviceName": "设备名称",
"firmwareVersion": "固件版本",
"macAddress": "MAC 地址",
"deviceIp": "设备 IP"
"deviceIp": "设备 IP",
"sectionAmbientLight": "氛围灯",
"ambientLight": "总开关",
"ledRed": "红",
"ledGreen": "绿",
"ledBlue": "蓝"
}
},
"prompt": {
+4 -39
View File
@@ -3,7 +3,7 @@
Design: Reference luxsin_x8_音频设置.png
Sections:
- 平衡与灵敏度: 左右平衡, VU灵敏度, 模拟输入增益 sliders
- 输出特性: 滤波特性, 耳机增益, 音量幅度
- 输出特性: 前级输出音量直通模式, 音量幅度
- IIS设置: IIS静音电平, IIS模式
- 系统与接口: 开机音量, XLR极性
All list rows → navigate to /select page
@@ -19,6 +19,7 @@ import {
normalizeBootSound,
parseFirmwareVersion,
readBootSoundFromState,
VOLUME_STEP_DB,
} from "@/lib/luxsinApi";
import localeEn from "@/locales/data-en.json";
import { resolveLocalePack } from "@/locales/resolveLocale";
@@ -73,31 +74,11 @@ 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, toggle, checked, onToggle }: {
function ListRow({ label, value, onClick }: {
label: string;
value?: string;
onClick?: () => void;
toggle?: boolean;
checked?: boolean;
onToggle?: (v: boolean) => void;
}) {
if (toggle) {
return (
<div className="ios-list-row">
<span className="flex-1 text-[16px] text-white">{label}</span>
<IOSToggle checked={!!checked} onChange={onToggle ?? (() => {})} />
</div>
);
}
return (
<button className="ios-list-row w-full text-left active:bg-white/5 transition-colors" onClick={onClick}>
<span className="flex-1 text-[16px] text-white">{label}</span>
@@ -116,9 +97,7 @@ type LocaleDac = {
balance?: string;
sensitivity?: string;
analogInputGain?: string;
autoImpedanceDetection?: string;
filters?: { label?: string; options?: Array<{ index: number; label: string }> };
dacGain?: { label?: string; options?: Array<{ index: number; label: string }> };
dacVolumeDirect?: { label?: string; options?: Array<{ index: number; label: string }> };
dacArc?: { label?: string; options?: Array<{ index: number; label: string }> };
volumeStep?: string;
@@ -137,7 +116,7 @@ function optionLabels(items: Array<{ index: number; label: string }> | undefined
return sorted.map((item) => item.label);
}
const STEP_OPTIONS = ["0.5dB", "1dB", "2dB", "3dB"];
const STEP_OPTIONS = VOLUME_STEP_DB.map((step) => `${step}dB`);
const BOOT_VOLUME_FALLBACK = [
"Default",
@@ -223,8 +202,6 @@ export default function AudioPage() {
}
}, [location, isConnected, api, refresh]);
const gainIdx = ds?.dacGain ?? 0;
const autoImpedanceOn = (ds?.dacImpedance ?? 0) === 1;
const dacVolumeDirectIdx = ds?.dacVolumeDirect ?? 0;
const dacArcIdx = ds?.dacArc ?? 0;
const stepIdx = ds?.soundStep ?? 1;
@@ -240,7 +217,6 @@ export default function AudioPage() {
const BOOT_VOLUME_OPTIONS = bootVolumeOptions(dacText, bootSoundMaxIdx);
const FILTER_OPTIONS = optionLabels(dacText.filters?.options, ["快速滚降", "慢速滚降", "短延迟快速滚降", "短延迟慢速滚降", "超慢速滚降", "低离散短延迟"]);
const GAIN_OPTIONS = optionLabels(dacText.dacGain?.options, ["低", "中", "高"]);
const DAC_VOLUME_DIRECT_OPTIONS = optionLabels(dacText.dacVolumeDirect?.options, ["关闭", "0dB", "-12dB"]);
const DAC_ARC_OPTIONS = optionLabels(dacText.dacArc?.options, ["ARC", "EARC"]);
const XLR_OPTIONS = optionLabels(dacText.xlr?.options, ["正常", "反转"]);
@@ -354,17 +330,6 @@ export default function AudioPage() {
onClick={() => goSelect(dacText.filters?.label ?? "选择滤波特性", FILTER_OPTIONS, filterIdx, "filterCharacteristic")}
/>
)}
<ListRow
label={dacText.dacGain?.label ?? "耳机增益"}
value={GAIN_OPTIONS[gainIdx] ?? GAIN_OPTIONS[0] ?? "—"}
onClick={() => goSelect(dacText.dacGain?.label ?? "选择耳机增益", GAIN_OPTIONS, gainIdx, "dacGain")}
/>
<ListRow
label={dacText.autoImpedanceDetection ?? "自动检测耳机阻抗"}
toggle
checked={autoImpedanceOn}
onToggle={(v) => updateSetting({ dacImpedance: v ? 1 : 0 })}
/>
<ListRow
label={dacText.dacVolumeDirect?.label ?? "前级输出音量直通模式"}
value={DAC_VOLUME_DIRECT_OPTIONS[dacVolumeDirectIdx] ?? DAC_VOLUME_DIRECT_OPTIONS[0] ?? "—"}
+4 -2
View File
@@ -6,6 +6,7 @@ import Layout from "@/components/Layout";
import GlassToggle from "@/components/GlassToggle";
import { useDevice } from "@/contexts/DeviceContext";
import {
getVolumeStep,
INPUT_LABELS,
OUTPUT_LABELS,
} from "@/lib/luxsinApi";
@@ -29,6 +30,7 @@ import { useCallback, useRef, useState } from "react";
function VolumeKnob() {
const { deviceState, setVolume } = useDevice();
const volume = deviceState?.volume ?? 0;
const volumeStep = getVolumeStep(deviceState?.soundStep);
const percent = volume / 200;
const isDragging = useRef(false);
const startY = useRef(0);
@@ -143,7 +145,7 @@ function VolumeKnob() {
{/* Volume controls */}
<div className="flex items-center gap-3 mt-2">
<button
onClick={() => setVolume(Math.max(0, volume - (deviceState?.soundStep ?? 2)))}
onClick={() => setVolume(Math.max(0, volume - volumeStep))}
className="btn-glass w-8 h-8 flex items-center justify-center"
>
<ChevronDown size={14} className="text-white/60" />
@@ -152,7 +154,7 @@ function VolumeKnob() {
{Math.round(percent * 100)}%
</span>
<button
onClick={() => setVolume(Math.min(200, volume + (deviceState?.soundStep ?? 2)))}
onClick={() => setVolume(Math.min(200, volume + volumeStep))}
className="btn-glass w-8 h-8 flex items-center justify-center"
>
<ChevronUp size={14} className="text-white/60" />
+2 -5
View File
@@ -24,6 +24,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
getVolumeStep,
INPUT_BLUETOOTH_INDEX,
INPUT_LABELS,
isHomeVolumeLockedByPassthrough,
@@ -433,11 +434,7 @@ export default function Home() {
const vuLabel = `${homeText.vu ?? "VU表"}${(ds.vu ?? 0) + 1}`;
const bypassOn = (ds.dsp_enable ?? 0) === 0;
// Home 的 volume 取值为 0..200,对应 dB = (v - 200) / 2
// 因此 1 个 volume 单位 = 0.5dB
// AudioPage 的 soundStep:0->0.5dB, 1->1dB, 2->2dB, 3->3dB
// 换算为 volume 单位:0.5/1/2/3 dB -> 1/2/4/6
const fineVolumeStep = [1, 2, 4, 6][ds.soundStep ?? 1] ?? 1;
const fineVolumeStep = getVolumeStep(ds.soundStep);
// Slider fill % (0-200 → 0-100%)
const fillPct = (localVol / 200) * 100;
+4 -14
View File
@@ -106,7 +106,7 @@ const INPUT_OPTION_DEFS: IOOptionDef[] = [
),
},
{
index: 7,
index: 8,
icon: <FolderInput size={28} />,
},
];
@@ -143,20 +143,10 @@ const OUTPUT_OPTION_DEFS: IOOptionDef[] = [
</svg>
),
},
{
index: 2,
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-7 h-7">
<path d="M3 18v-6a9 9 0 0 1 18 0v6" />
<path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3z" />
<path d="M3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z" />
</svg>
),
},
];
const FALLBACK_INPUT_LABELS = ["USB-B", "USB-C", "同轴", "光纤", "蓝牙", "IIS", "RCA", "本地输入"];
const FALLBACK_OUTPUT_LABELS = ["XLR", "RCA", "XLR/RCA", "耳机"];
const FALLBACK_INPUT_LABELS = ["USB-B", "USB-C", "同轴", "光纤", "蓝牙", "IIS", "AES", "U 盘"];
const FALLBACK_OUTPUT_LABELS = ["XLR", "RCA", "XLR/RCA"];
function withLabels(defs: IOOptionDef[], labels: string[] | undefined, fallback: string[]): IOOption[] {
return defs.map((def, i) => ({
@@ -225,7 +215,7 @@ export default function IOPage() {
const outputSectionTitle = sourceText.outputSection ?? "输出";
const currentInput = deviceState?.input ?? 1;
const currentOutput = deviceState?.output ?? 2;
const currentOutput = deviceState?.output ?? 0;
return (
<div className="min-h-screen bg-black">
+155 -28
View File
@@ -2,7 +2,8 @@
SYSTEM PAGE — Settings
Design: Reference luxsin_x8_设置.png
Sections:
- 显示: 屏幕亮度, 自动锁屏, 旋钮亮度, 旋钮熄屏呼吸灯
- 显示: 屏幕亮度, 自动锁屏
- 氛围灯: 总开关, RGB 通道调节
- 通用: 语言, 自动返回首页, 休眠
- 声音: 开机提示音
- 关于: 设备名, 固件版本, MAC
@@ -11,18 +12,18 @@
import { useDevice } from "@/contexts/DeviceContext";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useLocation } from "wouter";
import { useEffect } from "react";
import { useEffect, useRef, useState } from "react";
import BottomNav from "@/components/BottomNav";
import { formatFirmwareVersionDisplay } from "@/lib/luxsinApi";
import { clampLedChannel, formatFirmwareVersionDisplay } from "@/lib/luxsinApi";
import { navigateToSelect } from "./SelectPage";
import localeEn from "@/locales/data-en.json";
import { DEFAULT_DEVICE_LANGUAGE, resolveDeviceLanguage, resolveLocalePack } from "@/locales/resolveLocale";
import { usePageTitleWithConnection } from "@/hooks/usePageTitleWithConnection";
function IOSToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
function IOSToggle({ checked, onChange, label }: { checked: boolean; onChange: (v: boolean) => void; label: string }) {
return (
<label className="ios-toggle" onClick={(e) => e.stopPropagation()}>
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
<input type="checkbox" aria-label={label} checked={checked} onChange={(e) => onChange(e.target.checked)} />
<span className="ios-toggle-track"><span className="ios-toggle-thumb" /></span>
</label>
);
@@ -36,21 +37,55 @@ function SectionHeader({ title }: { title: string }) {
);
}
function ListRow({ label, description, value, onClick, toggle, checked, onToggle }: {
label: string; description?: string; value?: string;
onClick?: () => void; toggle?: boolean; checked?: boolean; onToggle?: (v: boolean) => void;
function CyanSlider({ value, min, max, step = 1, label, onChange, onRelease }: {
value: number;
min: number;
max: number;
step?: number;
label: string;
onChange: (v: number) => void;
onRelease: (v: number) => void;
}) {
if (toggle) {
const fillPct = ((value - min) / (max - min)) * 100;
const emitRelease = (el: EventTarget | null) => {
if (!(el instanceof HTMLInputElement)) return;
onRelease(Number(el.value));
};
return (
<div className="ios-list-row">
<div className="flex-1 min-w-0">
<div className="text-[16px] text-white">{label}</div>
{description && <div className="text-[12px] text-white/35 mt-0.5">{description}</div>}
</div>
<IOSToggle checked={!!checked} onChange={onToggle ?? (() => {})} />
<div className="relative flex items-center w-full mt-2">
<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"
aria-label={label}
min={min}
max={max}
step={step}
value={value}
className="cyan-slider relative z-10"
onChange={(e) => onChange(Number(e.target.value))}
onPointerUp={(e) => emitRelease(e.currentTarget)}
onPointerCancel={(e) => emitRelease(e.currentTarget)}
onKeyUp={(e) => {
if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End", "PageUp", "PageDown"].includes(e.key)) {
emitRelease(e.currentTarget);
}
}}
/>
</div>
);
}
function ListRow({ label, description, value, onClick }: {
label: string; description?: string; value?: string;
onClick?: () => void;
}) {
return (
<button className="ios-list-row w-full text-left active:bg-white/5 transition-colors" onClick={onClick}>
<div className="flex-1 min-w-0">
@@ -71,8 +106,6 @@ type LocaleSettings = {
vuMeter?: string;
screenBrightness: { label: string; options: Array<{ index: number; label: string }> };
turnOffScreen: { label: string; options: Array<{ index: number; label: string }> };
knobBrightness: { label: string; options: Array<{ index: number; label: string }> };
buttonLight: { label: string; options: Array<{ index: number; label: string }> };
language: { label: string; options: Array<{ index: number; label: string }> };
Automatic: { label: string; options: Array<{ index: number; label: string }> };
sleepTime?: { label: string; options: Array<{ index: number; label: string }> };
@@ -83,8 +116,6 @@ type LocaleSettings = {
sectionAbout: string;
screenBrightnessDesc: string;
turnOffScreenDesc: string;
knobBrightnessDesc: string;
buttonLightDesc: string;
languageDesc: string;
automaticDesc: string;
sleepDesc: string;
@@ -92,6 +123,11 @@ type LocaleSettings = {
firmwareVersion: string;
macAddress: string;
deviceIp: string;
sectionAmbientLight?: string;
ambientLight?: string;
ledRed?: string;
ledGreen?: string;
ledBlue?: string;
};
};
};
@@ -127,18 +163,35 @@ export default function SystemPage() {
}, [isConnected, api]);
const screenLightIdx = ds?.screenLight ?? 1;
const buttonLightIdx = ds?.buttonLight ?? 1;
const screenOffIdx = ds?.screenOff ?? 2;
const sleepIdx = ds?.sleep ?? 0;
const autoHomeIdx = ds?.autoHome ?? 1;
const rawLangIdx = resolveDeviceLanguage(ds?.language);
const langIdx = SUPPORTED_LANGUAGE_INDICES.has(rawLangIdx) ? rawLangIdx : DEFAULT_DEVICE_LANGUAGE;
const knobBreathLightOn = (ds?.knob_breathlight ?? 1) === 0;
const ledOn = (ds?.led_enable ?? 0) === 1;
const [localLedRed, setLocalLedRed] = useState(() => clampLedChannel(ds?.led_red));
const [localLedGreen, setLocalLedGreen] = useState(() => clampLedChannel(ds?.led_green));
const [localLedBlue, setLocalLedBlue] = useState(() => clampLedChannel(ds?.led_blue));
const draggingLedRed = useRef(false);
const draggingLedGreen = useRef(false);
const draggingLedBlue = useRef(false);
useEffect(() => {
if (!draggingLedRed.current) setLocalLedRed(clampLedChannel(ds?.led_red));
}, [ds?.led_red]);
useEffect(() => {
if (!draggingLedGreen.current) setLocalLedGreen(clampLedChannel(ds?.led_green));
}, [ds?.led_green]);
useEffect(() => {
if (!draggingLedBlue.current) setLocalLedBlue(clampLedChannel(ds?.led_blue));
}, [ds?.led_blue]);
const localeData = resolveLocalePack(ds?.language) as LocaleSettings;
const settingsText = localeData.settings;
const ui = settingsText.systemPage;
const SCREEN_LIGHT_OPTIONS = optionLabels(settingsText.screenBrightness?.options, ["亮", "中等", "暗"]);
const KNOB_LIGHT_OPTIONS = optionLabels(settingsText.knobBrightness?.options, ["关闭", "亮", "中等", "暗"]);
const SCREEN_OFF_OPTIONS = optionLabels(settingsText.turnOffScreen?.options, ["常亮", "30秒", "1分钟", "3分钟", "5分钟"]);
const SLEEP_OPTIONS = optionLabels(settingsText.sleepTime?.options ?? settingsText.sleep?.options, ["关闭", "1分钟", "5分钟", "10分钟"]);
const AUTO_HOME_OPTIONS = optionLabels(settingsText.Automatic?.options, ["关闭", "20秒", "40秒", "60秒"]);
@@ -181,12 +234,86 @@ export default function SystemPage() {
<ListRow label={settingsText.turnOffScreen.label} description={ui.turnOffScreenDesc}
value={SCREEN_OFF_OPTIONS[screenOffIdx]}
onClick={() => goSelect(settingsText.turnOffScreen.label, SCREEN_OFF_OPTIONS, screenOffIdx, "screenOff")} />
<ListRow label={settingsText.knobBrightness.label} description={ui.knobBrightnessDesc}
value={KNOB_LIGHT_OPTIONS[buttonLightIdx]}
onClick={() => goSelect(settingsText.knobBrightness.label, KNOB_LIGHT_OPTIONS, buttonLightIdx, "buttonLight")} />
<ListRow label={settingsText.buttonLight.label} description={ui.buttonLightDesc}
toggle checked={knobBreathLightOn}
onToggle={(v) => updateSetting({ knob_breathlight: v ? 0 : 1 })} />
</div>
{/* ── 氛围灯 ── */}
<SectionHeader title={ui.sectionAmbientLight ?? "氛围灯"} />
<div className="ios-list-group">
<div className="ios-list-row">
<div className="flex-1" />
<IOSToggle
label={ui.ambientLight ?? "总开关"}
checked={ledOn}
onChange={(v) => updateSetting({ led_enable: v ? 1 : 0 })}
/>
</div>
{ledOn && (
<div className="p-4 pt-0 space-y-4">
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-[14px] text-white/60">{ui.ledRed ?? "红"}</span>
<span className="text-[14px] font-bold" style={{ color: "#00FFF6" }}>{localLedRed}</span>
</div>
<CyanSlider
label={ui.ledRed ?? "红"}
value={localLedRed}
min={0}
max={255}
onChange={(v) => {
draggingLedRed.current = true;
setLocalLedRed(v);
}}
onRelease={(v) => {
draggingLedRed.current = false;
updateSetting({ led_red: clampLedChannel(v) });
}}
/>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-[14px] text-white/60">{ui.ledGreen ?? "绿"}</span>
<span className="text-[14px] font-bold" style={{ color: "#00FFF6" }}>{localLedGreen}</span>
</div>
<CyanSlider
label={ui.ledGreen ?? "绿"}
value={localLedGreen}
min={0}
max={255}
onChange={(v) => {
draggingLedGreen.current = true;
setLocalLedGreen(v);
}}
onRelease={(v) => {
draggingLedGreen.current = false;
updateSetting({ led_green: clampLedChannel(v) });
}}
/>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-[14px] text-white/60">{ui.ledBlue ?? "蓝"}</span>
<span className="text-[14px] font-bold" style={{ color: "#00FFF6" }}>{localLedBlue}</span>
</div>
<CyanSlider
label={ui.ledBlue ?? "蓝"}
value={localLedBlue}
min={0}
max={255}
onChange={(v) => {
draggingLedBlue.current = true;
setLocalLedBlue(v);
}}
onRelease={(v) => {
draggingLedBlue.current = false;
updateSetting({ led_blue: clampLedChannel(v) });
}}
/>
</div>
</div>
)}
</div>
{/* ── 通用 ── */}