diff --git a/packages/vidstack/src/components/ui/thumbnails/thumbnail-preload.test.ts b/packages/vidstack/src/components/ui/thumbnails/thumbnail-preload.test.ts new file mode 100644 index 000000000..2b5e11efa --- /dev/null +++ b/packages/vidstack/src/components/ui/thumbnails/thumbnail-preload.test.ts @@ -0,0 +1,164 @@ +import { createComponent, provideContext, root, tick } from 'maverick.js'; +import { vi } from 'vitest'; + +import { mediaContext } from '../../../core/api/media-context'; +import { mediaState } from '../../../core/api/player-state'; +import { Thumbnail } from './thumbnail'; +import type { ThumbnailImageInit } from './thumbnail-loader'; + +interface Preload { + src: string; + crossOrigin: string | null; +} + +let preloads: Preload[]; + +function setupThumbnail( + src: ThumbnailImageInit[], + time: number, + crossOrigin: 'anonymous' | 'use-credentials' | null = null, +) { + let dispose!: () => void; + + const $state = mediaState.create(); + $state.canLoad.set(true); + $state.providedDuration.set(120); + + const rootEl = document.createElement('div'), + img = document.createElement('img'); + + Object.defineProperties(img, { + naturalWidth: { value: 1800 }, + naturalHeight: { value: 1012.5 }, + }); + + const thumbnail = root((stop) => { + dispose = stop; + provideContext(mediaContext, { $state } as any); + + const instance = createComponent(Thumbnail, { + props: { src, time, crossOrigin }, + }) as any; + + instance.$$.setup(); + instance.$state.img.set(img); + instance.$$.attach(rootEl); + instance.$$.connect(); + + return instance; + }) as any; + + tick(); + + return { + dispose, + thumbnail, + }; +} + +describe(Thumbnail.name, function () { + beforeEach(function () { + preloads = []; + + vi.stubGlobal( + 'Image', + class { + crossOrigin: string | null = null; + decoding = ''; + #src = ''; + + get src() { + return this.#src; + } + + set src(src: string) { + this.#src = src; + preloads.push({ src, crossOrigin: this.crossOrigin }); + } + }, + ); + }); + + afterEach(function () { + vi.unstubAllGlobals(); + }); + + it('preloads upcoming sprites in a bounded window', function () { + const thumbnails: ThumbnailImageInit[] = [ + { + url: 'https://sprites.example.test/seek/_0.jpg', + startTime: 0, + width: 300, + height: 168.75, + coords: { x: 0, y: 0 }, + }, + { + url: 'https://sprites.example.test/seek/_0.jpg', + startTime: 10, + width: 300, + height: 168.75, + coords: { x: 300, y: 0 }, + }, + { + url: 'https://sprites.example.test/seek/_1.jpg', + startTime: 20, + width: 300, + height: 168.75, + coords: { x: 0, y: 0 }, + }, + { + url: 'https://sprites.example.test/seek/_1.jpg', + startTime: 30, + width: 300, + height: 168.75, + coords: { x: 300, y: 0 }, + }, + { + url: 'https://sprites.example.test/seek/_2.jpg', + startTime: 40, + width: 300, + height: 168.75, + coords: { x: 0, y: 0 }, + }, + { + url: 'https://sprites.example.test/seek/_3.jpg', + startTime: 50, + width: 300, + height: 168.75, + coords: { x: 0, y: 0 }, + }, + ]; + + const { dispose, thumbnail } = setupThumbnail(thumbnails, 0, 'use-credentials'); + + try { + expect(preloads).to.deep.equal([ + { + src: 'https://sprites.example.test/seek/_1.jpg', + crossOrigin: 'use-credentials', + }, + ]); + + thumbnail.$props.time.set(10); + tick(); + + expect(preloads).to.have.length(1); + + thumbnail.$props.time.set(20); + tick(); + + expect(preloads).to.deep.equal([ + { + src: 'https://sprites.example.test/seek/_1.jpg', + crossOrigin: 'use-credentials', + }, + { + src: 'https://sprites.example.test/seek/_2.jpg', + crossOrigin: 'use-credentials', + }, + ]); + } finally { + dispose(); + } + }); +}); diff --git a/packages/vidstack/src/components/ui/thumbnails/thumbnail.ts b/packages/vidstack/src/components/ui/thumbnails/thumbnail.ts index 5d6137fc6..1756df743 100644 --- a/packages/vidstack/src/components/ui/thumbnails/thumbnail.ts +++ b/packages/vidstack/src/components/ui/thumbnails/thumbnail.ts @@ -7,6 +7,10 @@ import { $ariaBool } from '../../../utils/aria'; import { round } from '../../../utils/number'; import { ThumbnailsLoader, type ThumbnailImage, type ThumbnailSrc } from './thumbnail-loader'; +const PRELOAD_SPRITE_BATCH_SIZE = 2, + MAX_PRELOADED_SPRITES = 32, + preloadedSprites = new Map(); + /** * Used to load and display a preview thumbnail at the given `time`. * @@ -58,6 +62,7 @@ export class Thumbnail extends Component { effect(this.#watchCrossOrigin.bind(this)); effect(this.#onLoadStart.bind(this)); effect(this.#onFindActiveThumbnail.bind(this)); + effect(this.#onPreloadNextSprites.bind(this)); effect(this.#resize.bind(this)); } @@ -153,6 +158,29 @@ export class Thumbnail extends Component { src.set(activeImage?.url.href || ''); } + #onPreloadNextSprites() { + if (__SERVER__) return; + + const images = this.#loader.$images(), + activeThumbnail = this.$state.activeThumbnail(); + + if (!activeThumbnail) return; + + const activeIndex = images.indexOf(activeThumbnail); + + if (activeIndex < 0) return; + + const activeSrc = activeThumbnail.url.href, + crossOrigin = this.$state.crossOrigin(), + startIndex = activeIndex + 1, + endIndex = Math.min(startIndex + PRELOAD_SPRITE_BATCH_SIZE, images.length); + + for (let i = startIndex; i < endIndex; i++) { + const src = images[i].url.href; + if (src !== activeSrc) preloadSprite(src, crossOrigin); + } + } + #resize() { if (!this.scope || this.$state.hidden()) return; @@ -242,3 +270,27 @@ export interface ThumbnailState { error: ErrorEvent | null; hidden: boolean; } + +function preloadSprite(src: string, crossOrigin: MediaCrossOrigin | null) { + const key = `${src}::${crossOrigin}`; + + if (preloadedSprites.has(key)) { + const image = preloadedSprites.get(key)!; + preloadedSprites.delete(key); + preloadedSprites.set(key, image); + return; + } + + const image = new Image(); + + image.decoding = 'async'; + image.crossOrigin = crossOrigin; + image.src = src; + + preloadedSprites.set(key, image); + + if (preloadedSprites.size > MAX_PRELOADED_SPRITES) { + const firstKey = preloadedSprites.keys().next().value!; + preloadedSprites.delete(firstKey); + } +}