Files

342 lines
9.6 KiB
JavaScript

const SNAPSHOT_REFRESH_DELAY_MS = 5000;
function formatRemaining(totalSeconds) {
const seconds = Math.max(0, Math.ceil(totalSeconds));
const minutes = Math.floor(seconds / 60);
const rest = seconds % 60;
return `${minutes}:${String(rest).padStart(2, "0")}`;
}
function updateTimers() {
const now = Date.now() / 1000;
document.querySelectorAll("[data-auto-off-at]").forEach((item) => {
const target = Number(item.dataset.autoOffAt || 0);
if (!target) {
return;
}
const remaining = target - now;
if (remaining <= 0) {
item.textContent = item.dataset.expiredLabel || "Automatic turn-off pending";
item.classList.add("timer-expired");
return;
}
item.textContent = `${item.dataset.prefix || "Automatic turn-off in"} ${formatRemaining(remaining)}`;
});
}
function refreshStatusElement() {
return document.querySelector("[data-refresh-status]");
}
function showRefreshMessage(message, type = "success") {
const status = refreshStatusElement();
if (!status) {
return;
}
status.textContent = message || "";
status.classList.toggle("is-visible", Boolean(message));
status.classList.toggle("is-success", type === "success");
status.classList.toggle("is-error", type === "error");
}
function rememberRefreshMessage(message) {
try {
window.sessionStorage.setItem("frigateRefreshMessage", message);
} catch (_error) {
// Some browsers may block sessionStorage; the refresh still succeeds.
}
}
function restoreRefreshMessage() {
try {
const message = window.sessionStorage.getItem("frigateRefreshMessage");
if (!message) {
return;
}
window.sessionStorage.removeItem("frigateRefreshMessage");
showRefreshMessage(message, "success");
} catch (_error) {
// Ignore storage errors and keep the GUI usable.
}
}
async function handleRefreshSubmit(form, event) {
event.preventDefault();
const button = form.querySelector(".refresh-button");
if (button && button.classList.contains("is-refreshing")) {
return;
}
const status = refreshStatusElement();
const workingLabel = status?.dataset.workingLabel || "Refreshing cameras...";
showRefreshMessage(workingLabel, "success");
if (button) {
button.disabled = true;
button.classList.add("is-refreshing");
button.setAttribute("aria-busy", "true");
}
try {
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
});
const payload = await response.json();
if (!response.ok || payload.ok === false) {
throw new Error(payload.message || "Refresh failed");
}
showRefreshMessage(payload.message, "success");
if (payload.changed && payload.redirect_url) {
rememberRefreshMessage(payload.message);
window.setTimeout(() => {
window.location.assign(payload.redirect_url);
}, 900);
return;
}
} catch (error) {
showRefreshMessage(error.message || "Refresh failed", "error");
} finally {
if (button) {
button.disabled = false;
button.classList.remove("is-refreshing");
button.removeAttribute("aria-busy");
}
}
}
function refreshSnapshot(button) {
if (button.classList.contains("is-refreshing")) {
return;
}
const media = button.closest(".media");
const image = media?.querySelector("img.snapshot");
if (!image) {
return;
}
const url = new URL(image.currentSrc || image.src, window.location.href);
url.searchParams.set("refresh", "1");
url.searchParams.set("v", String(Date.now()));
const cleanup = () => {
button.disabled = false;
button.classList.remove("is-refreshing");
button.removeAttribute("aria-busy");
};
button.disabled = true;
button.classList.add("is-refreshing");
button.setAttribute("aria-busy", "true");
image.addEventListener("load", cleanup, { once: true });
image.addEventListener("error", cleanup, { once: true });
window.setTimeout(() => {
image.src = url.toString();
}, SNAPSHOT_REFRESH_DELAY_MS);
}
function actionContainerById(actionId) {
for (const container of document.querySelectorAll("[data-action-id]")) {
if (container.dataset.actionId === actionId) {
return container;
}
}
return null;
}
function actionStatusForForm(form) {
return form.closest(".action-control, .global-action")?.querySelector("[data-action-status]");
}
function showActionMessage(form, message, type = "success") {
const status = actionStatusForForm(form);
if (!status) {
return;
}
status.textContent = message || "";
status.classList.toggle("is-visible", Boolean(message));
status.classList.toggle("is-success", type === "success");
status.classList.toggle("is-error", type === "error");
}
function setCommandBusy(button, busy) {
if (!button) {
return;
}
if (busy) {
button.disabled = true;
button.classList.add("is-working");
button.setAttribute("aria-busy", "true");
return;
}
if (button.dataset.timerLocked !== "true") {
button.disabled = false;
}
button.classList.remove("is-working");
button.removeAttribute("aria-busy");
}
function labelFromStateChip(chip, state) {
const key = `label${state.charAt(0).toUpperCase()}${state.slice(1)}`;
return chip.dataset[key] || state.toUpperCase();
}
function updateStateChip(container, action) {
const chip = container.querySelector("[data-state-chip]");
if (!chip || !action.state) {
return;
}
const state = String(action.state).toLowerCase();
for (const className of Array.from(chip.classList)) {
if (className.startsWith("chip-state-")) {
chip.classList.remove(className);
}
}
chip.classList.add(`chip-state-${state}`);
chip.textContent = action.state_label || labelFromStateChip(chip, state);
}
function updateTimer(container, action) {
const timer = container.querySelector("[data-timer]");
if (!timer) {
return;
}
if (action.auto_off_at) {
timer.dataset.autoOffAt = String(action.auto_off_at);
timer.classList.remove("timer-idle", "timer-expired");
updateTimers();
return;
}
delete timer.dataset.autoOffAt;
timer.classList.add("timer-idle");
timer.classList.remove("timer-expired");
timer.textContent = timer.dataset.idleLabel || "";
}
function updateOnButtonLock(container, action) {
const button = container.querySelector(".cmd-on");
if (!button) {
return;
}
if (action.auto_off_at) {
const label = button.dataset.disabledLabel || "Already active until automatic turn-off";
button.dataset.timerLocked = "true";
button.disabled = true;
button.title = label;
button.setAttribute("aria-label", label);
return;
}
delete button.dataset.timerLocked;
if (!button.classList.contains("is-working")) {
button.disabled = false;
}
button.removeAttribute("title");
button.removeAttribute("aria-label");
}
function applyActionState(action) {
if (!action?.id) {
return;
}
const container = actionContainerById(action.id);
if (!container) {
return;
}
updateStateChip(container, action);
updateTimer(container, action);
updateOnButtonLock(container, action);
}
function applyActionPayload(payload) {
if (Array.isArray(payload.actions)) {
payload.actions.forEach(applyActionState);
return;
}
applyActionState(payload.action);
}
function refreshSnapshotForCommand(form) {
const button = form.closest(".camera-card")?.querySelector(".snapshot-refresh");
if (!button || button.classList.contains("is-refreshing")) {
return;
}
refreshSnapshot(button);
}
async function responseJson(response) {
const text = await response.text();
if (!text) {
return {};
}
try {
return JSON.parse(text);
} catch (_error) {
return { ok: false, message: text };
}
}
function commandErrorMessage(form, error) {
const fallback = form.dataset.failedLabel || "Command failed.";
if (!error) {
return fallback;
}
if (error.name === "TypeError") {
return form.dataset.networkFailedLabel || fallback;
}
return error.message || fallback;
}
async function handleCommandSubmit(form, event) {
event.preventDefault();
if (form.dataset.requiresAck === "true") {
const message = form.dataset.ackMessage || "Confirm this action";
if (!window.confirm(message)) {
return;
}
}
const button = form.querySelector(".cmd");
if (button?.classList.contains("is-working")) {
return;
}
showActionMessage(form, form.dataset.workingLabel || "Sending command...", "success");
setCommandBusy(button, true);
try {
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
});
const payload = await responseJson(response);
applyActionPayload(payload);
if (!response.ok || payload.ok === false) {
throw new Error(payload.message || form.dataset.failedLabel || "Command failed.");
}
showActionMessage(form, payload.message || form.dataset.successLabel || "Command sent.", "success");
refreshSnapshotForCommand(form);
} catch (error) {
showActionMessage(form, commandErrorMessage(form, error), "error");
} finally {
setCommandBusy(button, false);
}
}
document.addEventListener("click", (event) => {
const button = event.target.closest(".snapshot-refresh");
if (!(button instanceof HTMLButtonElement)) {
return;
}
refreshSnapshot(button);
});
document.addEventListener("submit", (event) => {
const form = event.target;
if (!(form instanceof HTMLFormElement)) {
return;
}
if (form.classList.contains("refresh-form")) {
handleRefreshSubmit(form, event);
return;
}
if (form.classList.contains("command-form")) {
handleCommandSubmit(form, event);
return;
}
});
updateTimers();
restoreRefreshMessage();
setInterval(updateTimers, 1000);