Skip to content

Custom Adapter

When the default adapter implementations don’t fit: async persistence (IndexedDB Workers), translations from a CMS, or any other infrastructure replacement.

Prerequisites: Core: Adapters for the port catalog. Core: Testing for the test harness.

What an adapter is

An adapter is a class that implements a typed interface from the core. You wire it through setup() and the player and its plugins use your implementation instead of the default.

There are 28 core-level ports plus 4 video-specific and 6 music-specific ones. This guide walks through two of the most commonly replaced ones:

  1. IStorage: persistence backend (synchronous interface, but you can build async internally)
  2. ITranslator: translation backend (load strings from a CMS or i18n service)

IStorage: custom persistence backend

The interface

TypeScript
interface IStorage {
get(key: string): string | null | Promise<string | null>;
set(key: string, value: string): void | Promise<void>;
remove(key: string): void | Promise<void>;
getJSON<T>(key: string): T | null | Promise<T | null>;
setJSON<T>(key: string, value: T): void | Promise<void>;
}

Methods may return values or Promises, so synchronous backends (localStorage) and asynchronous ones (IndexedDB, remote API) share one interface. Plugin code always uses await. For large async datasets, the IndexedDBBackend (ships with the core) covers the most common case.

Custom backend backed by IndexedDB Workers

This pattern uses a local synchronous cache (for immediate reads) with an async IndexedDB writer via a Web Worker:

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

export class IndexedDBWorkerBackend implements IStorage {
private cache: Map<string, string> = new Map();
private worker: Worker;
private dbName: string;

constructor(dbName: string) {
this.dbName = dbName;
// The worker handles actual IndexedDB reads/writes off the main thread
this.worker = new Worker(new URL('./idb-storage-worker.js', import.meta.url), {
type: 'module',
});

// Sync the cache from IndexedDB on construction:
this.init();
}

private async init(): Promise<void> {
const allValues = await this.workerRequest<Record<string, string>>({
type: 'get-all',
dbName: this.dbName,
});
for (const [key, value] of Object.entries(allValues)) {
this.cache.set(key, value);
}
}

get(key: string): string | null {
// Reads from the in-memory cache, always fast and synchronous
return this.cache.get(key) ?? null;
}

set(key: string, value: string): void {
// Write to cache immediately (synchronous read is consistent)
this.cache.set(key, value);
// Flush to IndexedDB asynchronously via the worker
this.worker.postMessage({ type: 'set', dbName: this.dbName, key, value });
}

remove(key: string): void {
this.cache.delete(key);
this.worker.postMessage({ type: 'remove', dbName: this.dbName, key });
}

clear(): void {
this.cache.clear();
this.worker.postMessage({ type: 'clear', dbName: this.dbName });
}

private workerRequest<T>(message: object): Promise<T> {
return new Promise((resolve) => {
const id = Math.random().toString(36).slice(2);
const handler = (event: MessageEvent) => {
if (event.data.id === id) {
this.worker.removeEventListener('message', handler);
resolve(event.data.result as T);
}
};
this.worker.addEventListener('message', handler);
this.worker.postMessage({ ...message, id });
});
}

// Call this when the player is disposed to terminate the worker:
dispose(): void {
this.worker.terminate();
}
}

Use it:

TypeScript
const storage = new IndexedDBWorkerBackend('my-player-db');

player.setup({
storage,
});

// Terminate the worker when the player is disposed:
player.on('dispose', () => storage.dispose());

Error handling in adapters

If your adapter can fail (network error, worker crash), do not throw. Return null from get and silently fail on set. The player is resilient to storage failures:

TypeScript
get(key: string): string | null {
try {
return this.cache.get(key) ?? null;
} catch {
return null; // never throw, storage is non-critical
}
}

ITranslator: custom translation backend

The interface

TypeScript
interface ITranslator {
t(key: string, vars?: Record<string, string>): string;
language(): string;
language(lang: string): Promise<void>;
addTranslations(bundle: Translations): void;
translation(lang: string, key: string): string | undefined;
translation(lang: string, key: string, value: string): void;
removeTranslations(prefix: string, lang?: string): void;
dispose(): void;
}

Variable interpolation uses single-brace {var} syntax: 'Hello {name}'.

CMS-backed translator

Load translation strings from a CMS (e.g. Contentful, Sanity, or your own API) and cache them:

TypeScript
import type { ITranslator, Translations } from '@nomercy-entertainment/nomercy-player-core';

