Files
espresso_frame/server/app/static/frame_header.js
T
tfaour aa4a382c1b
Build and push server image / test (push) Successful in 37s
Build and push server image / build-and-push (push) Successful in 2m40s
Build and push server image / deploy (push) Successful in 57s
Add "now displaying" / "up next" preview pair to the frame header
The server now records exactly what was last sent to the device on
every device-facing render (/frame/image, /frame/advance, /frame/back,
and the global hold actions), persisted as Frame.last_displayed_image/
_at and served back via GET /api/frames/{id}/now-displaying. The
header thumbnail is split into that frozen "now displaying" snapshot
and the existing live "up next" re-render, with an arrow between them
-- so editing a layout shows the change immediately on the right while
the left stays exactly what's actually on the panel until the device's
next real wake.
2026-07-28 00:41:11 +00:00

170 lines
6.6 KiB
JavaScript

// 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);
}
}
})();