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.
170 lines
6.6 KiB
JavaScript
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);
|
|
}
|
|
}
|
|
})();
|