export class CmsTranslator implements ITranslator {
private strings: Record<string, Record<string, string>> = {};
private currentLang = 'en';

constructor(private cmsUrl: string) {}

// Load all strings for the given locale. Call this before player.setup():
async load(lang: string): Promise<void> {
const response = await fetch(`${this.cmsUrl}/player-strings/${lang}`);
if (!response.ok) throw new Error(`Failed to load translations for ${lang}`);
this.strings[lang] = await response.json();
}

t(key: string, vars?: Record<string, string>): string {
const base = this.strings[this.currentLang]?.[key] ?? key;
if (!vars) return base;

// Single-brace {variable} interpolation:
return base.replace(/\{(\w+)\}/g, (_, name) => vars[name] ?? `{${name}}`);
}

language(): string;
language(lang: string): Promise<void>;
language(lang?: string): string | Promise<void> {
if (lang === undefined) return this.currentLang;
this.currentLang = lang;
return this.load(lang);
}

addTranslations(bundle: Translations): void {
for (const [lang, keys] of Object.entries(bundle)) {
this.strings[lang] = { ...(this.strings[lang] ?? {}), ...keys };
}
}

translation(lang: string, key: string): string | undefined;
translation(lang: string, key: string, value: string): void;
translation(lang: string, key: string, value?: string): string | undefined | void {
if (value === undefined) return this.strings[lang]?.[key];
if (!this.strings[lang]) this.strings[lang] = {};
this.strings[lang]![key] = value;
}

removeTranslations(prefix: string, lang?: string): void {
const langs = lang ? [lang] : Object.keys(this.strings);
for (const l of langs) {
for (const k of Object.keys(this.strings[l] ?? {})) {
if (k.startsWith(prefix)) delete this.strings[l]![k];
}
}
}

dispose(): void {
for (const lang of Object.keys(this.strings)) {
delete this.strings[lang];
}
}
}

Wire it up, load before setup():

TypeScript
const translator = new CmsTranslator('https://cms.example.com/api');

// Load the strings before setting up the player:
await translator.load('en');

player.setup({
translator,
});

Lifecycle hooks

Some adapter interfaces include lifecycle callbacks. For example, IRealtimeChannel exposes lifecycle events ('open', 'close') that the player’s lifecycle layer responds to. Always implement the full interface shape — the core enforces the contract.

TypeScript
interface IRealtimeChannel {
send(data: string | ArrayBuffer | Blob): void;
close(code?: number, reason?: string): void;
on(event: 'open' | 'message' | 'close' | 'error', fn: (data?: unknown) => void): void;
off(event: 'open' | 'message' | 'close' | 'error', fn: (data?: unknown) => void): void;
readonly readyState: 'connecting' | 'open' | 'closing' | 'closed';
}

Testing your adapter

Use MemoryStorageBackend as the baseline in tests, it satisfies IStorage without any browser APIs. Test your custom backend in isolation:

TypeScript
import { describe, it, expect, beforeEach } from 'vitest';
import { IndexedDBWorkerBackend } from './idb-worker-backend';

describe('IndexedDBWorkerBackend', () => {
let backend: IndexedDBWorkerBackend;

beforeEach(() => {
backend = new IndexedDBWorkerBackend('test-db');
});

afterEach(() => {
backend.dispose();
});

it('returns null for missing keys', () => {
expect(backend.get('missing')).toBeNull();
});

it('stores and retrieves values from cache', () => {
backend.set('volume', '0.8');
expect(backend.get('volume')).toBe('0.8');
});

it('removes keys', () => {
backend.set('volume', '0.8');
backend.remove('volume');
expect(backend.get('volume')).toBeNull();
});

it('clears all keys', () => {
backend.set('a', '1');
backend.set('b', '2');
backend.clear();
expect(backend.get('a')).toBeNull();
expect(backend.get('b')).toBeNull();
});
});

For testing a plugin that uses your adapter, wire MemoryStorageBackend through setup() in the core test harness.

TypeScript
import { describePlugin } from '@nomercy-entertainment/nomercy-player-core/testing';
import { MemoryStorageBackend } from '@nomercy-entertainment/nomercy-player-core';
import { MyPlugin } from './my-plugin';

describePlugin(MyPlugin, ({ player }) => {
// The test player already uses MemoryStorageBackend by default
it('reads from storage', () => {
player.storage.set('my-key', 'my-value');
// ...test behavior that depends on storage
});
});