diff --git a/server/README.md b/server/README.md index a835ac4..79fd70b 100644 --- a/server/README.md +++ b/server/README.md @@ -111,6 +111,15 @@ algorithm itself -- it just streams the response straight to the panel. body is `{"asset_id": "..."}`. Used by "Show next" in the web UI -- unlike `/reorder`, doesn't depend on the client knowing the queue's full current order, so it can't fail from staleness +- `POST /api/queue/remove` -- permanently excludes a photo from this + frame's rotation; body is `{"asset_id": "..."}`. Doesn't touch Immich + or the album -- the photo just stops being selected by this frame + again (`app/photo_queue.py`'s `excluded_asset_ids`/`remove_from_rotation()`). + Works on the current photo too, in which case it immediately advances + to a different one (without recording the removed photo in history -- + going back to a photo you just removed wouldn't make sense). Used by + the "×" button in the web UI on both the current-photo thumbnail and + each upcoming card - `GET /api/photo-thumbnail/{asset_id}` -- proxies an Immich thumbnail so the browser never needs the Immich API key directly - `GET /health` -- liveness check diff --git a/server/app/config.py b/server/app/config.py index af02b27..cf6be4c 100644 --- a/server/app/config.py +++ b/server/app/config.py @@ -37,6 +37,7 @@ class FrameConfig(BaseModel): queue_cursor: int = 0 # internal bookkeeping for sequential queue top-up; not user-facing queue_target_len: int = 20 # how many upcoming photos to keep queued/shown in the web UI history: list[str] = [] # bounded stack of previously-current asset ids, most recent last + excluded_asset_ids: list[str] = [] # permanently removed from this frame's rotation (not deleted from Immich) def load() -> FrameConfig: diff --git a/server/app/main.py b/server/app/main.py index faff6e5..66af35b 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -132,6 +132,7 @@ def api_config_save( cfg.queue = [] cfg.queue_cursor = 0 cfg.history = [] + cfg.excluded_asset_ids = [] cfg.album_id = album_id cfg.order = order if order in ("sequential", "shuffle") else "sequential" cfg.refresh_interval_s = max(MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s)) @@ -488,6 +489,28 @@ def api_queue_promote(body: QueuePromoteRequest): return {"status": "saved"} +class QueueRemoveRequest(BaseModel): + asset_id: str + + +@app.post("/api/queue/remove", dependencies=[Depends(require_access_token)]) +def api_queue_remove(body: QueueRemoveRequest): + """Permanently removes a photo from this frame's rotation -- "Remove" + in the web UI, on either an upcoming card or the current photo. Does + NOT touch Immich or the album itself; see photo_queue.remove_from_rotation().""" + cfg = config.load() + _require_configured(cfg) + + client = ImmichClient(cfg.immich_url, cfg.immich_api_key) + assets = _list_assets(client, cfg) + + with config.locked(): + cfg = config.load() # re-read: state may have changed since the unlocked read above + photo_queue.remove_from_rotation(cfg, assets, body.asset_id) + config.save(cfg) + return {"status": "removed"} + + @app.get("/api/photo-thumbnail/{asset_id}", dependencies=[Depends(require_access_token)]) def api_photo_thumbnail(asset_id: str): cfg = config.load() diff --git a/server/app/photo_queue.py b/server/app/photo_queue.py index c3ea914..d40df06 100644 --- a/server/app/photo_queue.py +++ b/server/app/photo_queue.py @@ -20,6 +20,13 @@ actually changes `current_asset_id`, the old one is pushed onto `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 @@ -34,7 +41,8 @@ HISTORY_MAX_LEN = 20 def _top_up(cfg: FrameConfig, assets: list[dict]) -> None: valid_ids = {a["id"] for a in assets} - cfg.queue = [asset_id for asset_id in cfg.queue if asset_id in valid_ids] + 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: @@ -48,7 +56,7 @@ def _top_up(cfg: FrameConfig, assets: list[dict]) -> None: if needed <= 0 or not assets: return - excluded = set(cfg.queue) + excluded = set(cfg.queue) | set(cfg.excluded_asset_ids) if cfg.current_asset_id: excluded.add(cfg.current_asset_id) @@ -127,6 +135,39 @@ def back_forced(cfg: FrameConfig, assets: list[dict]) -> bool: 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 diff --git a/server/app/templates/index.html b/server/app/templates/index.html index ef6a415..77d2572 100644 --- a/server/app/templates/index.html +++ b/server/app/templates/index.html @@ -30,22 +30,30 @@ .photo-card { position: relative; cursor: grab; border-radius: 6px; overflow: hidden; aspect-ratio: 1; background: #f3f4f6; border: 1px solid #e5e7eb; - /* Touching a card is what starts a drag -- without this the browser - treats that touch as the start of a page scroll instead, and - pointermove events for the drag never arrive on mobile. */ - touch-action: none; + /* pan-y (not none): lets a normal touch-scroll of the page work + when you touch a card without meaning to drag it. Dragging on + touch instead requires a brief hold first (see the JS below), + which switches this to "none" for the rest of that touch -- + only once we're sure it's a deliberate drag, not a scroll. */ + touch-action: pan-y; } .photo-card:active { cursor: grabbing; } .photo-card.dragging { opacity: 0.35; } + .photo-card.drag-armed { box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.5) inset; } .photo-card.drag-over { outline: 3px solid #2563eb; outline-offset: -3px; } .photo-card img { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; } .photo-card .badge { position: absolute; top: 6px; left: 6px; background: rgba(0, 0, 0, 0.6); color: white; font-size: 10px; padding: 2px 6px; border-radius: 3px; } + .photo-card .remove-btn, .thumb-wrap .remove-btn { + position: absolute; top: 6px; right: 6px; width: 22px; height: 22px; margin: 0; padding: 0; + line-height: 20px; text-align: center; font-size: 13px; border-radius: 50%; + background: rgba(0, 0, 0, 0.55); color: white; + } + .photo-card .remove-btn:hover, .thumb-wrap .remove-btn:hover { background: rgba(153, 27, 27, 0.85); } .photo-card .show-next { position: absolute; bottom: 6px; left: 6px; right: 6px; margin: 0; padding: 5px 0; - font-size: 11px; text-align: center; background: rgba(37, 99, 235, 0.92); border-radius: 4px; - opacity: 0; transition: opacity 0.15s; + font-size: 11px; text-align: center; background: rgba(37, 99, 235, 0.85); border-radius: 4px; } - .photo-card:hover .show-next, .photo-card:focus-within .show-next { opacity: 1; } + .thumb-wrap { position: relative; display: inline-block; } @@ -100,7 +108,9 @@

Upcoming

-

Drag a photo to reorder, or use "Show next" to jump it to the front.

+

Drag a photo to reorder (on touch, hold briefly first so a + normal scroll still works), "Show next" to jump it to the front, or + the × to remove it from rotation entirely.