Skip to content

Adapter: Thumbnail Source

IThumbnailSource answers “what sprite sub-region shows this timestamp” for a scrubber’s hover-preview thumbnail, load once per item, then a synchronous lookup(time) on every hover-move. Like Adapter: Chapter Source, this is a standalone contract, not a setup() field: DesktopUiPlugin’s own scrub-preview (see Plugin: Desktop UI) calls the same underlying sprite-parsing helpers directly rather than going through this interface, this adapter exists for custom scrubber UI built outside that plugin.

The interface

TypeScript
interface IThumbnailSource {
/** Fetch/cache thumbnails for item. Returns false when the item has no source. */
load(item: BasePlaylistItem): Promise<boolean>;

/** Frame covering timeSeconds, or null. Synchronous, called on every scrub event. */
lookup(timeSeconds: number): ThumbnailFrame | null;

/** Release cached data for the current item. */
unload(): void;
}

interface ThumbnailFrame {
spriteUrl: string;
x: number; y: number; w: number; h: number; // sub-region within spriteUrl, in pixels
start: number; end: number; // cue time range, in seconds
}

x/y/w/h are all zero for a source that packs one standalone image per cue instead of a sprite sheet, ThumbnailFrame covers both shapes with the same fields.

The shipped implementation

VttSpriteThumbnailSource reads item.previewSpriteUrl, fetches a WebVTT sprite manifest (cue body format sprite.webp#xywh=x,y,w,h, the format NoMercy’s media-server sprite generator produces, and compatible with ffmpeg tileify output and the common web-player sprite convention), and preloads the sprite image so lookup() stays synchronous once loaded:

TypeScript
import nmplayer from '@nomercy-entertainment/nomercy-video-player';

const player = nmplayer('player');

const source = new VttSpriteThumbnailSource();
const available = await source.load(player.item()!); // false if no previewSpriteUrl
if (available) {
const frame = source.lookup(45); // ThumbnailFrame | null
}

Rules & restrictions

  • load() resolves false (not a rejected promise) when the item has no previewSpriteUrl, treat that as “no preview available,” not an error condition.
  • lookup() must stay synchronous in any implementation, it runs on every pointer-move across the scrubber.
  • The default implementation caches exactly one item’s sprite set at a time, call load() again on every item change, there’s no automatic per-item cache eviction to configure.

How to extend it

Implement against a different manifest format, or a source that returns one full-size frame per cue instead of sprite sub-regions:

TypeScript
class ApiThumbnailSource implements IThumbnailSource {
private frames: ThumbnailFrame[] = [];

async load(item: BasePlaylistItem): Promise<boolean> {
this.frames = await fetchThumbnailManifest(item.id);
return this.frames.length > 0;
}

lookup(timeSeconds: number): ThumbnailFrame | null {
return this.frames.find((f) => timeSeconds >= f.start && timeSeconds < f.end) ?? null;
}

unload(): void {
this.frames = [];
}
}

Wire load() from a 'mediaReady' listener and lookup() from your scrubber’s pointer-move handler in your own overlay code, same as Adapter: Chapter Source, there is no automatic hook.

Next steps