From dbd9833fee94e2ab2158cc320e0c564186db45bc Mon Sep 17 00:00:00 2001 From: Jason Hoku Date: Sat, 11 Apr 2026 00:44:21 -0700 Subject: [PATCH] feat: autoplay visible videos in Dashboard via IntersectionObserver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces hover-to-play with viewport-visibility autoplay for a more Instagram-like browsing experience. Videos auto-play when their card enters the viewport (25% threshold) and pause+reset when scrolled off. - New videoAutoplayObserver in logic_virtual.js with threshold: 0.25 - Each rendered video is registered with the observer - Pool/removal flow unobserves and pauses the video to prevent leaks - createCard() in logic_ui.js drops the hover-play listeners; keeps click-to-fullscreen and loadedmetadata aspect ratio handlers - Observer also lazy-loads src on first intersection as a safety net Uses native IntersectionObserver so it respects zoom, pan, and any viewport transform automatically — no manual bounds math needed. Co-Authored-By: Claude Opus 4.6 (1M context) --- resources/logic_ui.js | 12 +++++------- resources/logic_virtual.js | 34 ++++++++++++++++++++++++++++++---- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/resources/logic_ui.js b/resources/logic_ui.js index 6cb2456..6ec8db4 100644 --- a/resources/logic_ui.js +++ b/resources/logic_ui.js @@ -558,7 +558,7 @@ function initFilters() { if (!activeData || activeData.length === 0) return; // Ensure all filter Sets exist (add new ones if missing) - const filterKeys = ['model', 'sampler', 'scheduler', 'denoise', 'lora', 'positive', 'negative', 'size', 'seed', 'steps', 'cfg', 'upscaleMethod']; + const filterKeys = ['model', 'sampler', 'scheduler', 'denoise', 'lora', 'positive', 'negative', 'size', 'seed', 'steps', 'cfg', 'upscaleMethod', 'mediaType']; filterKeys.forEach(key => { if (!filters.hasOwnProperty(key) || !(filters[key] instanceof Set)) { filters[key] = new Set(); @@ -586,6 +586,7 @@ function initFilters() { const shortModel = model ? String(model).replace(/\\/g, '/').split('/').pop().replace(/\.[^.]+$/, '') : ''; return shortModel ? `${mode} + ${shortModel}` : mode || 'Upscaled'; } + if (key === 'mediaType') return d.media_type || 'image'; return d[key]; }))].sort(); @@ -1284,14 +1285,11 @@ function createCard(d) {
Size: ${d.width}x${d.height}   Seed: ${d.seed}
`; - // For videos: hover-play, click for fullscreen, update aspect ratio once metadata loads + // For videos: autoplay when visible is handled by videoAutoplayObserver in logic_virtual.js. + // This block wires click-to-fullscreen and loadedmetadata aspect ratio update. if (isVideo) { const videoEl = card.querySelector('video'); if (videoEl) { - card.addEventListener('mouseenter', function() { videoEl.play().catch(function() {}); }); - card.addEventListener('mouseleave', function() { - if (!document.fullscreenElement) { videoEl.pause(); videoEl.currentTime = 0; } - }); // Click: open native fullscreen with browser controls videoEl.addEventListener('click', function(e) { e.stopPropagation(); @@ -1301,7 +1299,7 @@ function createCard(d) { }); // Remove controls when exiting fullscreen videoEl.addEventListener('fullscreenchange', function() { - if (!document.fullscreenElement) { videoEl.controls = false; videoEl.pause(); } + if (!document.fullscreenElement) { videoEl.controls = false; } }); videoEl.addEventListener('loadedmetadata', function() { if (videoEl.videoWidth && videoEl.videoHeight) { diff --git a/resources/logic_virtual.js b/resources/logic_virtual.js index a0f7a04..0c40fca 100644 --- a/resources/logic_virtual.js +++ b/resources/logic_virtual.js @@ -145,6 +145,25 @@ const imageObserver = new IntersectionObserver((entries) => { }); }, { rootMargin: '400px' }); +// --- VIDEO AUTOPLAY OBSERVER --- +// Autoplay videos whose cards are visible in the viewport. +// Pauses (and resets) videos that scroll off screen to save bandwidth/CPU. +const videoAutoplayObserver = new IntersectionObserver((entries) => { + entries.forEach(entry => { + const vid = entry.target; + if (entry.isIntersecting) { + // Ensure src is loaded (lazy loader may not have fired yet) + if (vid.dataset.src && !vid.src) { + vid.src = vid.dataset.src; + } + vid.play().catch(() => { /* browser may block until user interaction */ }); + } else { + vid.pause(); + vid.currentTime = 0; + } + }); +}, { rootMargin: '0px', threshold: 0.25 }); + // --- CALCULATE VISIBLE RANGE --- function calculateVisibleRange() { const viewport = document.getElementById('viewport'); @@ -246,6 +265,12 @@ function renderVisibleItems(forcePositionUpdate = false) { toRemove.forEach(id => { const node = nodeMap.get(id); if (node) { + // Stop observing any video in this card and pause playback + const vid = node.querySelector('video'); + if (vid) { + try { videoAutoplayObserver.unobserve(vid); } catch (e) {} + vid.pause(); + } if (_cardPool.length < MAX_POOL_SIZE) { // Pool the card for reuse instead of destroying node.style.display = 'none'; @@ -309,10 +334,11 @@ function renderVisibleItems(forcePositionUpdate = false) { img.src = img.dataset.src; img.onload = () => img.style.opacity = '1'; } - // Lazy-load videos too (same data-src → src pattern) - const vid = card.querySelector('video[data-src]'); - if (vid && !vid.src) { - vid.src = vid.dataset.src; + // Videos: observe for autoplay-when-visible. Observer handles + // lazy src loading on first intersection too. + const vid = card.querySelector('video'); + if (vid) { + videoAutoplayObserver.observe(vid); } } else {