feat(video): enhance video handling in model cards with lazy loading and autoplay settings, see #446

This commit is contained in:
Will Miao
2025-09-24 21:11:36 +08:00
parent 3ad8d8b17c
commit 14c468f2a2
2 changed files with 162 additions and 38 deletions

View File

@@ -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 = `
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
${isVideo ?
`<video ${videoAttrs} style="pointer-events: none;">
<source src="${versionedPreviewUrl}" type="video/mp4">
</video>` :
`<video ${videoAttrs.join(' ')} style="pointer-events: none;"></video>` :
`<img src="${versionedPreviewUrl}" alt="${model.model_name}">`
}
<div class="card-header">
@@ -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();
}
}
}