975 lines
38 KiB
JavaScript
975 lines
38 KiB
JavaScript
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 = {
|
||
mobile: document.getElementById("topbar-mobile"),
|
||
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;
|
||
}
|
||
}
|
||
|
||
let lastMobileSession = null;
|
||
|
||
export async function refreshStatus({ source = "manual" } = {}) {
|
||
try {
|
||
const data = await apiJson("/api/status");
|
||
const mobile = data.mobile;
|
||
if (statusEls.mobile) {
|
||
statusEls.mobile.textContent = mobile?.connected
|
||
? `连接设备:${mobile.client_name || "手机"} · ${mobile.active_transport === "wifi" ? "WiFi" : "蓝牙"}`
|
||
: "连接设备:未连接";
|
||
const session = `${data.service?.instance_id || ""}:${mobile?.generation || 0}`;
|
||
if (mobile?.connected && lastMobileSession !== null && session !== lastMobileSession) {
|
||
announce(`${mobile.client_name || "手机"}已连接`);
|
||
}
|
||
lastMobileSession = session;
|
||
}
|
||
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(() => {}));
|