Writing a Backend
Supporting a streaming protocol or media pipeline the built-in backends don’t cover: MPEG-DASH, smooth streaming, or a custom MSE pipeline.
The extension point is IStreamFactory and IStreamSource from @nomercy-entertainment/nomercy-player-core/adapters/stream.
Prerequisites: Streams covers the default HLS and native stream factories. Read it first, because many needs can be addressed by configuring hls.js rather than writing a new factory.
When to write a custom stream factory
Before writing a factory, check whether the need can be addressed with lower-effort options:
- hls.js config override: tune bitrate caps, retry counts, or level limits through the
hlsConfigsetup option. - Stream interceptor: intercept manifest and segment responses before hls.js sees them via
StreamRegistry.intercept(). Useful for signing URLs or transforming responses. - Custom plugin: if the need is behavioral (custom ABR, buffer management), write a plugin that calls existing player methods rather than owning the media pipeline.
Replace the factory only when none of the above covers the use case.
Registering a custom factory
import { nmplayer } from '@nomercy-entertainment/nomercy-video-player';
import type { IStreamFactory } from '@nomercy-entertainment/nomercy-player-core/adapters/stream';
const player = nmplayer('main').setup({
playlist: [{ id: '1', url: 'https://example.com/manifest.mpd' }],
});
// Register before calling play():
player.registerStream(myDashFactory);
registerStream(factory) adds the factory to the StreamRegistry.
When a URL is resolved, each registered factory’s canPlay(url, contentType) is called in registration order.
The first factory that returns true wins.
The built-in HLS and native factories are always present as fallbacks.
The IStreamFactory interface
import type {
IStreamFactory,
IStreamSource,
StreamFactoryOptions,
StreamCapabilities,
} from '@nomercy-entertainment/nomercy-player-core/adapters/stream';
interface IStreamFactory {
readonly id: string;
canPlay(
url: string,
contentType?: string,
capabilities?: StreamCapabilities,
): boolean;
create(opts: StreamFactoryOptions): IStreamSource;
}
canPlay is called for every URL the player resolves.
Return true only when your factory can handle it.
Use the optional capabilities argument to decline when the device cannot decode the stream.
create is called at most once per URL resolution and must return an IStreamSource.
The IStreamSource interface
interface IStreamSource {
readonly kind: 'native' | 'hls' | 'dash';
/** Wire this source to a media element. Resolves once metadata is available. */
attach(element: HTMLMediaElement): Promise<void>;
/** Remove this source from the element without releasing internal state. */
detach(): void;
/** Detach and release all internal state. Unusable after this. */
destroy(): void;
/** Current lifecycle state. */
state(): StreamSourceState;
getLevels?(): StreamLevel[];
setLevel?(idx: number): void;
getCurrentLevel?(): StreamLevel | undefined;
setLevelStrategy?(
fn: (levels: StreamLevel[], ctx: { bandwidth: number; bufferedSeconds: number }) => number,
): void;
on<E extends StreamEvent>(event: E, fn: (data: StreamEventPayloadMap[E]) => void): void;
off<E extends StreamEvent>(event: E, fn: (data: StreamEventPayloadMap[E]) => void): void;
}
StreamSourceState is one of 'idle' | 'loading' | 'ready' | 'playing' | 'error'.
The events a source can emit are:
| Event | Payload | When |
|---|---|---|
manifest-loaded | undefined | Playlist / header parsed; levels are available |
level-switched | LevelSwitchedData | ABR or manual call changed the active quality |
level-considered | { level: number; reason: string } | ABR evaluated but did not switch |
fragment-loaded | FragLoadedData | A media segment finished downloading |
encrypted | { keyUri: string; keyFormat?: string } | An encrypted segment was encountered |
error | MediaError | ErrorData | A fatal or non-fatal error occurred |
Minimal Shaka Player factory (DASH)
import shaka from 'shaka-player';
import type {
IStreamFactory,
IStreamSource,
StreamFactoryOptions,
StreamLevel,
StreamEvent,
StreamEventPayloadMap,
StreamSourceState,
} from '@nomercy-entertainment/nomercy-player-core/adapters/stream';
const DASH_RE = /\.mpd(\?|$)/i;
export class ShakaStreamFactory implements IStreamFactory {
readonly id = 'shaka-dash';
canPlay(url: string, contentType?: string): boolean {
return DASH_RE.test(url) || contentType === 'application/dash+xml';
}
create(opts: StreamFactoryOptions): IStreamSource {
return new ShakaStreamSource(opts);
}
}
class ShakaStreamSource implements IStreamSource {
readonly kind = 'dash' as const;
private shakaPlayer: shaka.Player | null = null;
private listeners = new Map<string, Set<(data: any) => void>>();
private _state: StreamSourceState = 'idle';
private readonly url: string;
constructor(private opts: StreamFactoryOptions) {
this.url = opts.url;
}
async attach(element: HTMLMediaElement): Promise<void> {
this._state = 'loading';
this.shakaPlayer = new shaka.Player();
await this.shakaPlayer.attach(element);
this.shakaPlayer.addEventListener('variantchanged', () => {
const variant = this.shakaPlayer!.getVariantTracks().find((t) => t.active);
if (variant) {
this._emit('level-switched', { level: variant.id } as any);
}
});
try {
await this.shakaPlayer.load(this.url);
this._state = 'ready';
this._emit('manifest-loaded', undefined);
} catch (err) {
this._state = 'error';
this._emit('error', err as any);
throw err;
}
}
detach(): void {
this.shakaPlayer?.detach();
}
destroy(): void {
void this.shakaPlayer?.destroy();
this.shakaPlayer = null;
this.listeners.clear();
this._state = 'idle';
}
state(): StreamSourceState {
return this._state;
}
getLevels(): StreamLevel[] {
return (this.shakaPlayer?.getVariantTracks() ?? []).map((t) => ({
bitrate: t.bandwidth,
height: t.height ?? undefined,
width: t.width ?? undefined,
label: t.label ?? String(t.id),
index: t.id,
}));
}
setLevel(idx: number): void {
if (!this.shakaPlayer) return;
if (idx === -1) {
this.shakaPlayer.configure({ abr: { enabled: true } });
return;
}
const tracks = this.shakaPlayer.getVariantTracks();
const target = tracks.find((t) => t.id === idx);
if (target) this.shakaPlayer.selectVariantTrack(target, true);
}
getCurrentLevel(): StreamLevel | undefined {
const active = this.shakaPlayer?.getVariantTracks().find((t) => t.active);
if (!active) return undefined;
return {
bitrate: active.bandwidth,
height: active.height ?? undefined,
width: active.width ?? undefined,
label: active.label ?? String(active.id),
index: active.id,
};
}
on<E extends StreamEvent>(event: E, fn: (data: StreamEventPayloadMap[E]) => void): void {
if (!this.listeners.has(event)) this.listeners.set(event, new Set());
this.listeners.get(event)!.add(fn);
}
off<E extends StreamEvent>(event: E, fn: (data: StreamEventPayloadMap[E]) => void): void {
this.listeners.get(event)?.delete(fn);
}
private _emit<E extends StreamEvent>(event: E, data: StreamEventPayloadMap[E]): void {
this.listeners.get(event)?.forEach((fn) => fn(data));
}
}
Register it on the player:
player.registerStream(new ShakaStreamFactory());
From this point, any .mpd URL in the playlist resolves through Shaka instead of hls.js or the native element.
What to read next
- Streams: the full
IStreamFactory/IStreamSourcereference and the built-in HLS and native factories - Adapter Ports: every swappable port the core exposes
- Advanced: Custom Plugin: adding behavior on top of any backend