// Page-header controls shared by every per-frame page (Layout/ // Configuration/Stats): the frame-name pencil-edit, living outside the // tab structure since it applies regardless of which tab is open. // Depends on window.FRAME_BASE_API (a stable frame-level base set by // every page -- unlike window.FRAME_API, which the Layout page's // widget dialogs repoint to a widget-scoped base while one is open) and // common.js's showStatus/apiError. (function () { var view = document.getElementById('frame-name-view'); var editRow = document.getElementById('frame-name-edit-row'); var pencil = document.getElementById('frame-name-pencil'); var input = document.getElementById('frame-name-input'); var textEl = document.getElementById('frame-name-text'); var saveBtn = document.getElementById('frame-name-save'); var cancelBtn = document.getElementById('frame-name-cancel'); if (!view || !window.FRAME_BASE_API) return; function openEdit() { input.value = textEl.textContent.trim(); view.style.display = 'none'; editRow.style.display = 'inline-flex'; input.focus(); input.select(); } function closeEdit() { editRow.style.display = 'none'; view.style.display = 'inline-flex'; } pencil.addEventListener('click', openEdit); cancelBtn.addEventListener('click', closeEdit); async function save() { var name = input.value.trim(); if (!name || name === textEl.textContent.trim()) { closeEdit(); return; } try { const resp = await fetch(`${window.FRAME_BASE_API}/config`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ name }), }); if (!resp.ok) throw new Error(await apiError(resp)); textEl.textContent = name; closeEdit(); showStatus(true, 'Renamed.'); } catch (e) { showStatus(false, e.message); } } saveBtn.addEventListener('click', save); input.addEventListener('keydown', function (e) { if (e.key === 'Enter') save(); if (e.key === 'Escape') closeEdit(); }); })(); // Now-displaying / up-next header preview pair. "Up next" is a real // composite render (same pipeline /frame/image uses), not a cached // snapshot, so it's on a slow poll rather than something tighter like // the 10s device-status poll -- no need to hit Immich/calendar/ // whiteboard sources that often just for a header thumbnail, and it // shows layout edits live as they're made. "Now displaying" is the // opposite: exactly the bytes last actually sent to the device (see // routers/device.py's _record_last_displayed), frozen until the // device's next real wake even while the layout is being edited live -- // that contrast is the point of showing both side by side. (function () { var nextThumb = document.getElementById('frame-preview-thumb'); var nextDialog = document.getElementById('frame-preview-dialog'); var nextBigImg = document.getElementById('frame-preview-dialog-img'); var nextCloseBtn = document.getElementById('frame-preview-dialog-close'); var nowThumb = document.getElementById('frame-preview-now-thumb'); var nowDialog = document.getElementById('frame-preview-now-dialog'); var nowBigImg = document.getElementById('frame-preview-now-dialog-img'); var nowCloseBtn = document.getElementById('frame-preview-now-dialog-close'); if (!nextThumb || !window.FRAME_BASE_API) return; // Same backdrop-click-to-close trick as #widget-dialog: a click that // lands on the dialog element itself (not its content box) means the // backdrop was hit. function closeOnBackdropClick(dialog) { dialog.addEventListener('click', function (e) { if (e.target !== dialog) return; var rect = dialog.getBoundingClientRect(); var inside = e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom; if (!inside) dialog.close(); }); } function nextUrl() { return `${window.FRAME_BASE_API}/preview?t=${Date.now()}`; } function refreshNext() { nextThumb.src = nextUrl(); } // Opening the dialog (or clicking the big image inside it) fetches a // fresh render and keeps the header thumb in sync, so this single path // covers both "enlarge" and the old click-to-refresh behavior. function refreshNextBig() { var url = nextUrl(); nextBigImg.src = url; nextThumb.src = url; } nextThumb.addEventListener('click', function () { if (!nextDialog) { refreshNext(); return; } refreshNextBig(); nextDialog.showModal(); }); refreshNext(); setInterval(refreshNext, 60000); if (nextDialog && nextBigImg && nextCloseBtn) { nextBigImg.addEventListener('click', refreshNextBig); nextCloseBtn.addEventListener('click', function () { nextDialog.close(); }); closeOnBackdropClick(nextDialog); } // "Now displaying" fetches rather than sets .src directly: it needs to // tell a 404 (device hasn't fetched yet) apart from a real image to // show its own empty state instead of a broken-image icon, and reads // the capture time off X-Displayed-At for the "N ago" tooltip. if (nowThumb) { var nowObjectUrl = null; function refreshNow() { fetch(`${window.FRAME_BASE_API}/now-displaying?t=${Date.now()}`) .then(function (resp) { if (!resp.ok) { nowThumb.classList.add('frame-preview-thumb-empty'); nowThumb.removeAttribute('src'); nowThumb.title = "Now displaying -- hasn't shown anything yet"; return null; } var displayedAt = resp.headers.get('X-Displayed-At'); nowThumb.title = displayedAt ? `Now displaying -- ${formatDuration(Math.max(0, Date.now() / 1000 - parseFloat(displayedAt)))} ago -- click to enlarge` : 'Now displaying -- click to enlarge'; return resp.blob(); }) .then(function (blob) { if (!blob) return; nowThumb.classList.remove('frame-preview-thumb-empty'); var url = URL.createObjectURL(blob); var old = nowObjectUrl; nowObjectUrl = url; nowThumb.src = url; if (old) URL.revokeObjectURL(old); }) .catch(function () { /* transient failure -- leave the last-known thumb showing */ }); } nowThumb.addEventListener('click', function () { if (nowThumb.classList.contains('frame-preview-thumb-empty') || !nowDialog) return; nowBigImg.src = nowThumb.src; nowDialog.showModal(); }); refreshNow(); setInterval(refreshNow, 60000); if (nowDialog && nowCloseBtn) { nowCloseBtn.addEventListener('click', function () { nowDialog.close(); }); closeOnBackdropClick(nowDialog); } } })();