Files
matrix-screen-controller/核桃派软件源代码/app/static/views/device.js
T

961 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(() => {}));