Build and push server image / build-and-push (push) Successful in 32s
Remove from rotation: a new bounded exclude list (FrameConfig.excluded_asset_ids) that photo_queue._top_up() never selects from. POST /api/queue/remove scrubs an asset out of queue/history too so it can't resurface via "Show next" or the back button, and if it was the current photo, advances away from it immediately -- without recording it in history, since going back to a photo you just explicitly removed doesn't make sense. Doesn't touch Immich or the album itself, just this frame's own selection. Wired into the web UI as a small "x" button on both the current-photo thumbnail and every upcoming card. Mobile scroll fix: touching a card to scroll the page was being captured as a drag attempt every time (touch-action: none on every .photo-card, needed for the existing drag-reorder gesture to work at all), making it too easy to accidentally reorder instead of scroll. Reworked touch dragging to require a brief hold (350ms, roughly stationary) before it arms -- touch-action stays "pan-y" (native scroll allowed) the whole time up to that point, so a normal touch-and-swipe scrolls the page like anywhere else, and only switches to "none" once a hold is confirmed as deliberate. Mouse dragging is unchanged (no hold delay -- no scroll-vs-drag ambiguity with a mouse). Also made the "Show next" and new remove buttons always visible instead of hover/focus-revealed, since that was invisible-but-still-tappable on touch (no hover state) -- a real hazard for a destructive action.
198 lines
8.4 KiB
Python
198 lines
8.4 KiB
Python
"""Tracks which photo is currently displayed, what's queued up next, and
|
|
what's already been shown.
|
|
|
|
`current_asset_id` only ever changes two ways: the configured refresh
|
|
interval elapsing (`get_current`, called on every `GET /frame/image` --
|
|
a no-op otherwise, so an unplanned device reboot just redisplays the same
|
|
photo instead of silently skipping ahead) or an explicit forced advance
|
|
(`advance_forced`, called from `POST /frame/advance` -- the next-photo
|
|
button -- ignoring elapsed time).
|
|
|
|
`queue` is a small reorderable lookahead the web UI can preview and
|
|
rearrange, topped up (or trimmed) automatically to match
|
|
`cfg.queue_target_len` (user-configurable from the web UI) as it's
|
|
consumed. `queue_cursor` is separate, internal-only bookkeeping for where
|
|
sequential top-up resumes in the album -- not shown or reordered in the UI.
|
|
|
|
`history` is the mirror image of `queue`: every time `advance_forced`
|
|
actually changes `current_asset_id`, the old one is pushed onto
|
|
`history`. `back_forced` (the back-photo button) is the exact reverse of
|
|
`advance_forced` -- it pops `history` back into `current_asset_id` and
|
|
pushes the photo it's replacing onto the *front* of `queue`, so pressing
|
|
next afterwards lands you right back where you were.
|
|
|
|
`excluded_asset_ids` is a permanent (until explicitly un-excluded, which
|
|
there's no UI for yet) block list -- `_top_up()` never selects an
|
|
excluded photo, and `remove_from_rotation()` scrubs one out of
|
|
`queue`/`history` too, so it can't resurface via "Show next" or the back
|
|
button either. This doesn't touch Immich at all -- the photo stays in
|
|
the album, it's just never chosen by this frame again.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import random
|
|
import time
|
|
|
|
from .config import FrameConfig
|
|
|
|
HISTORY_MAX_LEN = 20
|
|
|
|
|
|
def _top_up(cfg: FrameConfig, assets: list[dict]) -> None:
|
|
valid_ids = {a["id"] for a in assets}
|
|
excluded_ids = set(cfg.excluded_asset_ids)
|
|
cfg.queue = [asset_id for asset_id in cfg.queue if asset_id in valid_ids and asset_id not in excluded_ids]
|
|
|
|
target = cfg.queue_target_len
|
|
if len(cfg.queue) > target:
|
|
# Target was lowered since this queue was built -- shrink it
|
|
# immediately rather than waiting for enough advances to consume
|
|
# the excess naturally.
|
|
cfg.queue = cfg.queue[:target]
|
|
return
|
|
|
|
needed = target - len(cfg.queue)
|
|
if needed <= 0 or not assets:
|
|
return
|
|
|
|
excluded = set(cfg.queue) | set(cfg.excluded_asset_ids)
|
|
if cfg.current_asset_id:
|
|
excluded.add(cfg.current_asset_id)
|
|
|
|
if cfg.order == "shuffle":
|
|
candidates = [a["id"] for a in assets if a["id"] not in excluded]
|
|
cfg.queue.extend(random.sample(candidates, min(needed, len(candidates))))
|
|
return
|
|
|
|
# Sequential: walk the album starting at queue_cursor, at most one full
|
|
# pass, wrapping around. queue_cursor resumes right after wherever this
|
|
# pass stopped, whether or not it filled the queue (e.g. a small album
|
|
# where everything's already queued/current -- next call is then a
|
|
# cheap no-op scan until something's consumed).
|
|
n = len(assets)
|
|
cfg.queue_cursor %= n
|
|
added = 0
|
|
i = 0
|
|
for i in range(n):
|
|
if added >= needed:
|
|
break
|
|
asset_id = assets[(cfg.queue_cursor + i) % n]["id"]
|
|
if asset_id not in excluded:
|
|
cfg.queue.append(asset_id)
|
|
excluded.add(asset_id)
|
|
added += 1
|
|
cfg.queue_cursor = (cfg.queue_cursor + i + 1) % n
|
|
|
|
|
|
def advance_forced(cfg: FrameConfig, assets: list[dict]) -> None:
|
|
"""Unconditionally moves to the next photo, ignoring elapsed time, and
|
|
resets the interval clock from now. Used by the explicit next-photo
|
|
action (POST /frame/advance) and by get_current() once the refresh
|
|
interval has elapsed -- always mutates cfg."""
|
|
if cfg.current_asset_id:
|
|
# Recorded regardless of *why* this advance happened (a manual
|
|
# next-press or the timer just elapsing) -- back should be able
|
|
# to undo either kind.
|
|
cfg.history.append(cfg.current_asset_id)
|
|
cfg.history = cfg.history[-HISTORY_MAX_LEN:]
|
|
|
|
_top_up(cfg, assets)
|
|
if cfg.queue:
|
|
cfg.current_asset_id = cfg.queue.pop(0)
|
|
elif assets:
|
|
# Queue still empty after top-up (e.g. a single-photo album whose
|
|
# only asset is already current) -- keep showing what we have.
|
|
cfg.current_asset_id = assets[0]["id"]
|
|
cfg.current_asset_set_at = time.time()
|
|
# Refill back up to queue_target_len now that current_asset_id has
|
|
# changed -- otherwise the queue is left one short until the *next*
|
|
# advance, since the pop above consumes one of the items _top_up just
|
|
# added.
|
|
_top_up(cfg, assets)
|
|
|
|
|
|
def back_forced(cfg: FrameConfig, assets: list[dict]) -> bool:
|
|
"""Unconditionally moves to the previously-current photo, the mirror
|
|
image of advance_forced() -- pops the most recent entry off history,
|
|
pushes the photo it's replacing onto the front of queue (so pressing
|
|
next afterwards returns to it), and resets the interval clock from
|
|
now. Skips over any history entries no longer in the album (deleted
|
|
since). Returns whether it actually moved -- False (history empty or
|
|
entirely stale) is a no-op, callers should still just display
|
|
whatever's current rather than treating it as an error. Used by the
|
|
back-photo button (POST /frame/back)."""
|
|
valid_ids = {a["id"] for a in assets}
|
|
while cfg.history:
|
|
previous_id = cfg.history.pop()
|
|
if previous_id not in valid_ids:
|
|
continue
|
|
if cfg.current_asset_id:
|
|
cfg.queue.insert(0, cfg.current_asset_id)
|
|
cfg.current_asset_id = previous_id
|
|
cfg.current_asset_set_at = time.time()
|
|
return True
|
|
return False
|
|
|
|
|
|
def remove_from_rotation(cfg: FrameConfig, assets: list[dict], asset_id: str) -> bool:
|
|
"""Permanently excludes asset_id from this frame's rotation (see the
|
|
module docstring) -- doesn't touch Immich, just this frame's own
|
|
selection. Scrubs it out of queue and history too, so it can't
|
|
resurface via "Show next" or the back button either. If it was the
|
|
current photo, immediately advances to a new one -- deliberately
|
|
*not* through advance_forced(), since that would record the removed
|
|
photo in history, and going back to a photo you just explicitly
|
|
removed doesn't make sense. Returns whether the current photo
|
|
changed as a result."""
|
|
if asset_id not in cfg.excluded_asset_ids:
|
|
cfg.excluded_asset_ids.append(asset_id)
|
|
cfg.queue = [a for a in cfg.queue if a != asset_id]
|
|
cfg.history = [a for a in cfg.history if a != asset_id]
|
|
|
|
if asset_id != cfg.current_asset_id:
|
|
return False
|
|
|
|
_top_up(cfg, assets)
|
|
if cfg.queue:
|
|
cfg.current_asset_id = cfg.queue.pop(0)
|
|
else:
|
|
# Queue empty even after top-up (e.g. everything else is also
|
|
# excluded, or a tiny album) -- fall back to any remaining
|
|
# non-excluded asset, or give up and show nothing.
|
|
excluded_ids = set(cfg.excluded_asset_ids)
|
|
remaining = [a["id"] for a in assets if a["id"] not in excluded_ids]
|
|
cfg.current_asset_id = remaining[0] if remaining else ""
|
|
cfg.current_asset_set_at = time.time()
|
|
_top_up(cfg, assets)
|
|
return True
|
|
|
|
|
|
def sync_queue_length(cfg: FrameConfig, assets: list[dict]) -> None:
|
|
"""Tops up or trims cfg.queue to match cfg.queue_target_len without
|
|
otherwise touching current_asset_id. Used by GET /api/queue so a
|
|
change to the "upcoming photos to show" setting takes effect on page
|
|
load rather than waiting for the next natural advance."""
|
|
_top_up(cfg, assets)
|
|
|
|
|
|
def get_current(cfg: FrameConfig, assets: list[dict]) -> bool:
|
|
"""Time-based, idempotent path used by GET /frame/image. Advances only
|
|
if the current photo is unset/invalid or refresh_interval_s has
|
|
elapsed since it was set. Returns whether it changed anything, so the
|
|
caller knows whether to persist. Calling this repeatedly well within
|
|
the interval is a no-op both times -- what makes an unplanned device
|
|
reboot safe: it just re-reads the current photo instead of skipping
|
|
ahead, while a wake that lands after the interval has elapsed still
|
|
advances exactly once, even after a long time offline."""
|
|
valid_ids = {a["id"] for a in assets}
|
|
stale = (
|
|
not cfg.current_asset_id
|
|
or cfg.current_asset_id not in valid_ids
|
|
or (time.time() - cfg.current_asset_set_at) >= cfg.refresh_interval_s
|
|
)
|
|
if not stale:
|
|
return False
|
|
advance_forced(cfg, assets)
|
|
return True
|