// The upcoming-photos grid: rendering plus drag-to-reorder. Ported // intact from the original single-page UI -- the Pointer Events state // machine below (hold-to-arm on touch so page scrolling still works) is // battle-tested; treat changes with suspicion. // // Expects window.FRAME_API = '/api/frames//widgets/' (set // by frame_layout.js when the photos dialog opens), and a loadQueue() // global (widget_dialog_photos.js) to refetch authoritative state. let upcomingItems = []; // 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. // // 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 = 250; const HOLD_CANCEL_THRESHOLD_PX = 10; // movement before the hold timer fires -> treat as a scroll, not a drag function vibrate(ms) { // Chrome/Android only -- iOS Safari has no Vibration API. Wrapped so // it's a harmless no-op everywhere else rather than needing a // feature check at every call site. try { navigator.vibrate?.(ms); } catch (e) { /* ignore */ } } function clearDragOverStyling() { document.querySelectorAll('.photo-card.drag-over').forEach((el) => el.classList.remove('drag-over')); } function endDrag(card) { if (dragState && dragState.holdTimer) { clearTimeout(dragState.holdTimer); } card.style.touchAction = ''; card.style.transform = ''; card.classList.remove('dragging', 'drag-armed'); clearDragOverStyling(); if (dragState && dragState.moved && dragState.toIndex !== undefined && dragState.toIndex !== dragState.fromIndex) { vibrate(15); moveItem(dragState.fromIndex, dragState.toIndex); } dragState = null; } function renderUpcoming(items) { upcomingItems = items; const grid = document.getElementById('upcoming-grid'); grid.innerHTML = ''; items.forEach((item, i) => { const card = document.createElement('div'); card.className = 'photo-card'; card.dataset.index = String(i); const img = document.createElement('img'); img.src = item.thumbnail_url; img.alt = ''; card.appendChild(img); const badge = document.createElement('span'); badge.className = 'badge'; 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'; nextBtn.textContent = 'Show next'; nextBtn.addEventListener('click', (e) => { e.stopPropagation(); showNext(i); }); card.appendChild(nextBtn); card.addEventListener('pointerdown', (e) => { 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, 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'; vibrate(10); 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; } const dx = e.clientX - dragState.startX; const dy = e.clientY - dragState.startY; if (!dragState.moved) { if (Math.hypot(dx, dy) < DRAG_START_THRESHOLD_PX) { return; } dragState.moved = true; card.classList.remove('drag-armed'); card.classList.add('dragging'); } // Follows the finger 1:1 -- the actual "pick it up and carry it" // feedback. card.style.transform = `translate(${dx}px, ${dy}px) scale(1.06)`; const overCard = document.elementFromPoint(e.clientX, e.clientY)?.closest('.photo-card'); clearDragOverStyling(); if (overCard && overCard !== card) { overCard.classList.add('drag-over'); dragState.toIndex = Number(overCard.dataset.index); } else { dragState.toIndex = undefined; } }); card.addEventListener('pointerup', (e) => { if (dragState && dragState.pointerId === e.pointerId) { endDrag(card); } }); card.addEventListener('pointercancel', (e) => { if (dragState && dragState.pointerId === e.pointerId) { endDrag(card); } }); grid.appendChild(card); }); } function moveItem(fromIndex, toIndex) { const items = upcomingItems.slice(); const [moved] = items.splice(fromIndex, 1); items.splice(toIndex, 0, moved); renderUpcoming(items); persistOrder(items); } async function showNext(index) { const assetId = upcomingItems[index].id; try { const resp = await fetch(`${window.FRAME_API}/queue/promote`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ asset_id: assetId }), }); if (!resp.ok) { throw new Error(await apiError(resp)); } } catch (e) { showStatus(false, e.message); } 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(`${window.FRAME_API}/queue/remove`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ asset_id: assetId }), }); if (!resp.ok) { throw new Error(await apiError(resp)); } } catch (e) { showStatus(false, e.message); } loadQueue(); } async function persistOrder(items) { try { const resp = await fetch(`${window.FRAME_API}/queue/reorder`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ queue: items.map((item) => item.id) }), }); if (!resp.ok) { throw new Error(await apiError(resp)); } } catch (e) { showStatus(false, e.message); loadQueue(); } }