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.
This commit is contained in:
@@ -271,6 +271,25 @@ def api_frame_preview(
|
||||
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")
|
||||
def api_battery_log(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
|
||||
rows = db.execute(
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user