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 --
|
body is `{"asset_id": "..."}`. Used by "Show next" in the web UI --
|
||||||
unlike `/reorder`, doesn't depend on the client knowing the queue's
|
unlike `/reorder`, doesn't depend on the client knowing the queue's
|
||||||
full current order, so it can't fail from staleness
|
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
|
- `GET /api/photo-thumbnail/{asset_id}` -- proxies an Immich thumbnail so
|
||||||
the browser never needs the Immich API key directly
|
the browser never needs the Immich API key directly
|
||||||
- `GET /health` -- liveness check
|
- `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_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
|
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
|
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:
|
def load() -> FrameConfig:
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ def api_config_save(
|
|||||||
cfg.queue = []
|
cfg.queue = []
|
||||||
cfg.queue_cursor = 0
|
cfg.queue_cursor = 0
|
||||||
cfg.history = []
|
cfg.history = []
|
||||||
|
cfg.excluded_asset_ids = []
|
||||||
cfg.album_id = album_id
|
cfg.album_id = album_id
|
||||||
cfg.order = order if order in ("sequential", "shuffle") else "sequential"
|
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))
|
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"}
|
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)])
|
@app.get("/api/photo-thumbnail/{asset_id}", dependencies=[Depends(require_access_token)])
|
||||||
def api_photo_thumbnail(asset_id: str):
|
def api_photo_thumbnail(asset_id: str):
|
||||||
cfg = config.load()
|
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
|
`advance_forced` -- it pops `history` back into `current_asset_id` and
|
||||||
pushes the photo it's replacing onto the *front* of `queue`, so pressing
|
pushes the photo it's replacing onto the *front* of `queue`, so pressing
|
||||||
next afterwards lands you right back where you were.
|
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
|
from __future__ import annotations
|
||||||
@@ -34,7 +41,8 @@ HISTORY_MAX_LEN = 20
|
|||||||
|
|
||||||
def _top_up(cfg: FrameConfig, assets: list[dict]) -> None:
|
def _top_up(cfg: FrameConfig, assets: list[dict]) -> None:
|
||||||
valid_ids = {a["id"] for a in assets}
|
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
|
target = cfg.queue_target_len
|
||||||
if len(cfg.queue) > target:
|
if len(cfg.queue) > target:
|
||||||
@@ -48,7 +56,7 @@ def _top_up(cfg: FrameConfig, assets: list[dict]) -> None:
|
|||||||
if needed <= 0 or not assets:
|
if needed <= 0 or not assets:
|
||||||
return
|
return
|
||||||
|
|
||||||
excluded = set(cfg.queue)
|
excluded = set(cfg.queue) | set(cfg.excluded_asset_ids)
|
||||||
if cfg.current_asset_id:
|
if cfg.current_asset_id:
|
||||||
excluded.add(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
|
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:
|
def sync_queue_length(cfg: FrameConfig, assets: list[dict]) -> None:
|
||||||
"""Tops up or trims cfg.queue to match cfg.queue_target_len without
|
"""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
|
otherwise touching current_asset_id. Used by GET /api/queue so a
|
||||||
|
|||||||
+123
-17
@@ -30,22 +30,30 @@
|
|||||||
.photo-card {
|
.photo-card {
|
||||||
position: relative; cursor: grab; border-radius: 6px; overflow: hidden; aspect-ratio: 1;
|
position: relative; cursor: grab; border-radius: 6px; overflow: hidden; aspect-ratio: 1;
|
||||||
background: #f3f4f6; border: 1px solid #e5e7eb;
|
background: #f3f4f6; border: 1px solid #e5e7eb;
|
||||||
/* Touching a card is what starts a drag -- without this the browser
|
/* pan-y (not none): lets a normal touch-scroll of the page work
|
||||||
treats that touch as the start of a page scroll instead, and
|
when you touch a card without meaning to drag it. Dragging on
|
||||||
pointermove events for the drag never arrive on mobile. */
|
touch instead requires a brief hold first (see the JS below),
|
||||||
touch-action: none;
|
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:active { cursor: grabbing; }
|
||||||
.photo-card.dragging { opacity: 0.35; }
|
.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.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 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 .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 {
|
.photo-card .show-next {
|
||||||
position: absolute; bottom: 6px; left: 6px; right: 6px; margin: 0; padding: 5px 0;
|
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;
|
font-size: 11px; text-align: center; background: rgba(37, 99, 235, 0.85); border-radius: 4px;
|
||||||
opacity: 0; transition: opacity 0.15s;
|
|
||||||
}
|
}
|
||||||
.photo-card:hover .show-next, .photo-card:focus-within .show-next { opacity: 1; }
|
.thumb-wrap { position: relative; display: inline-block; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -100,7 +108,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 class="section">Upcoming</h2>
|
<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>
|
<div id="upcoming-grid" class="photo-grid"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -169,15 +179,30 @@
|
|||||||
// Pointer Events (not the native HTML5 Drag-and-Drop API) so the same
|
// Pointer Events (not the native HTML5 Drag-and-Drop API) so the same
|
||||||
// code drives mouse, touch, and pen -- native drag-and-drop is
|
// code drives mouse, touch, and pen -- native drag-and-drop is
|
||||||
// mouse-only by spec and never fires at all on phones/tablets.
|
// 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() {
|
function clearDragOverStyling() {
|
||||||
document.querySelectorAll('.photo-card.drag-over').forEach((el) => el.classList.remove('drag-over'));
|
document.querySelectorAll('.photo-card.drag-over').forEach((el) => el.classList.remove('drag-over'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function endDrag(card) {
|
function endDrag(card) {
|
||||||
card.classList.remove('dragging');
|
if (dragState && dragState.holdTimer) {
|
||||||
|
clearTimeout(dragState.holdTimer);
|
||||||
|
}
|
||||||
|
card.style.touchAction = '';
|
||||||
|
card.classList.remove('dragging', 'drag-armed');
|
||||||
clearDragOverStyling();
|
clearDragOverStyling();
|
||||||
if (dragState && dragState.moved && dragState.toIndex !== undefined && dragState.toIndex !== dragState.fromIndex) {
|
if (dragState && dragState.moved && dragState.toIndex !== undefined && dragState.toIndex !== dragState.fromIndex) {
|
||||||
moveItem(dragState.fromIndex, dragState.toIndex);
|
moveItem(dragState.fromIndex, dragState.toIndex);
|
||||||
@@ -205,6 +230,17 @@
|
|||||||
badge.textContent = String(i + 1);
|
badge.textContent = String(i + 1);
|
||||||
card.appendChild(badge);
|
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');
|
const nextBtn = document.createElement('button');
|
||||||
nextBtn.type = 'button';
|
nextBtn.type = 'button';
|
||||||
nextBtn.className = 'show-next';
|
nextBtn.className = 'show-next';
|
||||||
@@ -216,20 +252,50 @@
|
|||||||
card.appendChild(nextBtn);
|
card.appendChild(nextBtn);
|
||||||
|
|
||||||
card.addEventListener('pointerdown', (e) => {
|
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
|
return; // let the button's own click handler run, don't start a drag
|
||||||
}
|
}
|
||||||
if (e.pointerType === 'mouse' && e.button !== 0) {
|
if (e.pointerType === 'mouse' && e.button !== 0) {
|
||||||
return; // left button only
|
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) => {
|
card.addEventListener('pointermove', (e) => {
|
||||||
if (!dragState || dragState.pointerId !== e.pointerId) {
|
if (!dragState || dragState.pointerId !== e.pointerId) {
|
||||||
return;
|
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) {
|
if (!dragState.moved) {
|
||||||
const dx = e.clientX - dragState.startX;
|
const dx = e.clientX - dragState.startX;
|
||||||
const dy = e.clientY - dragState.startY;
|
const dy = e.clientY - dragState.startY;
|
||||||
@@ -237,6 +303,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dragState.moved = true;
|
dragState.moved = true;
|
||||||
|
card.classList.remove('drag-armed');
|
||||||
card.classList.add('dragging');
|
card.classList.add('dragging');
|
||||||
}
|
}
|
||||||
const overCard = document.elementFromPoint(e.clientX, e.clientY)?.closest('.photo-card');
|
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
|
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) {
|
async function persistOrder(items) {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/queue/reorder', {
|
const resp = await fetch('/api/queue/reorder', {
|
||||||
@@ -315,9 +401,29 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
currentEl.innerHTML = data.current
|
currentEl.innerHTML = '';
|
||||||
? `<img class="thumb" src="${data.current.thumbnail_url}">`
|
if (data.current) {
|
||||||
: '<p class="sub">Nothing displayed yet.</p>';
|
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);
|
renderUpcoming(data.upcoming);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
currentEl.innerHTML = '<p class="sub">Could not load.</p>';
|
currentEl.innerHTML = '<p class="sub">Could not load.</p>';
|
||||||
|
|||||||
Reference in New Issue
Block a user