Add "now displaying" / "up next" preview pair to the frame header
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

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.
This commit is contained in:
2026-07-28 00:41:11 +00:00
parent 684225422c
commit aa4a382c1b
10 changed files with 373 additions and 70 deletions
+99 -40
View File
@@ -58,48 +58,31 @@
});
})();
// Live "how it's displaying" thumbnail. 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. Click enlarges it in a dialog (which also fetches
// a fresh render); clicking the enlarged image refreshes it again.
// 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 thumb = document.getElementById('frame-preview-thumb');
var dialog = document.getElementById('frame-preview-dialog');
var bigImg = document.getElementById('frame-preview-dialog-img');
var closeBtn = document.getElementById('frame-preview-dialog-close');
if (!thumb || !window.FRAME_BASE_API) return;
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;
function previewUrl() {
return `${window.FRAME_BASE_API}/preview?t=${Date.now()}`;
}
function refreshThumb() {
thumb.src = previewUrl();
}
// 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 refreshBig() {
var url = previewUrl();
bigImg.src = url;
thumb.src = url;
}
thumb.addEventListener('click', function () {
if (!dialog) { refreshThumb(); return; }
refreshBig();
dialog.showModal();
});
refreshThumb();
setInterval(refreshThumb, 60000);
if (dialog && bigImg && closeBtn) {
bigImg.addEventListener('click', refreshBig);
closeBtn.addEventListener('click', function () { dialog.close(); });
// 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.
// 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();
@@ -107,4 +90,80 @@
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);
}
}
})();