初始化奇妙小屏幕控制器项目
This commit is contained in:
@@ -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(() => {}));
|
||||
Reference in New Issue
Block a user