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 = `