Skip to content

Custom Adapters

The video player uses four adapter ports for pluggable storage and data loading. This page shows how to implement custom adapters for each port.

Prerequisites: Read Adapters, Video Backend, Adapters, Chapter Source, Adapters, Thumbnail Source, and Adapters, Subtitle Style Store first, as they cover the interface contracts. This page focuses on non-trivial implementations.

ISubtitleStyleStore, IndexedDB backend

The default StorageBackedSubtitleStyleStore writes to localStorage. For larger style objects or cross-tab sync, use IndexedDB:

TypeScript
import type { ISubtitleStyleStore } from '@nomercy-entertainment/nomercy-video-player';
import type { SubtitleStyle } from '@nomercy-entertainment/nomercy-player-core';

const DB_NAME = 'player-preferences';
const STORE_NAME = 'subtitle-styles';

class IndexedDbSubtitleStyleStore implements ISubtitleStyleStore {
private dbPromise: Promise<IDBDatabase>;

constructor() {
this.dbPromise = this.openDb();
}

private openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1);
request.onupgradeneeded = () => {
request.result.createObjectStore(STORE_NAME);
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}

async load(): Promise<SubtitleStyle | null> {
const db = await this.dbPromise;
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readonly');
const request = transaction.objectStore(STORE_NAME).get('default');
request.onsuccess = () => resolve(request.result ?? null);
request.onerror = () => reject(request.error);
});
}

async save(style: SubtitleStyle): Promise<void> {
const db = await this.dbPromise;
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readwrite');
const request = transaction.objectStore(STORE_NAME).put(style, 'default');
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
}

// Persist on every style change, restore after ready.
const styleStore = new IndexedDbSubtitleStyleStore();

player.on('subtitleStyle', (style) => {
void styleStore.save(style);
});

player.on('ready', async () => {
const saved = await styleStore.load();
if (saved) player.subtitleStyle(saved);
});

IChapterSource, cache-aside pattern

When chapters come from an API, avoid refetching per-item with a simple cache:

TypeScript
import { Plugin } from '@nomercy-entertainment/nomercy-player-core';
import type { Chapter } from '@nomercy-entertainment/nomercy-player-core';
import type { NMVideoPlayer, VideoPlaylistItem } from '@nomercy-entertainment/nomercy-video-player';

// IChapterSource is an adapter interface — there is no chapterSource config field.
// Build a Plugin that owns the cache, loads on 'current', and emits 'chapters'.
class CachedApiChapterSourcePlugin extends Plugin<NMVideoPlayer<VideoPlaylistItem>> {
static readonly id = 'cached-api-chapter-source';
static readonly version = '1.0.0';

private cache = new Map<string, Chapter[]>();

override use(): void {
this.on('current', async ({ item }) => {
if (!item) return;
const key = String(item.id);
if (this.cache.has(key)) {
this.player.emit('chapters', { chapters: this.cache.get(key)! });
return;
}

let chapterData: Array<{ id: string; start_ms: number; end_ms: number; title: string }>;
try {
chapterData = await this.fetch<Array<{ id: string; start_ms: number; end_ms: number; title: string }>>(
`/api/v1/chapters/${key}`,
{ responseType: 'json' },
);
} catch {
return;
}

const chapters: Chapter[] = chapterData.map((entry, index) => ({
index,
start: entry.start_ms / 1000,
end: entry.end_ms / 1000,
title: entry.title,
}));

this.cache.set(key, chapters);
this.player.emit('chapters', { chapters });
});
}

invalidate(itemId: string | number): void {
this.cache.delete(String(itemId));
}
}

player.addPlugin(CachedApiChapterSourcePlugin);

IThumbnailSource, sprite-sheet from API

When your server generates preview sprites on demand instead of serving a static VTT file:

TypeScript
import type {
IThumbnailSource,
ThumbnailFrame,
VideoPlaylistItem,
} from '@nomercy-entertainment/nomercy-video-player';

// IThumbnailSource is an adapter interface — there is no thumbnailSource config field.
// Implement it inside your own UI plugin that owns the scrub-preview rendering.
// VttSpriteThumbnailSource (the built-in) reads item.previewSpriteUrl automatically.
// Only implement IThumbnailSource when your server returns a non-VTT format.
class ApiThumbnailSource implements IThumbnailSource {
private cache: ThumbnailFrame[] = [];
private currentItemId: string | number | undefined;

async load(item: VideoPlaylistItem): Promise<boolean> {
if (this.currentItemId === item.id) return this.cache.length > 0;
this.currentItemId = item.id;
this.cache = [];

type ApiFrame = { url: string; x: number; y: number; width: number; height: number; start: number; end: number };
let frames: ApiFrame[];
try {
const response = await fetch(`/api/v1/thumbnails/${item.id}`);
frames = await response.json() as ApiFrame[];
} catch {
return false;
}

this.cache = frames.map((frame) => ({
spriteUrl: frame.url,
x: frame.x,
y: frame.y,
w: frame.width,
h: frame.height,
start: frame.start,
end: frame.end,
}));

return this.cache.length > 0;
}

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

unload(): void {
this.cache = [];
this.currentItemId = undefined;
}
}

// Use your source inside a UI plugin — pass it to the scrub-preview handler:
// const thumbnailSource = new ApiThumbnailSource();
// In your plugin's 'current' handler: await thumbnailSource.load(item);
// In your scrub handler: const frame = thumbnailSource.lookup(timeSeconds);

lookup() is called synchronously on every scrub hover event. load() is called once per playlist item when it loads, so fetch and cache all frames there. Keep lookup() a pure array scan with no I/O.

IStorage (core), MemoryStorageBackend

The player core’s IStorage port controls where plugin state (volume preference, subtitle index, repeat mode) is persisted. For testing or SSR environments where localStorage is unavailable:

TypeScript
import { MemoryStorageBackend } from '@nomercy-entertainment/nomercy-player-core';

player.setup({
storage: new MemoryStorageBackend(),
});

For a cross-tab storage adapter, see Core, Storage.