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
+45 -12
View File
@@ -43,7 +43,7 @@ router = APIRouter()
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
content -- instructions with a QR, rendered at 200 so the device
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,
manage=manage,
as_png=as_png,
capture_snapshot=capture_snapshot,
)
if frame.owner_user_id is None:
return render_placeholder(
@@ -68,6 +69,7 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
palette_rgb=frame.palette_rgb,
manage=manage,
as_png=as_png,
capture_snapshot=capture_snapshot,
)
return render_placeholder(
["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,
manage=manage,
as_png=as_png,
capture_snapshot=capture_snapshot,
)
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
into its own region (see app/grid.py for grid-cell -> pixel math),
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,
color_boost=frame.color_boost, contrast_boost=frame.contrast_boost,
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,
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.
An unclaimed frame or one with no widgets yet gets the setup
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:
return render_placeholder(
["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:
@@ -245,6 +250,17 @@ def _manage_flag(request: Request) -> bool:
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")
def frame_image(
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
whatever this would have returned anyway -- see build_manage_content.
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
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")
@@ -277,7 +298,10 @@ def frame_advance(request: Request, frame: Frame = Depends(require_device), db:
device's next-photo button."""
_run_button_actions(db, frame, "next")
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")
@@ -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."""
_run_button_actions(db, frame, "back")
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")
@@ -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."""
_run_global_action(db, frame, "next")
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")
@@ -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."""
_run_global_action(db, frame, "back")
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")