初始化奇妙小屏幕控制器项目

This commit is contained in:
2026-09-08 22:56:52 +08:00
commit 8d368de3b5
491 changed files with 67678 additions and 0 deletions
@@ -0,0 +1,960 @@
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(() => {}));