342 lines
9.6 KiB
JavaScript
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);
|