diff --git a/static/js/components/shared/showcase/ShowcaseView.js b/static/js/components/shared/showcase/ShowcaseView.js index d2512be7..2b852216 100644 --- a/static/js/components/shared/showcase/ShowcaseView.js +++ b/static/js/components/shared/showcase/ShowcaseView.js @@ -284,7 +284,7 @@ function renderThumbnail(img, index, exampleFiles) { const activeClass = index === galleryState.activeIndex ? ' active' : ''; const blurClass = shouldBlur ? ' blurred' : ''; const mediaHtml = isVideo ? - ` + ` ` : ``; const nsfwBadge = shouldBlur ? '' : ''; @@ -442,18 +442,24 @@ function findLocalFile(img, index, exampleFiles) { // never issue duplicate prefetch requests const prefetchedUrls = new Set(); +// Direction of the last main-viewer navigation (+1 next / -1 prev); users +// tend to keep clicking the same arrow, so prefetch reaches one further +// ahead along it. Defaults to forward (Next is the most common navigation) +let lastNavDirection = 1; + /** - * Warm the HTTP cache for the examples most likely to be shown next (the - * indices adjacent to the active one), so prev/next navigation feels instant. - * Images only: video payloads are too heavy for speculative prefetch, and - * locally stored examples need no network fetch at all. + * Warm the HTTP cache for the examples most likely to be shown next: both + * indices adjacent to the active one, plus one extra ahead along the last + * navigation direction, so prev/next navigation feels instant. Images only: + * video payloads are too heavy for speculative prefetch, and locally stored + * examples need no network fetch at all. */ function prefetchAdjacentMedia() { const { images, exampleFiles, activeIndex, expanded } = galleryState; if (!expanded || images.length < 2) return; - [activeIndex + 1, activeIndex - 1].forEach(i => { - const index = ((i % images.length) + images.length) % images.length; + [1, -1, lastNavDirection * 2].forEach(offset => { + const index = ((activeIndex + offset) % images.length + images.length) % images.length; const img = images[index]; if (!img?.url || findLocalFile(img, index, exampleFiles)) return; @@ -480,6 +486,11 @@ export function updateMainDisplay(index) { const count = galleryState.images.length; if (!count || !galleryState.expanded) return; + // Remember the navigation direction for direction-aware prefetching + // (a raw index of -1 / count means wrap-around prev / next) + const delta = index - galleryState.activeIndex; + if (delta !== 0) lastNavDirection = delta > 0 ? 1 : -1; + galleryState.activeIndex = ((index % count) + count) % count; const container = document.getElementById('mainMediaContainer'); @@ -659,6 +670,40 @@ function setupScrollToExpand(gallery) { }, { passive: true }); } +/** + * Defer metadata fetches for video thumbnails until they scroll into view: + * with preload="metadata" on every strip video, expanding the gallery would + * otherwise hit the network for all of them at once + * @param {HTMLElement} gallery - The .showcase-gallery element + */ +function initStripVideoLazyLoading(gallery) { + const videos = gallery.querySelectorAll('.gallery-strip video[data-lazy-video]'); + if (!videos.length) return; + + const enable = (video) => { + video.preload = 'metadata'; + video.load(); + video.removeAttribute('data-lazy-video'); + }; + + if (typeof IntersectionObserver === 'undefined') { + videos.forEach(enable); + return; + } + + // No explicit root: intersection accounts for the strip's overflow + // clipping, so off-screen thumbnails stay at preload="none" + const observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + enable(entry.target); + observer.unobserve(entry.target); + } + }); + }); + videos.forEach(video => observer.observe(video)); +} + /** * Initialize all gallery interactions * @param {HTMLElement} gallery - The .showcase-gallery element @@ -721,6 +766,8 @@ export function initShowcaseContent(gallery) { // Gallery just (re)rendered expanded: warm the cache for the // examples adjacent to the active one prefetchAdjacentMedia(); + // Video thumbnails start at preload="none"; enable them on visibility + initStripVideoLazyLoading(gallery); } // Reposition controls on window resize diff --git a/tests/frontend/components/showcase.gallery.test.js b/tests/frontend/components/showcase.gallery.test.js index c1a3e41f..57883c3d 100644 --- a/tests/frontend/components/showcase.gallery.test.js +++ b/tests/frontend/components/showcase.gallery.test.js @@ -257,4 +257,58 @@ describe('Showcase gallery', () => { vi.unstubAllGlobals(); }); + + it('prefetches one extra example ahead along the navigation direction', async () => { + const { renderShowcaseContent, initShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE); + + const prefetched = []; + class MockImage { + set src(value) { prefetched.push(value); } + set fetchPriority(_value) { /* jsdom lacks fetchPriority */ } + } + vi.stubGlobal('Image', MockImage); + + // Unique URLs: the module-level prefetch dedup set persists across tests + const images = [0, 1, 2, 3, 4].map(i => ({ + url: `https://image.civitai.com/pd/${i}.jpeg`, width: 100, height: 100, nsfwLevel: 0, + })); + document.body.innerHTML = `
${renderShowcaseContent(images, [], PREVIEW_URL, true)}
`; + initShowcaseContent(document.querySelector('.showcase-gallery')); + + // Pin position, then step forward: prefetch reaches +2 ahead (index 3) + updateMainDisplay(0); + updateMainDisplay(1); + expect(prefetched).toContain('https://image.civitai.com/pd/2.jpeg'); + expect(prefetched).toContain('https://image.civitai.com/pd/3.jpeg'); + + // Step backward: prefetch reaches -2 ahead (index 4 wrapping around) + updateMainDisplay(0); + expect(prefetched).toContain('https://image.civitai.com/pd/4.jpeg'); + + vi.unstubAllGlobals(); + }); + + it('defers video thumbnail metadata fetches until the strip shows them', async () => { + const { renderShowcaseContent, initShowcaseContent } = await import(SHOWCASE_MODULE); + + const images = [ + { url: 'https://image.civitai.com/lv/fff.jpeg', width: 100, height: 100, nsfwLevel: 0 }, + { url: 'https://image.civitai.com/lv/ggg.mp4', width: 100, height: 100, nsfwLevel: 0 }, + ]; + document.body.innerHTML = `
${renderShowcaseContent(images, [], PREVIEW_URL, true)}
`; + + const video = document.querySelector('.gallery-strip video'); + expect(video?.getAttribute('preload')).toBe('none'); + expect(video?.hasAttribute('data-lazy-video')).toBe(true); + + // jsdom's HTMLMediaElement.load() is a not-implemented stub that logs + const loadSpy = vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => {}); + + // jsdom has no IntersectionObserver → fallback enables everything at once + initShowcaseContent(document.querySelector('.showcase-gallery')); + expect(video.preload).toBe('metadata'); + expect(video.hasAttribute('data-lazy-video')).toBe(false); + expect(loadSpy).toHaveBeenCalled(); + loadSpy.mockRestore(); + }); });