import { announce, apiBlob, apiJson, registerWorkspace, setActiveButton, } from "../core.js"; import { refreshCurrentDisplayPreview } from "../scene-board.js"; import { refreshColorTrigger } from "../color-picker.js"; import { setControlBusy, setControlReady, setControlUnavailable } from "../interaction-feedback.js"; import { renderUiCopy, renderUiValue } from "../ui-copy-runtime.js"; import { LatestSeekDispatcher, formatPlaybackTime, projectPlaybackPosition, } from "../current-display-playback.js"; const statusEls = { orientation: document.getElementById("status-orientation"), brightness: document.getElementById("status-brightness"), currentContent: document.getElementById("topbar-current-content"), cpu: document.getElementById("topbar-cpu"), memory: document.getElementById("topbar-memory"), lowVoltageWarning: document.getElementById("topbar-low-voltage-warning"), voltage: document.getElementById("topbar-voltage"), }; const fillButtons = Array.from(document.querySelectorAll("[data-test-fill]")); const customFillButton = document.getElementById("custom-test-fill"); const customColor = document.getElementById("test-color"); const refreshStatusButton = document.getElementById("refresh-status"); const displayTestControls = document.getElementById("display-test-controls"); const testBrightness = document.getElementById("test-brightness"); const testBrightnessValue = document.getElementById("test-brightness-value"); const testBrightnessProtection = document.getElementById("test-brightness-protection"); const displayTestFeedback = document.getElementById("display-test-feedback"); const exitDisplayTest = document.getElementById("exit-display-test"); const storageEls = { total: document.getElementById("device-total-space"), free: document.getElementById("device-free-space"), software: document.getElementById("device-software-size"), templates: document.getElementById("device-template-size"), feedback: document.getElementById("device-storage-feedback"), }; const resourceDialog = document.getElementById("resource-details-dialog"); const resourceDialogClose = document.getElementById("resource-details-close"); const resourceDetailsMessage = document.getElementById("resource-details-message"); const resourceCoreList = document.getElementById("resource-core-list"); const resourceDetailEls = { cpuAppCores: document.getElementById("resource-cpu-app-cores"), cpuSinglePercent: document.getElementById("resource-cpu-single-percent"), cpuAppPercent: document.getElementById("resource-cpu-app-percent"), cpuTotalPercent: document.getElementById("resource-cpu-total-percent"), memoryApp: document.getElementById("resource-memory-app"), memoryAppPercent: document.getElementById("resource-memory-app-percent"), memoryTotalPercent: document.getElementById("resource-memory-total-percent"), }; const currentDisplayDialog = document.getElementById("current-display-dialog"); const currentDisplayDialogName = document.getElementById("current-display-dialog-name"); const currentDisplayDialogClose = document.getElementById("current-display-dialog-close"); const currentDisplayPreviewSurface = document.getElementById("current-display-preview-surface"); const currentDisplayPreviewImage = document.getElementById("current-display-preview-image"); const currentDisplayPreviewPlaceholder = document.getElementById("current-display-preview-placeholder"); const currentDisplayPlaybackControls = document.getElementById("current-display-playback-controls"); const currentDisplayProgress = document.getElementById("current-display-progress"); const currentDisplayProgressTime = document.getElementById("current-display-progress-time"); const currentDisplayPlayPause = document.getElementById("current-display-play-pause"); const currentDisplaySpeedButtons = Array.from(document.querySelectorAll("[data-playback-speed]")); const currentDisplayPlaybackFeedback = document.getElementById("current-display-playback-feedback"); const currentDisplaySeekUnavailableReason = document.getElementById("current-display-seek-unavailable-reason"); let loadedOnce = false; let lastStatusIdentity = null; let latestResources = null; let latestProtection = null; let latestStatus = null; let currentDisplayOpenIdentity = null; let currentDisplayPlayback = null; let currentDisplayPlaybackSampledAt = 0; let currentDisplayPreviewTimer = null; let currentDisplayPreviewPending = false; let currentDisplayPreviewRequested = false; let currentDisplayPreviewSequence = 0; let currentDisplayPreviewUrl = null; let currentDisplayAnimationFrame = null; let currentDisplayDragging = false; let currentDisplayFinalSeekPosition = null; let currentDisplayControlPending = false; let customTestColor = "#40A0FF"; let testActionPending = false; let desiredTestBrightness = null; let applyingTestBrightness = false; let storageTimer = null; const STORAGE_REFRESH_INTERVAL_MS = 300000; const LOW_VOLTAGE_WARNING_TEXT = Object.freeze({ limiting: "电压过低,屏幕亮度限制", critical: "电压过低,屏幕已经禁用,请充电", }); function finiteNumber(value) { if (value === null || value === undefined || value === "") return null; const number = Number(value); return Number.isFinite(number) ? number : null; } function formatPercent(value) { const number = finiteNumber(value); return number === null ? "-" : `${number.toFixed(1)}%`; } function formatMemoryDetailed(bytes) { const number = finiteNumber(bytes); if (number === null || number < 0) return "-"; const mebibytes = number / (1024 * 1024); return `${mebibytes.toFixed(1)} MiB`; } function formatMemoryCompact(bytes) { const number = finiteNumber(bytes); if (number === null || number < 0) return "-"; return `${Math.round(number / (1024 * 1024))}M`; } function formatCoreEquivalent(value) { const number = finiteNumber(value); return number === null ? "-" : `${number.toFixed(1)}核`; } function renderCoreMeters(cpu) { resourceCoreList.replaceChildren(); const count = Number.isInteger(Number(cpu.logical_cpu_count)) ? Math.max(0, Number(cpu.logical_cpu_count)) : 0; const values = Array.isArray(cpu.cores_percent) ? cpu.cores_percent : []; for (let index = 0; index < count; index += 1) { const value = finiteNumber(values[index]); const row = document.createElement("div"); row.className = "resource-core-row"; const label = document.createElement("span"); label.textContent = `CPU ${index + 1}`; const progress = document.createElement("progress"); progress.max = 100; progress.value = value === null ? 0 : Math.max(0, Math.min(100, value)); progress.setAttribute("aria-label", `CPU ${index + 1} 占用`); const output = document.createElement("output"); output.textContent = formatPercent(value); row.append(label, progress, output); resourceCoreList.appendChild(row); } } function updateResourceDetails(resources) { const status = resources?.status || "starting"; const cpu = resources?.cpu || {}; const memory = resources?.memory || {}; const coreCount = finiteNumber(cpu.logical_cpu_count); const coreEquivalent = finiteNumber(cpu.application_core_equivalent); resourceDetailEls.cpuAppCores.textContent = coreEquivalent === null ? "-" : `${coreEquivalent.toFixed(2)} / ${coreCount ?? "-"} 核`; resourceDetailEls.cpuSinglePercent.textContent = coreEquivalent === null ? "-" : formatPercent(coreEquivalent * 100); resourceDetailEls.cpuAppPercent.textContent = formatPercent(cpu.application_percent); resourceDetailEls.cpuTotalPercent.textContent = formatPercent(cpu.total_percent); resourceDetailEls.memoryApp.textContent = formatMemoryDetailed(memory.application_bytes); resourceDetailEls.memoryAppPercent.textContent = formatPercent(memory.application_percent); resourceDetailEls.memoryTotalPercent.textContent = formatPercent(memory.total_percent); renderCoreMeters(cpu); if (status === "error") { resourceDetailsMessage.textContent = "资源读取异常,屏幕、电压和其他控制功能不受影响。"; } else if (status !== "ok") { resourceDetailsMessage.textContent = "正在收集资源样本…"; } else if (coreEquivalent === null) { resourceDetailsMessage.textContent = "正在等待第二个 CPU 差分样本;内存数据已可用。"; } else { resourceDetailsMessage.textContent = ""; } resourceDetailsMessage.hidden = resourceDetailsMessage.textContent === ""; } function showTopbarResources(resources) { latestResources = resources || null; const status = resources?.status || "starting"; statusEls.cpu.dataset.status = status; statusEls.memory.dataset.status = status; if (status === "error") { statusEls.cpu.textContent = "CPU !"; statusEls.memory.textContent = "RAM !"; statusEls.cpu.setAttribute("aria-label", "CPU 读取异常,查看资源占用详情"); statusEls.memory.setAttribute("aria-label", "内存读取异常,查看资源占用详情"); updateResourceDetails(resources); return; } const cpu = resources?.cpu || {}; const memory = resources?.memory || {}; const cpuCompact = finiteNumber(cpu.application_core_equivalent) === null ? "CPU -" : `CPU ${formatCoreEquivalent(cpu.application_core_equivalent)} · ${formatPercent(cpu.total_percent)}`; const memoryCompact = finiteNumber(memory.application_bytes) === null ? "RAM -" : `RAM ${formatMemoryCompact(memory.application_bytes)} · ${formatPercent(memory.total_percent)}`; statusEls.cpu.textContent = cpuCompact; statusEls.memory.textContent = memoryCompact; statusEls.cpu.setAttribute("aria-label", `${cpuCompact},查看 CPU 占用详情`); statusEls.memory.setAttribute("aria-label", `${memoryCompact},查看内存占用详情`); updateResourceDetails(resources); } function openResourceDetails() { updateResourceDetails(latestResources); if (!resourceDialog.open) resourceDialog.showModal(); resourceDialogClose.focus(); } statusEls.cpu.addEventListener("click", openResourceDetails); statusEls.memory.addEventListener("click", openResourceDetails); function activeCurrentDisplayPlayback(status = latestStatus) { const content = status?.state?.current_content; const playback = status?.state?.animation_playback; if ( content?.category !== "animation" || playback?.active !== true || typeof playback.session_id !== "string" || playback.animation_id !== content.id ) return null; return playback; } function currentDisplayIdentity(status = latestStatus) { const content = status?.state?.current_content || {}; const playback = activeCurrentDisplayPlayback(status); return [ content.category || "unsaved", content.id || "", content.name || "未保存内容", playback?.session_id || "", ].join(":"); } function setCurrentDisplayPlaybackFeedback(copyId = "", defaultText = "", values = {}, state = "idle") { if (copyId) renderUiCopy(copyId, currentDisplayPlaybackFeedback, defaultText, values); else renderUiValue(currentDisplayPlaybackFeedback, ""); currentDisplayPlaybackFeedback.dataset.state = state; currentDisplayPlaybackFeedback.hidden = !defaultText; } function syncCurrentDisplayControlAvailability() { const controls = [currentDisplayPlayPause, ...currentDisplaySpeedButtons]; if (currentDisplayControlPending) { [currentDisplayProgress, ...controls].forEach((control) => setControlBusy(control)); return; } setControlReady(currentDisplayProgress); if (currentDisplayDragging) { const reason = currentDisplaySeekUnavailableReason.textContent || "请先完成当前进度跳转。"; controls.forEach((control) => setControlUnavailable(control, reason)); } else { controls.forEach((control) => setControlReady(control)); } } function renderCurrentDisplayPosition(positionMs, { force = false } = {}) { if (!currentDisplayPlayback) return; const total = Math.max(1, Number(currentDisplayPlayback.total_duration_ms) || 1); const position = Math.max(0, Math.min(total - 1, Number(positionMs) || 0)); if (force || !currentDisplayDragging) currentDisplayProgress.value = String(Math.floor(position)); currentDisplayProgressTime.textContent = `${formatPlaybackTime(position)} / ${formatPlaybackTime(total)}`; } function applyCurrentDisplayPlayback(playback, { preservePosition = currentDisplayDragging } = {}) { if (!playback?.active) return; currentDisplayPlayback = { ...playback }; currentDisplayPlaybackSampledAt = performance.now(); const total = Math.max(1, Number(playback.total_duration_ms) || 1); currentDisplayProgress.max = String(total - 1); renderUiCopy( playback.paused ? "copy.dynamic.current_display.resume" : "copy.dynamic.current_display.pause", currentDisplayPlayPause, playback.paused ? "继续播放" : "暂停", ); currentDisplayPlayPause.setAttribute("aria-pressed", String(Boolean(playback.paused))); currentDisplaySpeedButtons.forEach((button) => { button.setAttribute("aria-pressed", String(Number(button.dataset.playbackSpeed) === Number(playback.speed))); }); if (!preservePosition) renderCurrentDisplayPosition(playback.position_ms, { force: true }); syncCurrentDisplayControlAvailability(); if (currentDisplayAnimationFrame === null) { currentDisplayAnimationFrame = window.requestAnimationFrame(animateCurrentDisplayProgress); } } function animateCurrentDisplayProgress(now) { currentDisplayAnimationFrame = null; if (!currentDisplayDialog.open || currentDisplayPlaybackControls.hidden || !currentDisplayPlayback) return; if (!currentDisplayDragging) { renderCurrentDisplayPosition( projectPlaybackPosition(currentDisplayPlayback, now - currentDisplayPlaybackSampledAt), ); } currentDisplayAnimationFrame = window.requestAnimationFrame(animateCurrentDisplayProgress); } function scheduleCurrentDisplayPreview(delay = null) { if (!currentDisplayDialog.open || document.visibilityState !== "visible") return; if (currentDisplayPreviewTimer !== null) window.clearTimeout(currentDisplayPreviewTimer); const configured = Number(latestStatus?.ui?.preview_refresh_interval_ms); const wait = delay === null ? Math.max(1, Number.isFinite(configured) ? configured : 1000) : delay; currentDisplayPreviewTimer = window.setTimeout(() => { currentDisplayPreviewTimer = null; refreshCurrentDisplayDialogFrame(); }, wait); } async function refreshCurrentDisplayDialogFrame() { if (!currentDisplayDialog.open || document.visibilityState !== "visible") return; if (currentDisplayPreviewPending) { currentDisplayPreviewRequested = true; return; } currentDisplayPreviewPending = true; currentDisplayPreviewRequested = false; const sequence = currentDisplayPreviewSequence; try { const blob = await apiBlob("/api/display/current-frame"); if (!currentDisplayDialog.open || sequence !== currentDisplayPreviewSequence) return; const nextUrl = URL.createObjectURL(blob); const previousUrl = currentDisplayPreviewUrl; currentDisplayPreviewUrl = nextUrl; currentDisplayPreviewImage.src = nextUrl; currentDisplayPreviewImage.hidden = false; currentDisplayPreviewPlaceholder.hidden = true; if (previousUrl !== null) URL.revokeObjectURL(previousUrl); } catch (error) { if (!currentDisplayDialog.open || sequence !== currentDisplayPreviewSequence) return; currentDisplayPreviewImage.hidden = true; currentDisplayPreviewPlaceholder.hidden = false; renderUiCopy( "copy.dynamic.current_display.load_failed", currentDisplayPreviewPlaceholder, "当前画面读取失败:{reason}", { reason: error.message }, ); } finally { currentDisplayPreviewPending = false; if (!currentDisplayDialog.open) return; if (sequence !== currentDisplayPreviewSequence) { if (currentDisplayPreviewRequested) scheduleCurrentDisplayPreview(0); return; } if (currentDisplayPreviewRequested) scheduleCurrentDisplayPreview(0); else scheduleCurrentDisplayPreview(); } } function requestCurrentDisplayPreviewRefresh() { if (currentDisplayPreviewPending) { currentDisplayPreviewRequested = true; return; } scheduleCurrentDisplayPreview(0); } function cleanupCurrentDisplayDialog() { currentDisplayOpenIdentity = null; currentDisplayPlayback = null; currentDisplayDragging = false; currentDisplayFinalSeekPosition = null; currentDisplayControlPending = false; currentDisplayPreviewSequence += 1; currentDisplayPreviewRequested = false; seekDispatcher.clear(); if (currentDisplayPreviewTimer !== null) window.clearTimeout(currentDisplayPreviewTimer); currentDisplayPreviewTimer = null; if (currentDisplayAnimationFrame !== null) window.cancelAnimationFrame(currentDisplayAnimationFrame); currentDisplayAnimationFrame = null; if (currentDisplayPreviewUrl !== null) URL.revokeObjectURL(currentDisplayPreviewUrl); currentDisplayPreviewUrl = null; currentDisplayPreviewImage.removeAttribute("src"); currentDisplayPreviewImage.hidden = true; currentDisplayPreviewPlaceholder.hidden = false; renderUiCopy( "copy.dynamic.current_display.loading", currentDisplayPreviewPlaceholder, "正在读取当前画面…", ); setCurrentDisplayPlaybackFeedback(); } function closeCurrentDisplayDialog() { if (currentDisplayDialog.open) currentDisplayDialog.close(); else cleanupCurrentDisplayDialog(); } async function openCurrentDisplayDialog() { if (currentDisplayDialog.open) return; setControlBusy(statusEls.currentContent); try { await refreshStatus({ source: "manual" }); } catch (_error) { if (!latestStatus) return; } finally { setControlReady(statusEls.currentContent); } const content = latestStatus?.state?.current_content || { name: "未保存内容" }; const playback = activeCurrentDisplayPlayback(); currentDisplayOpenIdentity = currentDisplayIdentity(); currentDisplayDialogName.textContent = content.name || "未保存内容"; currentDisplayPlaybackControls.hidden = !playback; currentDisplayDialog.showModal(); currentDisplayPreviewSequence += 1; if (playback) applyCurrentDisplayPlayback(playback, { preservePosition: false }); refreshCurrentDisplayDialogFrame(); if (playback) currentDisplayProgress.focus(); else currentDisplayPreviewSurface.focus(); } function syncCurrentDisplayDialogStatus(status) { latestStatus = status; if (!currentDisplayDialog.open) return; const identity = currentDisplayIdentity(status); if (identity !== currentDisplayOpenIdentity) { closeCurrentDisplayDialog(); return; } const playback = activeCurrentDisplayPlayback(status); if (playback) applyCurrentDisplayPlayback(playback); } async function sendCurrentDisplayPlaybackPatch(fields) { const sessionId = currentDisplayPlayback?.session_id; if (!sessionId) throw new Error("当前动图播放会话已经结束"); const result = await apiJson("/api/display/animation-playback", { method: "PATCH", body: JSON.stringify({ session_id: sessionId, ...fields }), }); const playback = result.animation_playback; if ( currentDisplayDialog.open && currentDisplayPlayback?.session_id === sessionId && playback?.session_id === sessionId ) { if (latestStatus?.state) latestStatus.state.animation_playback = playback; if (Number.isInteger(result.state_revision) && latestStatus?.service?.instance_id) { lastStatusIdentity = `${latestStatus.service.instance_id}:${result.state_revision}`; } applyCurrentDisplayPlayback(playback); requestCurrentDisplayPreviewRefresh(); } return playback; } function handleCurrentDisplayPlaybackError(error) { const defaultText = "动图播放控制失败:{reason}"; setCurrentDisplayPlaybackFeedback( "copy.dynamic.current_display.control_failed", defaultText, { reason: error.message }, "error", ); announce(`动图播放控制失败:${error.message}`, true); if (error.status === 409) { closeCurrentDisplayDialog(); refreshStatus({ source: "manual" }).catch(() => {}); } } async function sendCurrentDisplaySeek(positionMs) { const playback = await sendCurrentDisplayPlaybackPatch({ position_ms: Math.round(positionMs) }); if (currentDisplayFinalSeekPosition === positionMs) { currentDisplayDragging = false; currentDisplayFinalSeekPosition = null; applyCurrentDisplayPlayback(playback, { preservePosition: false }); syncCurrentDisplayControlAvailability(); } } const seekDispatcher = new LatestSeekDispatcher(sendCurrentDisplaySeek, { intervalMs: 100, onError: (error, positionMs) => { if (currentDisplayFinalSeekPosition === positionMs) { currentDisplayDragging = false; currentDisplayFinalSeekPosition = null; syncCurrentDisplayControlAvailability(); } handleCurrentDisplayPlaybackError(error); }, }); statusEls.currentContent.addEventListener("click", openCurrentDisplayDialog); currentDisplayDialogClose.addEventListener("click", closeCurrentDisplayDialog); currentDisplayDialog.addEventListener("close", cleanupCurrentDisplayDialog); currentDisplayDialog.addEventListener("click", (event) => { if (currentDisplayPlaybackControls.hidden || event.target === currentDisplayDialog) { closeCurrentDisplayDialog(); } }); currentDisplayProgress.addEventListener("pointerdown", () => { currentDisplayDragging = true; currentDisplayFinalSeekPosition = null; syncCurrentDisplayControlAvailability(); }); currentDisplayProgress.addEventListener("input", () => { currentDisplayDragging = true; currentDisplayFinalSeekPosition = null; const position = Number(currentDisplayProgress.value); renderCurrentDisplayPosition(position, { force: true }); syncCurrentDisplayControlAvailability(); seekDispatcher.enqueue(position); }); function flushCurrentDisplaySeek() { if (!currentDisplayDragging) return; const position = Number(currentDisplayProgress.value); currentDisplayFinalSeekPosition = position; seekDispatcher.enqueue(position, { flush: true }); } currentDisplayProgress.addEventListener("change", flushCurrentDisplaySeek); currentDisplayProgress.addEventListener("pointerup", flushCurrentDisplaySeek); currentDisplayProgress.addEventListener("pointercancel", flushCurrentDisplaySeek); currentDisplayPlayPause.addEventListener("click", async () => { if (!currentDisplayPlayback || currentDisplayControlPending || currentDisplayDragging) return; currentDisplayControlPending = true; syncCurrentDisplayControlAvailability(); setCurrentDisplayPlaybackFeedback( currentDisplayPlayback.paused ? "copy.dynamic.current_display.resuming" : "copy.dynamic.current_display.pausing", currentDisplayPlayback.paused ? "正在继续播放…" : "正在暂停…", {}, "pending", ); try { const playback = await sendCurrentDisplayPlaybackPatch({ paused: !currentDisplayPlayback.paused }); setCurrentDisplayPlaybackFeedback( playback.paused ? "copy.dynamic.current_display.paused" : "copy.dynamic.current_display.resumed", playback.paused ? "动图已暂停" : "动图继续播放", {}, "success", ); } catch (error) { handleCurrentDisplayPlaybackError(error); } finally { currentDisplayControlPending = false; syncCurrentDisplayControlAvailability(); } }); currentDisplaySpeedButtons.forEach((button) => { button.addEventListener("click", async () => { const speed = Number(button.dataset.playbackSpeed); if ( !currentDisplayPlayback || currentDisplayControlPending || currentDisplayDragging || speed === Number(currentDisplayPlayback.speed) ) return; currentDisplayControlPending = true; syncCurrentDisplayControlAvailability(); setCurrentDisplayPlaybackFeedback( "copy.dynamic.current_display.speed_changing", "正在切换到 {speed}×…", { speed }, "pending", ); try { await sendCurrentDisplayPlaybackPatch({ speed }); setCurrentDisplayPlaybackFeedback( "copy.dynamic.current_display.speed_changed", "已切换到 {speed}×", { speed }, "success", ); } catch (error) { handleCurrentDisplayPlaybackError(error); } finally { currentDisplayControlPending = false; syncCurrentDisplayControlAvailability(); } }); }); document.addEventListener("visibilitychange", () => { if (!currentDisplayDialog.open) return; if (document.visibilityState === "visible") requestCurrentDisplayPreviewRefresh(); else if (currentDisplayPreviewTimer !== null) { window.clearTimeout(currentDisplayPreviewTimer); currentDisplayPreviewTimer = null; } }); function showTopbarVoltage(voltage) { const status = voltage?.status || "starting"; statusEls.voltage.dataset.status = status; if (status === "ok" && Number.isFinite(Number(voltage.volts))) { renderUiCopy( "copy.dynamic.device.voltage_ok", statusEls.voltage, "屏幕输入电压 {volts} V", { volts: Number(voltage.volts).toFixed(2) }, ); } else if (status === "disconnected") { renderUiCopy("copy.dynamic.device.voltage_disconnected", statusEls.voltage, "电压传感器断开"); } else if (status === "error") { renderUiCopy("copy.dynamic.device.voltage_error", statusEls.voltage, "电压读取异常"); } else { renderUiCopy("copy.dynamic.device.voltage_loading", statusEls.voltage, "正在读取电压…"); } } function showCurrentContent(state) { const name = typeof state?.current_content?.name === "string" ? state.current_content.name : "未保存内容"; renderUiCopy( "copy.dynamic.device.current_content", statusEls.currentContent, "当前画面:{name}", { name }, ); } function showTopbarLowVoltageProtection(protection) { const requestedMode = typeof protection?.mode === "string" ? protection.mode : "unavailable"; const warningMode = Object.prototype.hasOwnProperty.call(LOW_VOLTAGE_WARNING_TEXT, requestedMode); const mode = warningMode ? requestedMode : ["disabled", "unavailable", "inactive"].includes(requestedMode) ? requestedMode : "unavailable"; const previousMode = statusEls.lowVoltageWarning.dataset.mode; const visible = Object.prototype.hasOwnProperty.call(LOW_VOLTAGE_WARNING_TEXT, mode); statusEls.lowVoltageWarning.dataset.mode = mode; if (!visible) { statusEls.lowVoltageWarning.hidden = true; if (previousMode !== mode) statusEls.lowVoltageWarning.textContent = ""; return; } if (previousMode !== mode) { statusEls.lowVoltageWarning.textContent = LOW_VOLTAGE_WARNING_TEXT[mode]; } statusEls.lowVoltageWarning.hidden = false; } function showDeviceBrightness(state) { const userBrightness = finiteNumber(state?.brightness); const effectiveBrightness = finiteNumber(state?.effective_brightness); if (userBrightness === null) { statusEls.brightness.textContent = "-"; return; } if (effectiveBrightness === null) { statusEls.brightness.textContent = `${userBrightness}%`; return; } statusEls.brightness.textContent = `用户 ${userBrightness}% · 实际 ${effectiveBrightness}%`; } function formatBytes(value) { const bytes = Number(value); if (!Number.isFinite(bytes) || bytes < 0) return "-"; if (bytes < 1024) return `${bytes} B`; const units = ["KiB", "MiB", "GiB", "TiB"]; let size = bytes / 1024; let unit = units[0]; for (let index = 1; index < units.length && size >= 1024; index += 1) { size /= 1024; unit = units[index]; } return `${size >= 10 ? size.toFixed(1) : size.toFixed(2)} ${unit}`; } function normalizedHex(value) { const match = /^#([0-9A-Fa-f]{6})$/.exec(String(value || "").trim()); return match ? `#${match[1].toUpperCase()}` : null; } function showTestColor(value, { editable = false } = {}) { const color = normalizedHex(value) || customTestColor; customColor.value = color; customColor.dataset.colorReadonly = String(!editable); customColor.setAttribute("aria-label", editable ? "编辑自定义测试颜色" : "查看测试颜色"); refreshColorTrigger(customColor); } function setDisplayTestFeedback(text = "", state = "idle") { displayTestFeedback.textContent = text; displayTestFeedback.dataset.state = state; displayTestFeedback.hidden = !text; } function showTestProtection(testValue, effectiveValue) { const mode = latestProtection?.mode; if (mode !== "limiting" && mode !== "critical") { testBrightnessProtection.hidden = true; testBrightnessProtection.textContent = ""; return; } testBrightnessProtection.dataset.mode = mode; if (mode === "critical") { testBrightnessProtection.textContent = "电压过低,纯色测试暂时由 35% 亮度的低电图标覆盖;退出或恢复电压前,测试颜色和亮度会保留。"; } else { const limit = finiteNumber(latestProtection?.brightness_limit_percent); const effective = finiteNumber(effectiveValue); testBrightnessProtection.textContent = `电压过低,当前允许的最高亮度为 ${limit ?? "-"}%;测试设置 ${testValue}%,实际 ${effective ?? "-"}%。`; } testBrightnessProtection.hidden = false; } function syncDisplayTest(state, protection = latestProtection) { latestProtection = protection || null; const displayTest = state?.display_test || {}; const active = displayTest.active === true; displayTestControls.hidden = !active; if (!active) { setActiveButton(fillButtons, null); customFillButton.classList.remove("active"); customFillButton.setAttribute("aria-pressed", "false"); showTestColor(customTestColor); showTestProtection(50, state?.effective_brightness); return; } const color = normalizedHex(displayTest.color) || customTestColor; const brightness = Math.max(1, Math.min(100, Number(displayTest.brightness) || 50)); const matchingFixed = fillButtons.find((button) => button.dataset.testFill === color) || null; setActiveButton(fillButtons, matchingFixed); customFillButton.classList.toggle("active", !matchingFixed); customFillButton.setAttribute("aria-pressed", String(!matchingFixed)); testBrightness.value = String(brightness); testBrightnessValue.textContent = `${brightness}%`; showTestColor(color, { editable: !matchingFixed }); showTestProtection(brightness, state?.effective_brightness); } export async function refreshDeviceStorage() { storageEls.feedback.textContent = "正在读取设备容量…"; storageEls.feedback.dataset.state = "pending"; storageEls.feedback.hidden = false; try { const data = await apiJson("/api/device-storage"); storageEls.total.textContent = formatBytes(data.device_total_bytes); storageEls.free.textContent = formatBytes(data.device_free_bytes); storageEls.software.textContent = formatBytes(data.software_bytes); storageEls.templates.textContent = formatBytes(data.templates_bytes); storageEls.feedback.textContent = ""; storageEls.feedback.hidden = true; return data; } catch (error) { storageEls.feedback.textContent = `容量读取失败:${error.message}`; storageEls.feedback.dataset.state = "error"; announce(storageEls.feedback.textContent, true); throw error; } } export async function refreshStatus({ source = "manual" } = {}) { try { const data = await apiJson("/api/status"); syncCurrentDisplayDialogStatus(data); statusEls.orientation.textContent = `${data.state.orientation}°`; showDeviceBrightness(data.state); showCurrentContent(data.state); showTopbarResources(data.resources); showTopbarLowVoltageProtection(data.power?.low_voltage_protection); showTopbarVoltage(data.power?.screen_input_voltage); syncDisplayTest(data.state, data.power?.low_voltage_protection); const statusIdentity = `${data.service?.instance_id || "legacy"}:${data.state.revision ?? "legacy"}`; const externalChange = source === "poll" && lastStatusIdentity !== null && statusIdentity !== lastStatusIdentity; lastStatusIdentity = statusIdentity; document.dispatchEvent(new CustomEvent("matrix:status", { detail: { ...data, externalChange }, })); if (externalChange) { announce("设备状态已由其他页面更新;本页画板未被自动应用"); } return data; } catch (error) { if (source !== "poll") announce(`状态读取失败:${error.message}`, true); throw error; } } fillButtons.forEach((button) => { button.addEventListener("click", () => startDisplayTest( button, button.dataset.testFill, button.textContent.trim(), )); }); async function startDisplayTest(button, color, label, { openEditor = false } = {}) { if (testActionPending) return; testActionPending = true; [...fillButtons, customFillButton].forEach((control) => setControlBusy(control)); setDisplayTestFeedback(`正在显示${label}…`, "pending"); try { await apiJson("/api/display/test/fill", { method: "POST", body: JSON.stringify({ color }), }); showTestColor(color, { editable: openEditor }); setDisplayTestFeedback(`${label}测试已开始`, "success"); announce(`${label}测试已开始`); refreshCurrentDisplayPreview(); await refreshStatus(); if (openEditor) { showTestColor(color, { editable: true }); customColor.click(); } } catch (error) { setDisplayTestFeedback(`纯色测试失败:${error.message}`, "error"); announce(`纯色测试失败:${error.message}`, true); } finally { testActionPending = false; [...fillButtons, customFillButton].forEach((control) => setControlReady(control)); } } customFillButton.addEventListener("click", () => startDisplayTest( customFillButton, customTestColor, "自定义纯色", { openEditor: true }, )); customColor.addEventListener("change", async () => { if (customColor.dataset.colorReadonly === "true") return; const requested = normalizedHex(customColor.value); if (!requested || requested === customTestColor) return; setControlBusy(customColor, { message: "正在保存并应用自定义纯色…" }); setDisplayTestFeedback("正在保存并应用自定义纯色…", "pending"); try { const config = await apiJson("/api/config", { method: "PUT", body: JSON.stringify({ custom_test_color: requested }), }); customTestColor = normalizedHex(config.custom_test_color) || requested; await apiJson("/api/display/test/fill", { method: "POST", body: JSON.stringify({ color: customTestColor }), }); setDisplayTestFeedback(`自定义纯色 ${customTestColor} 已保存并应用`, "success"); announce(`自定义纯色 ${customTestColor} 已保存并应用`); refreshCurrentDisplayPreview(); await refreshStatus(); } catch (error) { setDisplayTestFeedback(`自定义纯色保存或应用失败:${error.message}`, "error"); announce(displayTestFeedback.textContent, true); await refreshStatus().catch(() => {}); } finally { setControlReady(customColor); } }); async function processTestBrightnessQueue() { if (applyingTestBrightness) return; applyingTestBrightness = true; while (desiredTestBrightness !== null) { const requested = desiredTestBrightness; desiredTestBrightness = null; setDisplayTestFeedback(`正在应用测试亮度 ${requested}%…`, "pending"); try { const response = await apiJson("/api/display/test/brightness", { method: "PUT", body: JSON.stringify({ brightness: requested }), }); const applied = Number(response.display_test?.brightness) || requested; testBrightness.value = String(applied); testBrightnessValue.textContent = `${applied}%`; showTestProtection(applied, response.effective_brightness); setDisplayTestFeedback(`测试亮度 ${applied}% 已生效,不会修改用户亮度`, "success"); } catch (error) { setDisplayTestFeedback(`测试亮度应用失败:${error.message}`, "error"); announce(displayTestFeedback.textContent, true); if (desiredTestBrightness === null) await refreshStatus().catch(() => {}); } } applyingTestBrightness = false; refreshCurrentDisplayPreview(); await refreshStatus().catch(() => {}); } function queueTestBrightness(value) { desiredTestBrightness = Math.max(1, Math.min(100, Number(value))); void processTestBrightnessQueue(); } testBrightness.addEventListener("input", () => { testBrightnessValue.textContent = `${testBrightness.value}%`; showTestProtection(testBrightness.value, null); queueTestBrightness(testBrightness.value); }); testBrightness.addEventListener("change", () => queueTestBrightness(testBrightness.value)); exitDisplayTest.addEventListener("click", async () => { setControlBusy(exitDisplayTest, { label: "正在退出…", message: "正在退出纯色测试…" }); setDisplayTestFeedback("正在退出纯色测试…", "pending"); try { await apiJson("/api/display/test", { method: "DELETE" }); setDisplayTestFeedback(); announce("已退出纯色测试,恢复进入测试前的设备状态"); refreshCurrentDisplayPreview(); await refreshStatus(); } catch (error) { setDisplayTestFeedback(`退出测试失败:${error.message}`, "error"); announce(displayTestFeedback.textContent, true); } finally { setControlReady(exitDisplayTest); } }); refreshStatusButton.addEventListener("click", async () => { setControlBusy(refreshStatusButton, { label: "正在刷新…" }); announce("正在刷新设备状态…"); try { await refreshStatus(); announce("设备状态已刷新"); } catch (_error) { // refreshStatus already reports the failure. } finally { setControlReady(refreshStatusButton); } }); registerWorkspace({ id: "device", title: "设备状态和测试", elementId: "workspace-device", order: 10, persistence: "none", async onEnter() { if (!loadedOnce) { loadedOnce = true; try { const config = await apiJson("/api/config"); customTestColor = normalizedHex(config.custom_test_color) || customTestColor; showTestColor(customTestColor); } catch (error) { announce(`自定义纯色读取失败:${error.message}`, true); } } await refreshStatus(); await refreshDeviceStorage().catch(() => {}); window.clearInterval(storageTimer); storageTimer = window.setInterval(() => refreshDeviceStorage().catch(() => {}), STORAGE_REFRESH_INTERVAL_MS); }, onLeave() { window.clearInterval(storageTimer); storageTimer = null; }, }); document.addEventListener("matrix:composition-applied", () => refreshStatus().catch(() => {}));