diff --git a/client/src/lib/luxsinApi.ts b/client/src/lib/luxsinApi.ts index b25959c..4243e2c 100644 --- a/client/src/lib/luxsinApi.ts +++ b/client/src/lib/luxsinApi.ts @@ -122,6 +122,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 PeqFilter { @@ -390,6 +394,19 @@ export function getBootSoundMaxIndex(firmwareVersion: number): number { return firmwareVersion >= 26 ? 10 : 6; } +/** Ambient LED block on System page (firmware build 28+). */ +export const AMBIENT_LED_MIN_FIRMWARE_VERSION = 28; + +export function supportsAmbientLed(firmwareVersion: number): boolean { + return firmwareVersion >= AMBIENT_LED_MIN_FIRMWARE_VERSION; +} + +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); @@ -408,7 +425,7 @@ export function readBootSoundFromState(state: DeviceState | null | undefined): n // ============================================================ export const MOCK_DEVICE_STATE: DeviceState = { device: "Luxsin-X8", - version: "1.2.3", + version: 28, mac: "AA:BB:CC:DD:EE:FF", language: 0, volume: 75, @@ -463,6 +480,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 = { diff --git a/client/src/locales/data-de.json b/client/src/locales/data-de.json index 941e14f..0e78bd9 100644 --- a/client/src/locales/data-de.json +++ b/client/src/locales/data-de.json @@ -274,6 +274,7 @@ "addPresetTitle": "Neue Voreinstellung", "addPresetNameEmpty": "Name darf nicht leer sein", "addPresetNameExists": "Dieser Name existiert bereits. Wählen Sie einen anderen.", + "addPresetNameTooLong": "Name darf maximal 80 Zeichen lang sein", "toastAddPresetOk": "Voreinstellung hinzugefügt", "toastAddPresetFail": "Hinzufügen der Voreinstellung fehlgeschlagen", "toastPresetLimitReached": "Voreinstellungslimit erreicht ({{max}}). Löschen Sie einige Voreinstellungen, bevor Sie weitere hinzufügen.", diff --git a/client/src/locales/data-en.json b/client/src/locales/data-en.json index 6efc2d9..259323d 100644 --- a/client/src/locales/data-en.json +++ b/client/src/locales/data-en.json @@ -274,6 +274,7 @@ "addPresetTitle": "New preset", "addPresetNameEmpty": "Name cannot be empty", "addPresetNameExists": "That name already exists. Choose another.", + "addPresetNameTooLong": "Name cannot exceed 80 characters", "toastAddPresetOk": "Preset added", "toastAddPresetFail": "Failed to add preset", "toastPresetLimitReached": "Preset limit reached ({{max}}). Delete some presets before adding more.", @@ -796,7 +797,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": { diff --git a/client/src/locales/data-es.json b/client/src/locales/data-es.json index 35d480b..364e4d1 100644 --- a/client/src/locales/data-es.json +++ b/client/src/locales/data-es.json @@ -274,6 +274,7 @@ "addPresetTitle": "Nuevo ajuste", "addPresetNameEmpty": "El nombre no puede estar vacío", "addPresetNameExists": "Ese nombre ya existe. Elija otro.", + "addPresetNameTooLong": "El nombre no puede superar los 80 caracteres", "toastAddPresetOk": "Ajuste agregado", "toastAddPresetFail": "Error al agregar el ajuste", "toastPresetLimitReached": "Límite de ajustes alcanzado ({{max}}). Elimine algunos ajustes antes de agregar más.", diff --git a/client/src/locales/data-fr.json b/client/src/locales/data-fr.json index 3dabbac..879e5a5 100644 --- a/client/src/locales/data-fr.json +++ b/client/src/locales/data-fr.json @@ -274,6 +274,7 @@ "addPresetTitle": "Nouveau préréglage", "addPresetNameEmpty": "Le nom ne peut pas être vide", "addPresetNameExists": "Ce nom existe déjà. Choisissez-en un autre.", + "addPresetNameTooLong": "Le nom ne peut pas dépasser 80 caractères", "toastAddPresetOk": "Préréglage ajouté", "toastAddPresetFail": "Échec de l'ajout du préréglage", "toastPresetLimitReached": "Limite de préréglages atteinte ({{max}}). Supprimez des préréglages avant d'en ajouter.", diff --git a/client/src/locales/data-zh-HK.json b/client/src/locales/data-zh-HK.json index 742aebe..90ea06c 100644 --- a/client/src/locales/data-zh-HK.json +++ b/client/src/locales/data-zh-HK.json @@ -274,6 +274,7 @@ "addPresetTitle": "新增預設", "addPresetNameEmpty": "名稱不能為空", "addPresetNameExists": "名稱已存在,請修改後再儲存", + "addPresetNameTooLong": "名稱不能超過 80 個字元", "toastAddPresetOk": "已新增預設", "toastAddPresetFail": "新增預設失敗", "toastPresetLimitReached": "預設已達上限({{max}} 個),請先清理後再操作", @@ -796,7 +797,12 @@ "deviceName": "裝置名稱", "firmwareVersion": "韌體版本", "macAddress": "MAC 位址", - "deviceIp": "裝置 IP" + "deviceIp": "裝置 IP", + "sectionAmbientLight": "氛圍燈", + "ambientLight": "總開關", + "ledRed": "紅", + "ledGreen": "綠", + "ledBlue": "藍" } }, "prompt": { diff --git a/client/src/locales/data-zh.json b/client/src/locales/data-zh.json index e2d700f..5e8f2c6 100644 --- a/client/src/locales/data-zh.json +++ b/client/src/locales/data-zh.json @@ -274,6 +274,7 @@ "addPresetTitle": "新增预设", "addPresetNameEmpty": "名称不能为空", "addPresetNameExists": "名称已存在,请修改后再保存", + "addPresetNameTooLong": "名称不能超过 80 个字符", "toastAddPresetOk": "已新增预设", "toastAddPresetFail": "新增预设失败", "toastPresetLimitReached": "预设已达上限({{max}} 个),请先清理后再操作", @@ -796,7 +797,12 @@ "deviceName": "设备名称", "firmwareVersion": "固件版本", "macAddress": "MAC 地址", - "deviceIp": "设备 IP" + "deviceIp": "设备 IP", + "sectionAmbientLight": "氛围灯", + "ambientLight": "总开关", + "ledRed": "红", + "ledGreen": "绿", + "ledBlue": "蓝" } }, "prompt": { diff --git a/client/src/pages/EQPage.tsx b/client/src/pages/EQPage.tsx index 1db38c9..24f86cd 100644 --- a/client/src/pages/EQPage.tsx +++ b/client/src/pages/EQPage.tsx @@ -42,6 +42,7 @@ import { BrandDrawer } from "./eq/components/BrandDrawer"; import { PeqOverwriteConfirmDialog } from "./eq/components/PeqOverwriteConfirmDialog"; import { PeqPresetManageDialog } from "./eq/components/PeqPresetManageDialog"; import { useRawCurve } from "./eq/hooks/useRawCurve"; +import { isPeqPresetNameTooLong } from "./eq/peqPresetName"; import { BAND_FREQ_MAX, BAND_FREQ_MIN, @@ -597,6 +598,10 @@ export default function EQPage() { toast.error(eqUi.addPresetNameEmpty); return; } + if (isPeqPresetNameTooLong(nextName)) { + toast.error(eqUi.addPresetNameTooLong); + return; + } if (headphoneModels.includes(nextName)) { toast.error(eqUi.addPresetNameExists); return; @@ -675,6 +680,10 @@ export default function EQPage() { toast.error(eqUi.addPresetNameEmpty); return; } + if (isPeqPresetNameTooLong(nextName)) { + toast.error(eqUi.addPresetNameTooLong); + return; + } if (headphoneModels.includes(nextName)) { toast.error(eqUi.addPresetNameExists); return; @@ -747,6 +756,10 @@ export default function EQPage() { toast.error(eqUi.addPresetNameEmpty); return; } + if (isPeqPresetNameTooLong(nextName)) { + toast.error(eqUi.addPresetNameTooLong); + return; + } if (!shareCode.trim()) { toast.error(eqUi.importCodeNotFound); return; diff --git a/client/src/pages/SystemPage.tsx b/client/src/pages/SystemPage.tsx index 00cd4f3..0170243 100644 --- a/client/src/pages/SystemPage.tsx +++ b/client/src/pages/SystemPage.tsx @@ -11,10 +11,15 @@ 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 { navigateToSelect } from "./SelectPage"; -import { formatFirmwareVersionDisplay } from "@/lib/luxsinApi"; +import { + clampLedChannel, + formatFirmwareVersionDisplay, + parseFirmwareVersion, + supportsAmbientLed, +} 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"; @@ -36,6 +41,60 @@ function SectionHeader({ title }: { title: string }) { ); } +function CyanSlider({ + value, + min, + max, + step = 1, + onChange, + onRelease, +}: { + value: number; + min: number; + max: number; + step?: number; + onChange: (v: number) => void; + onRelease?: (v: number) => void; +}) { + const fillPct = ((value - min) / (max - min)) * 100; + const emitRelease = (el: EventTarget | null) => { + if (!(el instanceof HTMLInputElement)) return; + onRelease?.(Number(el.value)); + }; + return ( +
+
+ 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); + } + }} + /> +
+ ); +} + 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; @@ -92,6 +151,11 @@ type LocaleSettings = { firmwareVersion: string; macAddress: string; deviceIp: string; + sectionAmbientLight?: string; + ambientLight?: string; + ledRed?: string; + ledGreen?: string; + ledBlue?: string; }; }; }; @@ -140,6 +204,29 @@ export default function SystemPage() { const rawLangIdx = ds?.language ?? 2; const langIdx = SUPPORTED_LANGUAGE_INDICES.has(rawLangIdx) ? rawLangIdx : 2; const knobBreathLightOn = (ds?.knob_breathlight ?? 1) === 0; + const firmwareVersion = parseFirmwareVersion(ds?.version); + const showAmbientLed = supportsAmbientLed(firmwareVersion); + 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 = LOCALE_BY_LANG[langIdx] ?? localeZh; const settingsText = localeData.settings; const ui = settingsText.systemPage; @@ -193,6 +280,91 @@ export default function SystemPage() { onToggle={(v) => updateSetting({ knob_breathlight: v ? 0 : 1 })} />
+ {showAmbientLed && ( + <> + +
+
+
+ updateSetting({ led_enable: v ? 1 : 0 })} + /> +
+ + {ledOn && ( +
+
+
+ {ui.ledRed ?? "红"} + + {localLedRed} + +
+ { + draggingLedRed.current = true; + setLocalLedRed(v); + }} + onRelease={(v) => { + draggingLedRed.current = false; + updateSetting({ led_red: clampLedChannel(v) }); + }} + /> +
+ +
+
+ {ui.ledGreen ?? "绿"} + + {localLedGreen} + +
+ { + draggingLedGreen.current = true; + setLocalLedGreen(v); + }} + onRelease={(v) => { + draggingLedGreen.current = false; + updateSetting({ led_green: clampLedChannel(v) }); + }} + /> +
+ +
+
+ {ui.ledBlue ?? "蓝"} + + {localLedBlue} + +
+ { + draggingLedBlue.current = true; + setLocalLedBlue(v); + }} + onRelease={(v) => { + draggingLedBlue.current = false; + updateSetting({ led_blue: clampLedChannel(v) }); + }} + /> +
+
+ )} +
+ + )} + {/* ── 通用 ── */}
diff --git a/client/src/pages/eq/components/AddPresetDialog.tsx b/client/src/pages/eq/components/AddPresetDialog.tsx index c047ff1..650d913 100644 --- a/client/src/pages/eq/components/AddPresetDialog.tsx +++ b/client/src/pages/eq/components/AddPresetDialog.tsx @@ -1,6 +1,9 @@ import { X } from "lucide-react"; import { cn } from "@/lib/utils"; import type { PeqEqUi } from "../types"; +import { PEQ_PRESET_NAME_MAX_LENGTH } from "../eqConstants"; +import { clampPeqPresetName } from "../peqPresetName"; +import { PeqPresetNameLengthHint } from "./PeqPresetNameLengthHint"; export function AddPresetDialog({ open, @@ -63,12 +66,16 @@ export function AddPresetDialog({ onChange={() => onModeChange("copy")} className="h-4 w-4 shrink-0 accent-[#00FFF6]" /> - onCopyNameChange(e.target.value)} - onClick={(e) => e.stopPropagation()} - className="h-10 min-w-0 flex-1 rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[15px] text-white/90 outline-none ring-0 ring-offset-0 placeholder:text-white/30 focus:border-white/15 focus:ring-0 focus-visible:ring-0" - /> +
+ onCopyNameChange(clampPeqPresetName(e.target.value))} + onClick={(e) => e.stopPropagation()} + maxLength={PEQ_PRESET_NAME_MAX_LENGTH} + className="h-10 w-full rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[15px] text-white/90 outline-none ring-0 ring-offset-0 placeholder:text-white/30 focus:border-white/15 focus:ring-0 focus-visible:ring-0" + /> + +
diff --git a/client/src/pages/eq/components/PeqPresetManageDialog.tsx b/client/src/pages/eq/components/PeqPresetManageDialog.tsx index 38d8006..cf22184 100644 --- a/client/src/pages/eq/components/PeqPresetManageDialog.tsx +++ b/client/src/pages/eq/components/PeqPresetManageDialog.tsx @@ -3,6 +3,9 @@ import { X, Trash2, Pencil, Check, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; import type { PeqEqUi } from "../types"; +import { PEQ_PRESET_NAME_MAX_LENGTH } from "../eqConstants"; +import { clampPeqPresetName, isPeqPresetNameTooLong } from "../peqPresetName"; +import { PeqPresetNameLengthHint } from "./PeqPresetNameLengthHint"; /* ── Custom checkbox ── */ function Checkbox({ @@ -133,6 +136,10 @@ export function PeqPresetManageDialog({ toast.error(eqUi.renameEmptyName); return; } + if (isPeqPresetNameTooLong(nextName)) { + toast.error(eqUi.addPresetNameTooLong); + return; + } const oldItem = items[editingIdx]; if (!oldItem) return; if (nextName === oldItem.name) { @@ -266,12 +273,14 @@ export function PeqPresetManageDialog({
{isEditing ? ( -
- setEditingName(e.target.value)} - className="h-9 min-w-0 flex-1 rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[14px] text-white/90 outline-none placeholder:text-white/30 focus:border-[#00FFF6]/40" - /> +
+
+ setEditingName(clampPeqPresetName(e.target.value))} + maxLength={PEQ_PRESET_NAME_MAX_LENGTH} + className="h-9 min-w-0 flex-1 rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[14px] text-white/90 outline-none placeholder:text-white/30 focus:border-[#00FFF6]/40" + /> +
+
) : (
+ {peqPresetNameLengthText(value)} +

+ ); +} diff --git a/client/src/pages/eq/components/SaveBDialog.tsx b/client/src/pages/eq/components/SaveBDialog.tsx index 0b87cc7..8401027 100644 --- a/client/src/pages/eq/components/SaveBDialog.tsx +++ b/client/src/pages/eq/components/SaveBDialog.tsx @@ -1,5 +1,8 @@ import { X } from "lucide-react"; import type { PeqEqUi } from "../types"; +import { PEQ_PRESET_NAME_MAX_LENGTH } from "../eqConstants"; +import { clampPeqPresetName } from "../peqPresetName"; +import { PeqPresetNameLengthHint } from "./PeqPresetNameLengthHint"; export function SaveBDialog({ open, @@ -40,12 +43,16 @@ export function SaveBDialog({
- onNameChange(e.target.value)} - className="h-11 w-full rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[15px] text-white/90 outline-none placeholder:text-white/30 focus:border-white/15" - autoFocus - /> +
+ onNameChange(clampPeqPresetName(e.target.value))} + maxLength={PEQ_PRESET_NAME_MAX_LENGTH} + className="h-11 w-full rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[15px] text-white/90 outline-none placeholder:text-white/30 focus:border-white/15" + autoFocus + /> + +