Recipe: Swap an Adapter
Adapters & Dependency Injection covers the mechanism: every non-lifecycle behavior is a named interface with a working default, swapped by naming the field in setup(). This recipe is a real task built on that mechanism: the default LocalStorageBackend is synchronous and capped around 5-10 MB per origin, which is a real ceiling for cached metadata or an offline queue.
The swap
IndexedDBBackend implements the same five-method IStorage interface as the default, so nothing that already calls this.storage (inside a plugin) or reads through the port needs to change. Only the setup() call does.
/**
* Recipe: swap the storage adapter for IndexedDB.
*
* The default `LocalStorageBackend` is fine for small values (volume, quality
* preference, a few flags), but it caps out around 5-10 MB per origin and is
* synchronous, which can jank a hot path. `IndexedDBBackend` is fully async
* and comfortable with much larger values (cached metadata, offline queues).
* Swapping is one field at `setup()` — `IStorage`'s five methods are the same
* shape on both, so a plugin's `this.storage` calls don't change at all.
*/
import type { BaseEventMap, IPlayer } from '@nomercy-entertainment/nomercy-player-core';
import { IndexedDBBackend, Plugin } from '@nomercy-entertainment/nomercy-player-core';
import { tourPlayer } from './tour-player';
interface CachedRecent {
ids: string[];
}
class RecentlyPlayedPlugin extends Plugin<IPlayer<BaseEventMap>> {
static override readonly id = 'recently-played';
async remember(itemId: string): Promise<void> {
const existing = (await this.storage.getJSON<CachedRecent>('recent')) ?? { ids: [] };
existing.ids = [itemId, ...existing.ids.filter((id) => id !== itemId)].slice(0, 20);
await this.storage.setJSON('recent', existing);
}
async recent(): Promise<string[]> {
return (await this.storage.getJSON<CachedRecent>('recent'))?.ids ?? [];
}
}
const player = tourPlayer('storage-swap-demo');
player.addPlugin(RecentlyPlayedPlugin);
player.setup({
logLevel: 'info',
storage: new IndexedDBBackend({ dbName: 'my-app-player', storeName: 'kv' }), // swap happens here
});
await player.ready();
const recentlyPlayed = player.getPlugin(RecentlyPlayedPlugin);
await recentlyPlayed?.remember('sintel');
console.log(await recentlyPlayed?.recent()); // ['sintel'] — persisted through IndexedDB, not localStorage
await player.dispose();
RecentlyPlayedPlugin never checks which backend is active. It calls this.storage.getJSON() / setJSON() exactly as it would against the default, the swap happens once, at the boundary, and every consumer downstream is unaffected.
Other ports swap the same way
Every port in the adapter table takes the identical shape: name the field, pass an instance. platform additionally supports a partial override ({ ...browserPlatform, wakeLock: myWakeLock }) for interfaces that bundle several sub-controllers, everything else is a full replace.
Next steps
- Recipe: Register a Custom Cue Parser: the same “swap at setup” shape, applied to a registry instead of a single interface.