Skip to content

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:

  1. hls.js config override: tune bitrate caps, retry counts, or level limits through the hlsConfig setup option.
  2. Stream interceptor: intercept manifest and segment responses before hls.js sees them via StreamRegistry.intercept(). Useful for signing URLs or transforming responses.
  3. 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

TypeScript
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

TypeScript
import type {
IStreamFactory,
IStreamSource,
StreamFactoryOptions,
StreamCapabilities,
} from '@nomercy-entertainment/nomercy-player-core/adapters/stream';
TypeScript
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

TypeScript
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:

EventPayloadWhen
manifest-loadedundefinedPlaylist / header parsed; levels are available
level-switchedLevelSwitchedDataABR or manual call changed the active quality
level-considered{ level: number; reason: string }ABR evaluated but did not switch
fragment-loadedFragLoadedDataA media segment finished downloading
encrypted{ keyUri: string; keyFormat?: string }An encrypted segment was encountered
errorMediaError | ErrorDataA fatal or non-fatal error occurred

Minimal Shaka Player factory (DASH)

TypeScript
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:

TypeScript
player.registerStream(new ShakaStreamFactory());

From this point, any .mpd URL in the playlist resolves through Shaka instead of hls.js or the native element.

  • Streams: the full IStreamFactory / IStreamSource reference 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