diff --git a/client/src/lib/luxsinApi.ts b/client/src/lib/luxsinApi.ts index 1a23847..6d2fc8e 100644 --- a/client/src/lib/luxsinApi.ts +++ b/client/src/lib/luxsinApi.ts @@ -28,8 +28,11 @@ export function decodeCustomBase64(encoded: string): string { const index = ALPHABET_CUSTOM.indexOf(char); translated += index !== -1 ? ALPHABET_STANDARD.charAt(index) : char; } - // Chrome 91 atob() is stricter than modern browsers. + // Strip whitespace / newlines that may be present in API responses. + // Chrome 91 atob() is stricter than modern browsers and rejects these. translated = translated.replace(/\s/g, ""); + // Ensure correct Base64 padding (=). Modern atob() tolerates missing padding, + // but Chrome 91 throws "The string to be decoded is not correctly encoded". const padNeeded = (4 - (translated.length % 4)) % 4; if (padNeeded > 0) { translated += "=".repeat(padNeeded); @@ -95,6 +98,7 @@ export interface DeviceState { dacGain: number; dacArc: number; dacImpedance: number; + dreMode: number; dacVolumeDirect: number; analogGain: number; effect_enable: number; @@ -109,56 +113,19 @@ export interface DeviceState { subwoofer_value: number; subwoofer_rate: number; subwoofer_gain: number; - subwoofer_mix_type: number; - subwoofer_delay: number; - subwoofer_delay_main: number; - subwoofer_delay_r: number; - subwoofer_delay_main_r: number; - subwoofer_lpf_enable: number; - subwoofer_hpf_enable: number; loudness_enable: number; loudness_bass_gain: number; loudness_treble_gain: number; loudness_threshold_gain: number; - hearing_enable: number; - hearing_select: number; - hearing_data: HearingProfile[] | string; bt_status: number; bt_srcname: string; bt_title: string; bt_artist: string; msgCount: number; -} - -export interface HearingProfile { - n: string; - l: number[]; - r: number[]; -} - -/** Parse `hearing_data` from syncData (array or JSON string). */ -export function parseHearingData(raw: unknown): HearingProfile[] { - if (raw == null || raw === "") return []; - let value: unknown = raw; - if (typeof raw === "string") { - try { - value = JSON.parse(raw); - } catch { - return []; - } - } - if (!Array.isArray(value)) return []; - return value - .map((item) => { - if (!item || typeof item !== "object") return null; - const row = item as Record; - const n = typeof row.n === "string" ? row.n.trim() : String(row.n ?? "").trim(); - if (!n) return null; - const l = Array.isArray(row.l) ? row.l.map((v) => Number(v)) : []; - const r = Array.isArray(row.r) ? row.r.map((v) => Number(v)) : []; - return { n, l, r }; - }) - .filter((item): item is HearingProfile => item !== null); + led_enable: number; + led_red: number; + led_green: number; + led_blue: number; } export interface PeqFilter { @@ -170,7 +137,8 @@ export interface PeqFilter { export interface PeqState { filters: PeqFilter[]; - peqSelect?: number; + peqSelect?: number; + peqEnable?: number; // 0: off, 1: on peq?: Array<{ name: string; filters?: PeqFilter[] | string; @@ -242,14 +210,8 @@ export interface PeqApplyPayload { // ============================================================ // Input/Output Labels // ============================================================ -export const INPUT_LABELS = ["USB", "USB-C", "Coaxial", "Optical", "Bluetooth", "HDMI-EARC", "RCA"]; +export const INPUT_LABELS = ["USB", "USB-C", "Coaxial", "Optical", "Bluetooth", "IIS", "RCA"]; export const OUTPUT_LABELS = ["XLR", "RCA", "Headset", "XLR/RCA"]; - -/** Device `input` index for Bluetooth source. */ -export const INPUT_BLUETOOTH_INDEX = 4; - -/** Device `output` index for headphone / headset. */ -export const OUTPUT_HEADSET_INDEX = 2; export const LANGUAGE_LABELS = ["English", "繁體中文", "简体中文"]; export const SCREEN_LIGHT_LABELS = ["Bright", "Medium", "Dark"]; export const KNOB_LIGHT_LABELS = ["Off", "Bright", "Medium", "Dark"]; @@ -301,7 +263,7 @@ export class LuxsinAPI { const text = await response.text(); const decoded = decodeCustomBase64(text.trim()); const state = JSON.parse(decoded) as DeviceState; - console.log("[syncData] decoded", state); + console.log("[syncData]", state); return state; } catch (error) { this.maybeRedirectForHttpsCert(error); @@ -342,29 +304,21 @@ export class LuxsinAPI { await this.postPeqJson(body); } - /** Apply current EQ filters (e.g. A/B comparison curve) without saving preset metadata. */ + /** Apply current EQ filters (e.g. A/B curve switch) without a full preset save. */ async upgradePeqApply(body: PeqApplyPayload): Promise { await this.postPeqJson(body); } /** POST `json=` — matches legacy axios `upgradePeq`. */ private async postPeqJson(body: PeqChangePayload | PeqApplyPayload): Promise { - if (import.meta.env.DEV) { - const action = - "peqChange" in body ? "peqChange" : "peqApply" in body ? "peqApply" : "peq"; - console.log(`[dev/info.cgi] POST ${action}`, body); - } const encoded = encodeCustomBase64(JSON.stringify(body)); const form = new URLSearchParams(); form.set("json", encoded); - const response = await fetch(`${this.baseUrl}/dev/info.cgi`, { + await fetch(`${this.baseUrl}/dev/info.cgi`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" }, body: form.toString(), }); - if (!response.ok) { - throw new Error(`PEQ request failed: ${response.status}`); - } } /** Remove one or more headphone PEQ profiles. */ @@ -372,14 +326,11 @@ export class LuxsinAPI { const encoded = encodeCustomBase64(JSON.stringify({ peqRemove: names })); const form = new URLSearchParams(); form.set("json", encoded); - const response = await fetch(`${this.baseUrl}/dev/info.cgi`, { + await fetch(`${this.baseUrl}/dev/info.cgi`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" }, body: form.toString(), }); - if (!response.ok) { - throw new Error(`PEQ remove failed: ${response.status}`); - } } setVolume(volume: number) { return this.setSetting({ volume }); } @@ -409,7 +360,7 @@ export class LuxsinAPI { setCrossfeedEnable(enable: boolean) { return this.setSetting({ crossfeed_enable: enable ? 1 : 0 }); } setXlrPolarity(reverse: boolean) { return this.setSetting({ xlr: reverse ? 1 : 0 }); } setDacArc(earc: boolean) { return this.setSetting({ dacArc: earc ? 1 : 0 }); } - /** Boot volume level: 0 = default, 1..6 = -5..-30 dB, 7..10 = -35..-50 dB (see firmwareFeatures.bootSoundExtendedSteps). */ + /** Boot volume level: 0 = default, 1..6 = -5..-30 dB, 7..10 = -35..-50 dB (firmware >= 26). */ setBootSound(level: number) { return this.setSetting({ bootSound: Math.max(0, Math.min(10, Math.round(level))) }); } @@ -417,7 +368,7 @@ export class LuxsinAPI { powerOff() { return this.setSetting({ power: 0 }); } } -/** Parse firmware `version` for feature gating (see `config/firmwareFeatures.ts`). */ +/** Parse firmware `version` for feature gating (e.g. bootSound extended steps). */ export function parseFirmwareVersion(version: unknown): number { if (version === null || version === undefined) return 0; if (typeof version === "number" && Number.isFinite(version)) return version; @@ -430,34 +381,32 @@ export function parseFirmwareVersion(version: unknown): number { return Number(parts[parts.length - 1]); } -/** Display firmware version as X.Y.0.Z (e.g. 2005 → 2.0.0.5). */ +/** 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 || String(version).trim() === "") { - return "—"; - } - const n = parseFirmwareVersion(version); - const major = Math.floor(n / 1000); - const minor = Math.floor((n % 1000) / 100); - const patch = 0; - const build = n % 10; - return `${major}.${minor}.${patch}.${build}`; + 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}`; } -/** Pre-out volume passthrough mode selected (0dB or -12dB). */ -export function isVolumePassthroughActive(dacVolumeDirect: number | undefined): boolean { - return dacVolumeDirect === 1 || dacVolumeDirect === 2; +/** Max `bootSound` index available for the current firmware. */ +export function getBootSoundMaxIndex(firmwareVersion: number): number { + return firmwareVersion >= 26 ? 10 : 6; } -/** Lock home volume controls when passthrough is on, except output is headphone. */ -export function isHomeVolumeLockedByPassthrough( - dacVolumeDirect: number | undefined, - output: number | undefined, -): boolean { - if (!isVolumePassthroughActive(dacVolumeDirect)) return false; - return (output ?? -1) !== OUTPUT_HEADSET_INDEX; +/** 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 { getBootSoundMaxIndex } from "@/config/firmwareFeatures"; +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 { @@ -476,8 +425,8 @@ export function readBootSoundFromState(state: DeviceState | null | undefined): n // Mock data for demo/offline mode // ============================================================ export const MOCK_DEVICE_STATE: DeviceState = { - device: "Luxsin-X9", - version: "1.2.3", + device: "Luxsin-X8", + version: 28, mac: "AA:BB:CC:DD:EE:FF", language: 0, volume: 75, @@ -508,6 +457,7 @@ export const MOCK_DEVICE_STATE: DeviceState = { dacGain: 0, dacArc: 0, dacImpedance: 0, + dreMode: 0, dacVolumeDirect: 0, analogGain: 0, effect_enable: 0, @@ -522,31 +472,19 @@ export const MOCK_DEVICE_STATE: DeviceState = { subwoofer_value: 80, subwoofer_rate: 0, subwoofer_gain: 0, - subwoofer_mix_type: 0, - subwoofer_delay: 582, - subwoofer_delay_main: 571, - subwoofer_delay_r: 582, - subwoofer_delay_main_r: 571, - subwoofer_lpf_enable: 0, - subwoofer_hpf_enable: 0, loudness_enable: 0, loudness_bass_gain: 3, loudness_treble_gain: 2, loudness_threshold_gain: 60, - hearing_enable: 0, - hearing_select: 0, - hearing_data: [ - { - n: "testing", - l: [1.7, 0.8, 0.5, 0.5, 0.5, 0.5, 0.4], - r: [0.4, 0.4, 0.3, 0.3, 0.2, 0.4, 0.3], - }, - ], bt_status: 1, bt_srcname: "iPhone 15 Pro", 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/pages/AIPage.tsx b/client/src/pages/AIPage.tsx index ba8abb5..a059752 100644 --- a/client/src/pages/AIPage.tsx +++ b/client/src/pages/AIPage.tsx @@ -8,6 +8,16 @@ ============================================================ */ import BottomNav from "@/components/BottomNav"; import ConnectionPlaceholder from "@/components/ConnectionPlaceholder"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { useDevice } from "@/contexts/DeviceContext"; import { AI_SPLIT_RESTORE_PATH_KEY, @@ -30,7 +40,6 @@ import { } from "@/lib/aiApi"; import { buildPeqSvgCurveData, getFilterType, PeqBandForResponse } from "@/lib/peqAudio"; -import { buildPeqPresetBody } from "@/lib/luxsinApi"; import { cn } from "@/lib/utils"; import { ArrowUp, @@ -206,12 +215,18 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) const [routePath, setLocation] = useLocation(); const isLgUp = useIsLgUp(); const { setOpen: setAiPanelOpen } = useAIDrawer(); - const { isConnected, deviceState, api } = useDevice(); + const { isConnected, deviceState, api, updateSetting } = useDevice(); const mac = deviceState?.mac ?? ""; const language = resolveDeviceLanguage(deviceState?.language); const deviceName = deviceState?.device ?? "Luxsin X9"; const aiText = useMemo(() => resolveAILocale(language), [language]); + const promptText = useMemo(() => resolveLocalePack(language).prompt, [language]); + const eqUi = useMemo(() => { + const pack = resolveLocalePack(language); + const peq = pack.peq as typeof localeZh.peq; + return peq.eqUi ?? (localeEn.peq as typeof localeEn.peq).eqUi; + }, [language]); const restoreDesktopSplit = useCallback(() => { const path = readAndClearAiSplitRestorePath(); @@ -224,6 +239,8 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) const [input, setInput] = useState(""); const [sending, setSending] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); + const [pendingDeleteId, setPendingDeleteId] = useState(null); + const [pendingApplyMsg, setPendingApplyMsg] = useState(null); const [chats, setChats] = useState([]); // 初次加载历史会话期间,避免先闪一下欢迎页再切到消息 const [initializing, setInitializing] = useState(true); @@ -383,27 +400,22 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) } }, [aiText, chatId, newChat]); - // ── 历史面板里删除某条会话 ── - const deleteChatById = useCallback( - async (id: string) => { - const ok = window.confirm(aiText.history.deleteConfirm); - if (!ok) return; - try { - await clearChat(id); - setChats((prev) => prev.filter((c) => c.id !== id)); - if (chatId === id) { - setChatId(null); - setMessages([]); - } - toast.success(aiText.history.deleted); - } catch (e: any) { - toast.error( - formatText(aiText.history.deleteFailed, { message: e?.message ?? e }), - ); + // ── 历史面板里删除某条会话(确认框用 AlertDialog,WebView 不支持 window.confirm) ── + const confirmDeleteChat = useCallback(async (id: string) => { + try { + await clearChat(id); + setChats((prev) => prev.filter((c) => c.id !== id)); + if (chatId === id) { + setChatId(null); + setMessages([]); } - }, - [aiText, chatId], - ); + toast.success(aiText.history.deleted); + } catch (e: any) { + toast.error( + formatText(aiText.history.deleteFailed, { message: e?.message ?? e }), + ); + } + }, [aiText, chatId]); // ── 发送提问 ── const send = useCallback( @@ -621,8 +633,6 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) autoPre: selected?.autoPre, brand: selected?.brand, model: selected?.model, - target: selected?.target, - form: selected?.form, filters: (selectedFilters ?? peqState.filters ?? []) as OptimizePeqPayload["filters"], }); } catch { @@ -634,7 +644,7 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) if (isConnected) void refreshCurrentDevicePeq(); }, [isConnected, refreshCurrentDevicePeq]); - const handleApplyToggle = useCallback( + const executeApplyToggle = useCallback( async (msg: OptimizeUIMessage) => { if (!api) { toast.error(aiText.deviceDisconnected); @@ -642,28 +652,21 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) } const nextApplied = !msg.applied; const targetPeq = nextApplied ? msg.afterPeq : msg.beforePeq; - const metaSource = currentDevicePeq ?? targetPeq; setApplyingId(msg.id); try { await api.upgradePeqChange({ - peqChange: buildPeqPresetBody( - { - name: targetPeq.name ?? msg.name ?? metaSource?.name ?? "", - brand: targetPeq.brand ?? metaSource?.brand, - model: targetPeq.model ?? metaSource?.model, - target: targetPeq.target ?? metaSource?.target, - form: targetPeq.form ?? metaSource?.form, - autoPre: targetPeq.autoPre ?? metaSource?.autoPre, - preamp: targetPeq.preamp ?? metaSource?.preamp, - canDel: targetPeq.canDel ?? metaSource?.canDel, - }, - (targetPeq.filters ?? []).map((f) => ({ + peqChange: { + name: targetPeq.name ?? msg.name, + filters: (targetPeq.filters ?? []).map((f) => ({ type: getFilterType(f.type), fc: (f.fc ?? f.frequency ?? 1000) as number, gain: f.gain, q: f.q, })), - ), + autoPre: targetPeq.autoPre, + preamp: targetPeq.preamp, + canDel: targetPeq.canDel, + }, }); await updateMessageApplied(msg.id, nextApplied); await api.refreshState(); @@ -687,7 +690,35 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) setApplyingId(null); } }, - [aiText, api, currentDevicePeq, refreshCurrentDevicePeq], + [aiText, api, refreshCurrentDevicePeq], + ); + + const handleApplyToggle = useCallback( + (msg: OptimizeUIMessage) => { + if (!api) { + toast.error(aiText.deviceDisconnected); + return; + } + if ((deviceState?.peqEnable ?? 0) !== 1) { + setPendingApplyMsg(msg); + return; + } + void executeApplyToggle(msg); + }, + [aiText, api, deviceState?.peqEnable, executeApplyToggle], + ); + + const confirmEnableAndApply = useCallback( + async (msg: OptimizeUIMessage) => { + try { + const bypassOn = (deviceState?.dsp_enable ?? 0) === 0; + await updateSetting(bypassOn ? { peqEnable: 1, dsp_enable: 1 } : { peqEnable: 1 }); + await executeApplyToggle(msg); + } catch (e: any) { + toast.error(formatText(aiText.optimize.applyFailed, { message: e?.message ?? e })); + } + }, + [aiText, deviceState?.dsp_enable, executeApplyToggle, updateSetting], ); const openCompare = useCallback( @@ -907,10 +938,72 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) onClose={() => setHistoryOpen(false)} onSelect={(id) => loadChat(id)} onNew={newChat} - onDelete={deleteChatById} + onDelete={setPendingDeleteId} /> )} + { + if (!open) setPendingDeleteId(null); + }} + > + + + + {aiText.history.delete} + + + {aiText.history.deleteConfirm} + + + + + {promptText.cancel} + + { + if (pendingDeleteId) void confirmDeleteChat(pendingDeleteId); + }} + > + {promptText.delete} + + + + + + { + if (!open) setPendingApplyMsg(null); + }} + > + + + + {eqUi.enableConfirmTitle} + + + {eqUi.enableConfirmDesc} + + + + + {eqUi.cancel} + + { + if (pendingApplyMsg) void confirmEnableAndApply(pendingApplyMsg); + }} + > + {eqUi.enableConfirmOk} + + + + + {/* ── 对比对话框 ── */} {compareMsg && ( )}