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:
+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