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
+31 -15
View File
@@ -496,9 +496,16 @@ def render_frame(source: Image.Image, faces: list[dict] | None = None,
return _transpose_and_pack(quantized, orientation) return _transpose_and_pack(quantized, orientation)
def _png_bytes(img: Image.Image) -> bytes:
buf = io.BytesIO()
img.convert("RGB").save(buf, format="PNG")
return buf.getvalue()
def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], orientation: str = "landscape", def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], orientation: str = "landscape",
palette_rgb: list | None = None, color_boost: float = 1.0, contrast_boost: float = 1.0, palette_rgb: list | None = None, color_boost: float = 1.0, contrast_boost: float = 1.0,
dither_strength: float = 1.0, manage: dict | None = None, as_png: bool = False) -> bytes: dither_strength: float = 1.0, manage: dict | None = None, as_png: bool = False,
capture_snapshot: bool = False) -> bytes | tuple[bytes, bytes]:
"""The widget system's compositor -- generalizes render_frame's tail """The widget system's compositor -- generalizes render_frame's tail
(paste, enhance once, overlay once, quantize once, pack once) from (paste, enhance once, overlay once, quantize once, pack once) from
"compose one photo" to "paste N already-rendered regions, then run "compose one photo" to "paste N already-rendered regions, then run
@@ -530,7 +537,14 @@ def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], o
as_png=True returns a normal browser-viewable PNG in logical (upright) as_png=True returns a normal browser-viewable PNG in logical (upright)
orientation instead of packed native-panel bytes, same convention as orientation instead of packed native-panel bytes, same convention as
render_preview_png -- used for the web UI's live "how it's displaying" render_preview_png -- used for the web UI's live "how it's displaying"
thumbnail.""" thumbnail.
capture_snapshot=True (only meaningful alongside as_png=False) returns
(packed_bytes, png_bytes) instead of just packed_bytes -- both derived
from the same already-quantized canvas, so a device-facing render can
also persist a browser-viewable copy (see routers/device.py's
_record_last_displayed) without re-running composition/quantization a
second time."""
logical_w, logical_h = logical_render_size(orientation) logical_w, logical_h = logical_render_size(orientation)
canvas = Image.new("RGB", (logical_w, logical_h), LETTERBOX_BG) canvas = Image.new("RGB", (logical_w, logical_h), LETTERBOX_BG)
for (x, y, w, h), region_img in regions: for (x, y, w, h), region_img in regions:
@@ -540,10 +554,11 @@ def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], o
fitted = _apply_manage_overlay(fitted, manage) fitted = _apply_manage_overlay(fitted, manage)
quantized = _quantize(fitted, palette_rgb, dither_strength) quantized = _quantize(fitted, palette_rgb, dither_strength)
if as_png: if as_png:
buf = io.BytesIO() return _png_bytes(quantized)
quantized.convert("RGB").save(buf, format="PNG") packed = _transpose_and_pack(quantized, orientation)
return buf.getvalue() if capture_snapshot:
return _transpose_and_pack(quantized, orientation) return packed, _png_bytes(quantized)
return packed
def render_preview_png(source: Image.Image, faces: list[dict] | None = None, def render_preview_png(source: Image.Image, faces: list[dict] | None = None,
@@ -559,14 +574,13 @@ def render_preview_png(source: Image.Image, faces: list[dict] | None = None,
fitted = _enhance(_compose(source, faces, orientation, display_mode), color_boost, contrast_boost) fitted = _enhance(_compose(source, faces, orientation, display_mode), color_boost, contrast_boost)
fitted = _apply_manage_overlay(fitted, manage) fitted = _apply_manage_overlay(fitted, manage)
quantized = _quantize(fitted, palette_rgb, dither_strength) quantized = _quantize(fitted, palette_rgb, dither_strength)
buf = io.BytesIO() return _png_bytes(quantized)
quantized.convert("RGB").save(buf, format="PNG")
return buf.getvalue()
def render_placeholder(lines: list[str], qr_url: str | None = None, def render_placeholder(lines: list[str], qr_url: str | None = None,
orientation: str = "landscape", palette_rgb: list | None = None, orientation: str = "landscape", palette_rgb: list | None = None,
manage: dict | None = None, as_png: bool = False) -> bytes: manage: dict | None = None, as_png: bool = False,
capture_snapshot: bool = False) -> bytes | tuple[bytes, bytes]:
"""A readable full-panel message (plus an optional QR code) in the """A readable full-panel message (plus an optional QR code) in the
same packed format as render_frame -- what /frame/image serves for a same packed format as render_frame -- what /frame/image serves for a
frame that isn't claimed or configured yet, so a fresh device shows frame that isn't claimed or configured yet, so a fresh device shows
@@ -574,7 +588,8 @@ def render_placeholder(lines: list[str], qr_url: str | None = None,
`manage`, same as render_frame's -- lets the manage button still work `manage`, same as render_frame's -- lets the manage button still work
(at minimum, the scan-to-manage QR) on a frame that isn't configured (at minimum, the scan-to-manage QR) on a frame that isn't configured
yet.""" yet. `capture_snapshot`, same as render_panel's -- (packed, png)
instead of just packed."""
margin = 24 margin = 24
logical_w, logical_h = logical_render_size(orientation) logical_w, logical_h = logical_render_size(orientation)
img = Image.new("RGB", (logical_w, logical_h), (255, 255, 255)) img = Image.new("RGB", (logical_w, logical_h), (255, 255, 255))
@@ -638,7 +653,8 @@ def render_placeholder(lines: list[str], qr_url: str | None = None,
img = _apply_manage_overlay(img, manage) img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0) quantized = _quantize(img, palette_rgb, dither_strength=1.0)
if as_png: if as_png:
buf = io.BytesIO() return _png_bytes(quantized)
quantized.convert("RGB").save(buf, format="PNG") packed = _transpose_and_pack(quantized, orientation)
return buf.getvalue() if capture_snapshot:
return _transpose_and_pack(quantized, orientation) return packed, _png_bytes(quantized)
return packed
+25
View File
@@ -774,6 +774,30 @@ def _migration_29(conn) -> None:
conn.execute(text("ALTER TABLE frames ADD COLUMN last_cycled_layout_id INTEGER")) conn.execute(text("ALTER TABLE frames ADD COLUMN last_cycled_layout_id INTEGER"))
def _migration_30(conn) -> None:
""""Now displaying" (models.Frame.last_displayed_image/
last_displayed_at) -- the web UI's header preview pair needs a frozen
record of exactly what the last device-facing render actually sent,
separate from the always-live "up next" re-render (see
routers/device.py's _record_last_displayed, api_frames.py's
/now-displaying endpoint). NULL/0.0 for every existing frame until
its next real device fetch -- no behavior change to what's served,
only a new thing recorded alongside it.
Guarded per-column, same reasoning as migration 26/27/29's own
comments: frames is a table test_migrations.py's pre-widget-system
replay tests leave un-dropped, so it keeps the fresh-install
create_all() copy -- which already has these columns -- when those
tests replay migrations 17+ from schema_version 16. Without the
guard, replaying this migration there re-adds a column that's already
there and SQLite raises "duplicate column name"."""
existing = {c["name"] for c in inspect(conn).get_columns("frames")}
if "last_displayed_image" not in existing:
conn.execute(text("ALTER TABLE frames ADD COLUMN last_displayed_image BLOB"))
if "last_displayed_at" not in existing:
conn.execute(text("ALTER TABLE frames ADD COLUMN last_displayed_at REAL NOT NULL DEFAULT 0.0"))
MIGRATIONS = [ MIGRATIONS = [
(1, _migration_1), (1, _migration_1),
(2, _migration_2), (2, _migration_2),
@@ -804,6 +828,7 @@ MIGRATIONS = [
(27, _migration_27), (27, _migration_27),
(28, _migration_28), (28, _migration_28),
(29, _migration_29), (29, _migration_29),
(30, _migration_30),
] ]
+10
View File
@@ -328,6 +328,16 @@ class Frame(Base):
# starts over from the first one, same as an unset value. # starts over from the first one, same as an unset value.
last_cycled_layout_id: Mapped[int | None] = mapped_column(Integer, nullable=True) last_cycled_layout_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
# -- "now displaying" (see routers/device.py's _record_last_displayed,
# api_frames.py's /now-displaying endpoint) -- exactly what the last
# device-facing render (/frame/image, /frame/advance, /frame/back, or
# a global hold action) actually sent, as an upright PNG, so the web
# UI's header preview can show it frozen alongside a live "up next"
# re-render instead of conflating the two. NULL until a real device
# has fetched at least once.
last_displayed_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True, default=None)
last_displayed_at: Mapped[float] = mapped_column(Float, default=0.0)
# -- stats (flattened from the old nested FrameStats) -- # -- stats (flattened from the old nested FrameStats) --
stats_first_seen: Mapped[float] = mapped_column(Float, default=0.0) stats_first_seen: Mapped[float] = mapped_column(Float, default=0.0)
stats_device_wakes: Mapped[int] = mapped_column(Integer, default=0) stats_device_wakes: Mapped[int] = mapped_column(Integer, default=0)
+19
View File
@@ -271,6 +271,25 @@ def api_frame_preview(
return Response(content=png, media_type="image/png") return Response(content=png, media_type="image/png")
@router.get("/api/frames/{frame_id}/now-displaying")
def api_frame_now_displaying(frame: Frame = Depends(require_frame_view)):
"""Exactly what was last actually sent to this frame's device (see
routers/device.py's _record_last_displayed) -- the frozen "now
displaying" half of the header preview pair, as opposed to /preview's
always-live "up next" re-render. 404 (not a placeholder image) until
the device has fetched at least once, so the web UI can show its own
empty state instead of a broken image. X-Displayed-At carries the
capture time (unix seconds) for a "N ago" label -- a header, not the
body, since the body is the raw PNG bytes."""
if frame.last_displayed_image is None:
raise HTTPException(404, "This frame hasn't displayed anything yet")
return Response(
content=frame.last_displayed_image,
media_type="image/png",
headers={"X-Displayed-At": str(frame.last_displayed_at)},
)
@router.get("/api/frames/{frame_id}/battery-log") @router.get("/api/frames/{frame_id}/battery-log")
def api_battery_log(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)): def api_battery_log(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
rows = db.execute( rows = db.execute(
+45 -12
View File
@@ -43,7 +43,7 @@ router = APIRouter()
def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = None, def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = None,
as_png: bool = False) -> bytes: as_png: bool = False, capture_snapshot: bool = False) -> bytes | tuple[bytes, bytes]:
"""What an unclaimed or widget-less frame displays instead of real """What an unclaimed or widget-less frame displays instead of real
content -- instructions with a QR, rendered at 200 so the device content -- instructions with a QR, rendered at 200 so the device
treats it as a perfectly normal image and never error-loops. The treats it as a perfectly normal image and never error-loops. The
@@ -60,6 +60,7 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
palette_rgb=frame.palette_rgb, palette_rgb=frame.palette_rgb,
manage=manage, manage=manage,
as_png=as_png, as_png=as_png,
capture_snapshot=capture_snapshot,
) )
if frame.owner_user_id is None: if frame.owner_user_id is None:
return render_placeholder( return render_placeholder(
@@ -68,6 +69,7 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
palette_rgb=frame.palette_rgb, palette_rgb=frame.palette_rgb,
manage=manage, manage=manage,
as_png=as_png, as_png=as_png,
capture_snapshot=capture_snapshot,
) )
return render_placeholder( return render_placeholder(
["Almost there!", "Add a widget for this frame at", base], ["Almost there!", "Add a widget for this frame at", base],
@@ -76,11 +78,12 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
palette_rgb=frame.palette_rgb, palette_rgb=frame.palette_rgb,
manage=manage, manage=manage,
as_png=as_png, as_png=as_png,
capture_snapshot=capture_snapshot,
) )
def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wake: bool, def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wake: bool,
as_png: bool = False) -> bytes: as_png: bool = False, capture_snapshot: bool = False) -> bytes | tuple[bytes, bytes]:
"""The widget-system compositor: renders every widget on this frame """The widget-system compositor: renders every widget on this frame
into its own region (see app/grid.py for grid-cell -> pixel math), into its own region (see app/grid.py for grid-cell -> pixel math),
draws that widget's own optional border directly onto its region draws that widget's own optional border directly onto its region
@@ -112,11 +115,13 @@ def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wa
regions, orientation=frame.orientation, palette_rgb=frame.palette_rgb, regions, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
color_boost=frame.color_boost, contrast_boost=frame.contrast_boost, color_boost=frame.color_boost, contrast_boost=frame.contrast_boost,
dither_strength=frame.dither_strength, manage=manage, as_png=as_png, dither_strength=frame.dither_strength, manage=manage, as_png=as_png,
capture_snapshot=capture_snapshot,
) )
def _render_frame_content(db: Session, frame: Frame, request: Request | None, manage: dict | None, def _render_frame_content(db: Session, frame: Frame, request: Request | None, manage: dict | None,
is_normal_wake: bool, as_png: bool = False) -> bytes: is_normal_wake: bool, as_png: bool = False,
capture_snapshot: bool = False) -> bytes | tuple[bytes, bytes]:
"""The top-level "what does this frame show right now" entry point. """The top-level "what does this frame show right now" entry point.
An unclaimed frame or one with no widgets yet gets the setup An unclaimed frame or one with no widgets yet gets the setup
placeholder (needs `request` for its QR URLs -- only available on the placeholder (needs `request` for its QR URLs -- only available on the
@@ -134,11 +139,11 @@ def _render_frame_content(db: Session, frame: Frame, request: Request | None, ma
if request is None: if request is None:
return render_placeholder( return render_placeholder(
["Almost there!"], orientation=frame.orientation, palette_rgb=frame.palette_rgb, ["Almost there!"], orientation=frame.orientation, palette_rgb=frame.palette_rgb,
manage=manage, as_png=as_png, manage=manage, as_png=as_png, capture_snapshot=capture_snapshot,
) )
return _setup_placeholder(frame, request, manage=manage, as_png=as_png) return _setup_placeholder(frame, request, manage=manage, as_png=as_png, capture_snapshot=capture_snapshot)
return _render_widgets(db, frame, manage, is_normal_wake, as_png=as_png) return _render_widgets(db, frame, manage, is_normal_wake, as_png=as_png, capture_snapshot=capture_snapshot)
def render_frame_preview_png(db: Session, frame: Frame, request: Request) -> bytes: def render_frame_preview_png(db: Session, frame: Frame, request: Request) -> bytes:
@@ -245,6 +250,17 @@ def _manage_flag(request: Request) -> bool:
return request.query_params.get("manage") == "1" return request.query_params.get("manage") == "1"
def _record_last_displayed(db: Session, frame: Frame, png_snapshot: bytes) -> None:
"""Persists exactly what a device-facing render just sent (upright
PNG, manage overlay included if present -- whatever's actually on the
panel) as this frame's "now displaying" snapshot, the frozen half of
the web UI's header preview pair (see api_frames.py's /now-displaying
endpoint and its always-live "up next" counterpart, /preview)."""
with frame_locked(db, frame.id) as locked:
locked.last_displayed_image = png_snapshot
locked.last_displayed_at = time.time()
@router.get("/frame/image") @router.get("/frame/image")
def frame_image( def frame_image(
request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db) request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)
@@ -262,9 +278,14 @@ def frame_image(
?manage=1 (the manage button) composites the manage overlay onto ?manage=1 (the manage button) composites the manage overlay onto
whatever this would have returned anyway -- see build_manage_content. whatever this would have returned anyway -- see build_manage_content.
This is also the "normal wake" that resets any calendar widget's This is also the "normal wake" that resets any calendar widget's
browse position back to today (see app/widgets/calendar.py).""" browse position back to today (see app/widgets/calendar.py).
Also records what's returned as this frame's "now displaying"
snapshot (see _record_last_displayed) -- every other device-facing
render endpoint below does the same."""
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
content = _render_frame_content(db, frame, request, manage, is_normal_wake=True) content, snapshot = _render_frame_content(db, frame, request, manage, is_normal_wake=True, capture_snapshot=True)
_record_last_displayed(db, frame, snapshot)
return Response(content=content, media_type="application/octet-stream") return Response(content=content, media_type="application/octet-stream")
@@ -277,7 +298,10 @@ def frame_advance(request: Request, frame: Frame = Depends(require_device), db:
device's next-photo button.""" device's next-photo button."""
_run_button_actions(db, frame, "next") _run_button_actions(db, frame, "next")
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False) content, snapshot = _render_frame_content(
db, frame, request=None, manage=manage, is_normal_wake=False, capture_snapshot=True
)
_record_last_displayed(db, frame, snapshot)
return Response(content=content, media_type="application/octet-stream") return Response(content=content, media_type="application/octet-stream")
@@ -288,7 +312,10 @@ def frame_back(request: Request, frame: Frame = Depends(require_device), db: Ses
with nothing to go back to. Used by the device's back-photo button.""" with nothing to go back to. Used by the device's back-photo button."""
_run_button_actions(db, frame, "back") _run_button_actions(db, frame, "back")
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False) content, snapshot = _render_frame_content(
db, frame, request=None, manage=manage, is_normal_wake=False, capture_snapshot=True
)
_record_last_displayed(db, frame, snapshot)
return Response(content=content, media_type="application/octet-stream") return Response(content=content, media_type="application/octet-stream")
@@ -303,7 +330,10 @@ def frame_global_next(request: Request, frame: Frame = Depends(require_device),
firmware/main/next_button.c for the short/long split.""" firmware/main/next_button.c for the short/long split."""
_run_global_action(db, frame, "next") _run_global_action(db, frame, "next")
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False) content, snapshot = _render_frame_content(
db, frame, request=None, manage=manage, is_normal_wake=False, capture_snapshot=True
)
_record_last_displayed(db, frame, snapshot)
return Response(content=content, media_type="application/octet-stream") return Response(content=content, media_type="application/octet-stream")
@@ -312,7 +342,10 @@ def frame_global_back(request: Request, frame: Frame = Depends(require_device),
"""The mirror of /frame/global-next, for a held BACK button.""" """The mirror of /frame/global-next, for a held BACK button."""
_run_global_action(db, frame, "back") _run_global_action(db, frame, "back")
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False) content, snapshot = _render_frame_content(
db, frame, request=None, manage=manage, is_normal_wake=False, capture_snapshot=True
)
_record_last_displayed(db, frame, snapshot)
return Response(content=content, media_type="application/octet-stream") return Response(content=content, media_type="application/octet-stream")
+99 -40
View File
@@ -58,48 +58,31 @@
}); });
})(); })();
// Live "how it's displaying" thumbnail. A real composite render (same // Now-displaying / up-next header preview pair. "Up next" is a real
// pipeline /frame/image uses), not a cached snapshot, so it's on a slow // composite render (same pipeline /frame/image uses), not a cached
// poll rather than something tighter like the 10s device-status poll -- // snapshot, so it's on a slow poll rather than something tighter like
// no need to hit Immich/calendar/whiteboard sources that often just for // the 10s device-status poll -- no need to hit Immich/calendar/
// a header thumbnail. Click enlarges it in a dialog (which also fetches // whiteboard sources that often just for a header thumbnail, and it
// a fresh render); clicking the enlarged image refreshes it again. // 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 () { (function () {
var thumb = document.getElementById('frame-preview-thumb'); var nextThumb = document.getElementById('frame-preview-thumb');
var dialog = document.getElementById('frame-preview-dialog'); var nextDialog = document.getElementById('frame-preview-dialog');
var bigImg = document.getElementById('frame-preview-dialog-img'); var nextBigImg = document.getElementById('frame-preview-dialog-img');
var closeBtn = document.getElementById('frame-preview-dialog-close'); var nextCloseBtn = document.getElementById('frame-preview-dialog-close');
if (!thumb || !window.FRAME_BASE_API) return; 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() { // Same backdrop-click-to-close trick as #widget-dialog: a click that
return `${window.FRAME_BASE_API}/preview?t=${Date.now()}`; // lands on the dialog element itself (not its content box) means the
} // backdrop was hit.
function refreshThumb() { function closeOnBackdropClick(dialog) {
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.
dialog.addEventListener('click', function (e) { dialog.addEventListener('click', function (e) {
if (e.target !== dialog) return; if (e.target !== dialog) return;
var rect = dialog.getBoundingClientRect(); var rect = dialog.getBoundingClientRect();
@@ -107,4 +90,80 @@
if (!inside) dialog.close(); 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);
}
}
})(); })();
+19 -1
View File
@@ -719,13 +719,24 @@ code {
} }
.frame-name-edit button { margin-top: 0; } .frame-name-edit button { margin-top: 0; }
.frame-preview-pair {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: 12px;
vertical-align: middle;
}
.frame-preview-arrow {
color: var(--text-muted);
font-size: 16px;
line-height: 1;
}
.frame-preview-thumb { .frame-preview-thumb {
height: 44px; height: 44px;
width: auto; width: auto;
max-width: 130px; max-width: 130px;
object-fit: contain; object-fit: contain;
vertical-align: middle; vertical-align: middle;
margin-left: 12px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 4px; border-radius: 4px;
background: var(--surface-alt); background: var(--surface-alt);
@@ -733,6 +744,13 @@ code {
transition: opacity .12s ease; transition: opacity .12s ease;
} }
.frame-preview-thumb:hover { opacity: 0.8; } .frame-preview-thumb:hover { opacity: 0.8; }
.frame-preview-thumb-empty {
opacity: 0.3;
cursor: default;
width: 60px;
font-size: 0; /* no src yet -- suppresses the browser's fallback alt-text render */
}
.frame-preview-thumb-empty:hover { opacity: 0.3; }
.frame-preview-dialog { .frame-preview-dialog {
position: fixed; position: fixed;
+11 -2
View File
@@ -7,10 +7,19 @@
<button type="button" class="btn-inline" id="frame-name-save">Save</button> <button type="button" class="btn-inline" id="frame-name-save">Save</button>
<button type="button" class="btn-inline secondary" id="frame-name-cancel">Cancel</button> <button type="button" class="btn-inline secondary" id="frame-name-cancel">Cancel</button>
</span> </span>
<img id="frame-preview-thumb" class="frame-preview-thumb" alt="Live preview of what the frame is displaying" title="What the frame is currently displaying -- click to enlarge"> <span class="frame-preview-pair">
<img id="frame-preview-now-thumb" class="frame-preview-thumb frame-preview-thumb-empty" alt="What the frame is currently displaying" title="Now displaying">
<span class="frame-preview-arrow" aria-hidden="true">&rarr;</span>
<img id="frame-preview-thumb" class="frame-preview-thumb" alt="Live preview of what the frame will show next" title="Up next -- live preview, updates as you edit the layout -- click to enlarge">
</span>
<dialog id="frame-preview-now-dialog" class="frame-preview-dialog">
<button type="button" id="frame-preview-now-dialog-close" class="icon-btn" aria-label="Close" title="Close">&times;</button>
<img id="frame-preview-now-dialog-img" alt="What the frame is currently displaying">
</dialog>
<dialog id="frame-preview-dialog" class="frame-preview-dialog"> <dialog id="frame-preview-dialog" class="frame-preview-dialog">
<button type="button" id="frame-preview-dialog-close" class="icon-btn" aria-label="Close" title="Close">&times;</button> <button type="button" id="frame-preview-dialog-close" class="icon-btn" aria-label="Close" title="Close">&times;</button>
<img id="frame-preview-dialog-img" alt="Live preview of what the frame is displaying" title="Click to refresh"> <img id="frame-preview-dialog-img" alt="Live preview of what the frame will show next" title="Click to refresh">
</dialog> </dialog>
+20
View File
@@ -92,6 +92,7 @@ def test_expected_columns_exist_on_current_schema():
button_action_indexes = {idx["name"] for idx in inspector.get_indexes("frame_button_actions")} button_action_indexes = {idx["name"] for idx in inspector.get_indexes("frame_button_actions")}
assert "ix_frame_button_actions_widget_button" in button_action_indexes # migration 28 assert "ix_frame_button_actions_widget_button" in button_action_indexes # migration 28
assert {"hold_duration_ms", "next_hold_action", "back_hold_action", "last_cycled_layout_id"} <= frame_columns assert {"hold_duration_ms", "next_hold_action", "back_hold_action", "last_cycled_layout_id"} <= frame_columns
assert {"last_displayed_image", "last_displayed_at"} <= frame_columns # migration 30
# --- widget system backfill (migration 16 + _ensure_widgets_backfilled) --- # --- widget system backfill (migration 16 + _ensure_widgets_backfilled) ---
@@ -331,6 +332,25 @@ def test_migration_29_adds_hold_action_columns_to_an_existing_database(db_sessio
assert frame.last_cycled_layout_id is None assert frame.last_cycled_layout_id is None
def test_migration_30_adds_now_displaying_columns_to_an_existing_database(db_session):
"""Exercises _migration_30's real guarded ALTER path (frames isn't
dropped/recreated by the pre-widget-system replay tests, so its
columns must be added defensively, same reasoning as migration
26/27/29's own comments)."""
with db_module.engine.begin() as conn:
conn.execute(text("UPDATE schema_version SET version = 29"))
run_migrations()
with db_module.engine.connect() as conn:
version = conn.execute(text("SELECT version FROM schema_version")).scalar()
assert version == MIGRATIONS[-1][0]
frame = db_session.get(Frame, 1)
assert frame.last_displayed_image is None
assert frame.last_displayed_at == 0.0
def test_migration_17_and_18_extract_tasks_into_a_standalone_multi_list_widget(db_session): def test_migration_17_and_18_extract_tasks_into_a_standalone_multi_list_widget(db_session):
"""Exercises _migration_17 and _migration_18's actual data-extraction """Exercises _migration_17 and _migration_18's actual data-extraction
SQL back to back (the real "existing widget-system database SQL back to back (the real "existing widget-system database
+94
View File
@@ -0,0 +1,94 @@
"""GET /api/frames/{id}/now-displaying -- the frozen half of the header
preview pair (see routers/device.py's _record_last_displayed). Distinct
from /preview (test_frame_preview.py): that one always live-renders,
this one serves back exactly whatever bytes a device-facing endpoint
last actually sent, recorded as a side effect of /frame/image,
/frame/advance, and /frame/back."""
from __future__ import annotations
import io
from PIL import Image
from app.image_pipeline import logical_render_size
from app.models import Frame
from .conftest import link_user, login, make_user
def test_now_displaying_404s_before_any_device_fetch(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.get("/api/frames/1/now-displaying")
assert resp.status_code == 404
def test_frame_image_records_now_displaying(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
resp = client.get("/frame/image")
assert resp.status_code == 200
resp = client.get("/api/frames/1/now-displaying")
assert resp.status_code == 200
assert resp.headers["content-type"] == "image/png"
assert "X-Displayed-At" in resp.headers
assert float(resp.headers["X-Displayed-At"]) > 0
img = Image.open(io.BytesIO(resp.content))
assert img.size == logical_render_size(frame.orientation)
def test_advance_and_back_also_update_now_displaying(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
db_session.get(Frame, 1)
client.get("/frame/image")
first = client.get("/api/frames/1/now-displaying")
assert first.status_code == 200
resp = client.post("/frame/advance")
assert resp.status_code == 200
after_advance = client.get("/api/frames/1/now-displaying")
assert after_advance.status_code == 200
resp = client.post("/frame/back")
assert resp.status_code == 200
after_back = client.get("/api/frames/1/now-displaying")
assert after_back.status_code == 200
assert float(after_back.headers["X-Displayed-At"]) >= float(first.headers["X-Displayed-At"])
def test_now_displaying_visible_to_linked_user(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
bob = make_user(db_session, "bob")
frame = db_session.get(Frame, 1)
link_user(db_session, bob, frame)
client.get("/frame/image")
client.cookies.clear()
login(client, "bob")
resp = client.get("/api/frames/1/now-displaying")
assert resp.status_code == 200
def test_now_displaying_hidden_from_unrelated_user(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
make_user(db_session, "mallory")
client.get("/frame/image")
client.cookies.clear()
login(client, "mallory")
resp = client.get("/api/frames/1/now-displaying")
assert resp.status_code == 404
def test_now_displaying_requires_login(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
client.get("/frame/image")
client.cookies.clear()
resp = client.get("/api/frames/1/now-displaying")
assert resp.status_code in (401, 403)