Add remove-from-rotation, fix mobile scroll-vs-drag conflict
Build and push server image / build-and-push (push) Successful in 32s
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
+123
-17
@@ -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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -100,7 +108,9 @@
|
||||
</div>
|
||||
|
||||
<h2 class="section">Upcoming</h2>
|
||||
<p class="sub">Drag a photo to reorder, or use "Show next" to jump it to the front.</p>
|
||||
<p class="sub">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.</p>
|
||||
<div id="upcoming-grid" class="photo-grid"></div>
|
||||
|
||||
<script>
|
||||
@@ -169,15 +179,30 @@
|
||||
// Pointer Events (not the native HTML5 Drag-and-Drop API) so the same
|
||||
// code drives mouse, touch, and pen -- native drag-and-drop is
|
||||
// mouse-only by spec and never fires at all on phones/tablets.
|
||||
let dragState = null; // { pointerId, fromIndex, toIndex, moved }
|
||||
const DRAG_START_THRESHOLD_PX = 6; // ignore tiny jitter from an imprecise tap
|
||||
//
|
||||
// On touch specifically, a card only "arms" for dragging after a
|
||||
// brief hold (DRAG_HOLD_MS) with the finger roughly stationary --
|
||||
// .photo-card's touch-action stays "pan-y" (native scroll allowed)
|
||||
// the whole time up to that point, so a normal touch-and-swipe to
|
||||
// scroll the page still works even though it starts on a card. Once
|
||||
// armed, touch-action switches to "none" for the rest of that touch
|
||||
// so drag tracking gets every pointermove reliably. Mouse skips the
|
||||
// hold entirely (no scroll-vs-drag ambiguity with a mouse).
|
||||
let dragState = null; // { pointerId, fromIndex, toIndex, moved, armed, holdTimer }
|
||||
const DRAG_START_THRESHOLD_PX = 6; // ignore tiny jitter once armed, before committing to a drag
|
||||
const DRAG_HOLD_MS = 350;
|
||||
const HOLD_CANCEL_THRESHOLD_PX = 10; // movement before the hold timer fires -> treat as a scroll, not a drag
|
||||
|
||||
function clearDragOverStyling() {
|
||||
document.querySelectorAll('.photo-card.drag-over').forEach((el) => el.classList.remove('drag-over'));
|
||||
}
|
||||
|
||||
function endDrag(card) {
|
||||
card.classList.remove('dragging');
|
||||
if (dragState && dragState.holdTimer) {
|
||||
clearTimeout(dragState.holdTimer);
|
||||
}
|
||||
card.style.touchAction = '';
|
||||
card.classList.remove('dragging', 'drag-armed');
|
||||
clearDragOverStyling();
|
||||
if (dragState && dragState.moved && dragState.toIndex !== undefined && dragState.toIndex !== dragState.fromIndex) {
|
||||
moveItem(dragState.fromIndex, dragState.toIndex);
|
||||
@@ -205,6 +230,17 @@
|
||||
badge.textContent = String(i + 1);
|
||||
card.appendChild(badge);
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.type = 'button';
|
||||
removeBtn.className = 'remove-btn';
|
||||
removeBtn.title = 'Remove from rotation';
|
||||
removeBtn.textContent = '×';
|
||||
removeBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
removeAsset(item.id);
|
||||
});
|
||||
card.appendChild(removeBtn);
|
||||
|
||||
const nextBtn = document.createElement('button');
|
||||
nextBtn.type = 'button';
|
||||
nextBtn.className = 'show-next';
|
||||
@@ -216,20 +252,50 @@
|
||||
card.appendChild(nextBtn);
|
||||
|
||||
card.addEventListener('pointerdown', (e) => {
|
||||
if (e.target.closest('.show-next')) {
|
||||
if (e.target.closest('.show-next') || e.target.closest('.remove-btn')) {
|
||||
return; // let the button's own click handler run, don't start a drag
|
||||
}
|
||||
if (e.pointerType === 'mouse' && e.button !== 0) {
|
||||
return; // left button only
|
||||
}
|
||||
dragState = { pointerId: e.pointerId, fromIndex: i, toIndex: undefined, moved: false, startX: e.clientX, startY: e.clientY };
|
||||
card.setPointerCapture(e.pointerId);
|
||||
|
||||
dragState = {
|
||||
pointerId: e.pointerId, fromIndex: i, toIndex: undefined, moved: false,
|
||||
armed: e.pointerType !== 'touch', startX: e.clientX, startY: e.clientY, holdTimer: null,
|
||||
};
|
||||
|
||||
if (e.pointerType === 'touch') {
|
||||
dragState.holdTimer = setTimeout(() => {
|
||||
if (dragState && dragState.pointerId === e.pointerId && !dragState.armed) {
|
||||
dragState.armed = true;
|
||||
card.classList.add('drag-armed');
|
||||
card.style.touchAction = 'none';
|
||||
try { card.setPointerCapture(e.pointerId); } catch (err) { /* pointer may have already left */ }
|
||||
}
|
||||
}, DRAG_HOLD_MS);
|
||||
} else {
|
||||
card.setPointerCapture(e.pointerId);
|
||||
}
|
||||
});
|
||||
|
||||
card.addEventListener('pointermove', (e) => {
|
||||
if (!dragState || dragState.pointerId !== e.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dragState.armed) {
|
||||
// Still deciding whether this is a hold-to-drag or a scroll --
|
||||
// moving this much before the hold timer fires means scroll;
|
||||
// bail out and let the browser's native pan-y handle it.
|
||||
const dx0 = e.clientX - dragState.startX;
|
||||
const dy0 = e.clientY - dragState.startY;
|
||||
if (Math.hypot(dx0, dy0) > HOLD_CANCEL_THRESHOLD_PX) {
|
||||
clearTimeout(dragState.holdTimer);
|
||||
dragState = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dragState.moved) {
|
||||
const dx = e.clientX - dragState.startX;
|
||||
const dy = e.clientY - dragState.startY;
|
||||
@@ -237,6 +303,7 @@
|
||||
return;
|
||||
}
|
||||
dragState.moved = true;
|
||||
card.classList.remove('drag-armed');
|
||||
card.classList.add('dragging');
|
||||
}
|
||||
const overCard = document.elementFromPoint(e.clientX, e.clientY)?.closest('.photo-card');
|
||||
@@ -289,6 +356,25 @@
|
||||
loadQueue(); // always refetch the authoritative order rather than guessing locally
|
||||
}
|
||||
|
||||
async function removeAsset(assetId) {
|
||||
if (!confirm('Remove this photo from the rotation? It stays in Immich -- this frame just won\'t show it again.')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch('/api/queue/remove', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ asset_id: assetId }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(await resp.text());
|
||||
}
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
loadQueue();
|
||||
}
|
||||
|
||||
async function persistOrder(items) {
|
||||
try {
|
||||
const resp = await fetch('/api/queue/reorder', {
|
||||
@@ -315,9 +401,29 @@
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
currentEl.innerHTML = data.current
|
||||
? `<img class="thumb" src="${data.current.thumbnail_url}">`
|
||||
: '<p class="sub">Nothing displayed yet.</p>';
|
||||
currentEl.innerHTML = '';
|
||||
if (data.current) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'thumb-wrap';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.className = 'thumb';
|
||||
img.src = data.current.thumbnail_url;
|
||||
img.alt = '';
|
||||
wrap.appendChild(img);
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.type = 'button';
|
||||
removeBtn.className = 'remove-btn';
|
||||
removeBtn.title = 'Remove from rotation';
|
||||
removeBtn.textContent = '×';
|
||||
removeBtn.addEventListener('click', () => removeAsset(data.current.id));
|
||||
wrap.appendChild(removeBtn);
|
||||
|
||||
currentEl.appendChild(wrap);
|
||||
} else {
|
||||
currentEl.innerHTML = '<p class="sub">Nothing displayed yet.</p>';
|
||||
}
|
||||
renderUpcoming(data.upcoming);
|
||||
} catch (e) {
|
||||
currentEl.innerHTML = '<p class="sub">Could not load.</p>';
|
||||
|
||||
Reference in New Issue
Block a user