From 14c468f2a2e63a80e1b7788bdf54f21d12da5be4 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Wed, 24 Sep 2025 21:11:36 +0800 Subject: [PATCH] feat(video): enhance video handling in model cards with lazy loading and autoplay settings, see #446 --- static/js/components/shared/ModelCard.js | 175 +++++++++++++++++++++-- static/js/managers/SettingsManager.js | 25 +--- 2 files changed, 162 insertions(+), 38 deletions(-) diff --git a/static/js/components/shared/ModelCard.js b/static/js/components/shared/ModelCard.js index 2a3c0d2f..17226bc0 100644 --- a/static/js/components/shared/ModelCard.js +++ b/static/js/components/shared/ModelCard.js @@ -435,7 +435,18 @@ export function createModelCard(model, modelType) { // Check if autoplayOnHover is enabled for video previews const autoplayOnHover = state.global?.settings?.autoplay_on_hover || false; const isVideo = previewUrl.endsWith('.mp4'); - const videoAttrs = autoplayOnHover ? 'controls muted loop' : 'controls autoplay muted loop'; + const videoAttrs = [ + 'controls', + 'muted', + 'loop', + 'playsinline', + 'preload="none"', + `data-src="${versionedPreviewUrl}"` + ]; + + if (!autoplayOnHover) { + videoAttrs.push('data-autoplay="true"'); + } // Get favorite status from model data const isFavorite = model.favorite === true; @@ -473,9 +484,7 @@ export function createModelCard(model, modelType) { card.innerHTML = `
${isVideo ? - `` : + `` : `${model.model_name}` }
@@ -514,21 +523,155 @@ export function createModelCard(model, modelType) { // Add video auto-play on hover functionality if needed const videoElement = card.querySelector('video'); - if (videoElement && autoplayOnHover) { - const cardPreview = card.querySelector('.card-preview'); - - // Remove autoplay attribute and pause initially - videoElement.removeAttribute('autoplay'); - videoElement.pause(); - - // Add mouse events to trigger play/pause using event attributes - cardPreview.setAttribute('onmouseenter', 'this.querySelector("video")?.play()'); - cardPreview.setAttribute('onmouseleave', 'const v=this.querySelector("video"); if(v){v.pause();v.currentTime=0;}'); + if (videoElement) { + configureModelCardVideo(videoElement, autoplayOnHover); } return card; } +const VIDEO_LAZY_ROOT_MARGIN = '200px 0px'; +let videoLazyObserver = null; + +function ensureVideoLazyObserver() { + if (videoLazyObserver) { + return videoLazyObserver; + } + + videoLazyObserver = new IntersectionObserver((entries, observer) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + const target = entry.target; + observer.unobserve(target); + loadVideoSource(target); + } + }); + }, { + root: null, + rootMargin: VIDEO_LAZY_ROOT_MARGIN, + threshold: 0.01 + }); + + return videoLazyObserver; +} + +function cleanupHoverHandlers(videoElement) { + const handlers = videoElement._hoverHandlers; + if (!handlers) return; + + const { cardPreview, mouseEnter, mouseLeave } = handlers; + if (cardPreview) { + cardPreview.removeEventListener('mouseenter', mouseEnter); + cardPreview.removeEventListener('mouseleave', mouseLeave); + } + + delete videoElement._hoverHandlers; +} + +function requestSafePlay(videoElement) { + const playPromise = videoElement.play(); + if (playPromise && typeof playPromise.catch === 'function') { + playPromise.catch(() => {}); + } +} + +function loadVideoSource(videoElement) { + if (!videoElement || videoElement.dataset.loaded === 'true') { + return; + } + + const sourceElement = videoElement.querySelector('source'); + const dataSrc = videoElement.dataset.src || sourceElement?.dataset?.src; + + if (!dataSrc) { + return; + } + + // Ensure src attributes are reset before applying + videoElement.removeAttribute('src'); + if (sourceElement) { + sourceElement.src = dataSrc; + } else { + videoElement.src = dataSrc; + } + + videoElement.load(); + videoElement.dataset.loaded = 'true'; + + if (videoElement.dataset.autoplay === 'true') { + videoElement.setAttribute('autoplay', ''); + requestSafePlay(videoElement); + } +} + +export function configureModelCardVideo(videoElement, autoplayOnHover) { + if (!videoElement) return; + + cleanupHoverHandlers(videoElement); + + const sourceElement = videoElement.querySelector('source'); + const existingSrc = videoElement.dataset.src || sourceElement?.dataset?.src || videoElement.currentSrc; + + if (existingSrc && !videoElement.dataset.src) { + videoElement.dataset.src = existingSrc; + } + + if (sourceElement && !sourceElement.dataset.src) { + sourceElement.dataset.src = videoElement.dataset.src || sourceElement.src; + } + + videoElement.removeAttribute('autoplay'); + videoElement.removeAttribute('src'); + videoElement.setAttribute('preload', 'none'); + videoElement.setAttribute('muted', ''); + videoElement.setAttribute('loop', ''); + videoElement.setAttribute('playsinline', ''); + videoElement.setAttribute('controls', ''); + videoElement.dataset.loaded = 'false'; + + if (sourceElement) { + sourceElement.removeAttribute('src'); + if (videoElement.dataset.src) { + sourceElement.dataset.src = videoElement.dataset.src; + } + } + + if (!autoplayOnHover) { + videoElement.dataset.autoplay = 'true'; + } else { + delete videoElement.dataset.autoplay; + } + + const observer = ensureVideoLazyObserver(); + observer.observe(videoElement); + + // Pause the video until it is either hovered or autoplay kicks in + try { + videoElement.pause(); + } catch (err) { + // Ignore pause errors (e.g., if not loaded yet) + } + + if (autoplayOnHover) { + const cardPreview = videoElement.closest('.card-preview'); + if (cardPreview) { + const mouseEnter = () => { + loadVideoSource(videoElement); + requestSafePlay(videoElement); + }; + const mouseLeave = () => { + videoElement.pause(); + videoElement.currentTime = 0; + }; + + cardPreview.addEventListener('mouseenter', mouseEnter); + cardPreview.addEventListener('mouseleave', mouseLeave); + + videoElement._hoverHandlers = { cardPreview, mouseEnter, mouseLeave }; + } + } +} + // Add a method to update card appearance based on bulk mode (LoRA only) export function updateCardsForBulkMode(isBulkMode) { // Update the state @@ -567,4 +710,6 @@ export function updateCardsForBulkMode(isBulkMode) { if (isBulkMode) { bulkManager.applySelectionState(); } -} \ No newline at end of file +} + + diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index b6ffe6c2..d7a03fc5 100644 --- a/static/js/managers/SettingsManager.js +++ b/static/js/managers/SettingsManager.js @@ -5,6 +5,7 @@ import { resetAndReload } from '../api/modelApiFactory.js'; import { DOWNLOAD_PATH_TEMPLATES, MAPPABLE_BASE_MODELS, PATH_TEMPLATE_PLACEHOLDERS, DEFAULT_PATH_TEMPLATES } from '../utils/constants.js'; import { translate } from '../utils/i18nHelpers.js'; import { i18n } from '../i18n/index.js'; +import { configureModelCardVideo } from '../components/shared/ModelCard.js'; export class SettingsManager { constructor() { @@ -1222,29 +1223,7 @@ export class SettingsManager { // Apply autoplay setting to existing videos in card previews const autoplayOnHover = state.global.settings.autoplay_on_hover; document.querySelectorAll('.card-preview video').forEach(video => { - // Remove previous event listeners by cloning and replacing the element - const videoParent = video.parentElement; - const videoClone = video.cloneNode(true); - - if (autoplayOnHover) { - // Pause video initially and set up mouse events for hover playback - videoClone.removeAttribute('autoplay'); - videoClone.pause(); - - // Add mouse events to the parent element - videoParent.onmouseenter = () => videoClone.play(); - videoParent.onmouseleave = () => { - videoClone.pause(); - videoClone.currentTime = 0; - }; - } else { - // Use default autoplay behavior - videoClone.setAttribute('autoplay', ''); - videoParent.onmouseenter = null; - videoParent.onmouseleave = null; - } - - videoParent.replaceChild(videoClone, video); + configureModelCardVideo(video, autoplayOnHover); }); // Apply display density class to grid