feat: autoplay visible videos in Dashboard via IntersectionObserver

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) <noreply@anthropic.com>
This commit is contained in:
Jason Hoku
2026-04-11 00:44:21 -07:00
co-authored by Claude Opus 4.6
parent b641beb94f
commit dbd9833fee
2 changed files with 35 additions and 11 deletions
+5 -7
View File
@@ -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) {
<div class="stat"><b>Size:</b> ${d.width}x${d.height} &nbsp; <b>Seed:</b> ${d.seed}</div>
</div>`;
// 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) {
+30 -4
View File
@@ -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 {