Adapter: Video Backend
IVideoBackend is the one port every video-specific player method (play(), time(), subtitles(), qualityLevels(), and the rest) reads and writes through, NMVideoPlayer itself never touches <video> or hls.js directly. The shipped default, Html5VideoBackend, passes HLS through natively on Safari and falls back to hls.js everywhere else, this is the swap point for MSE, WebCodecs, or a native-shell <video> bridge instead.
The interface
interface IVideoBackend {
readonly kind: VideoBackendKind; // 'html5' | 'mse' | 'webcodecs'
readonly canStartAt?: boolean;
load(url: string, opts?: { preload?: HtmlPreloadMode; startTime?: number }): Promise<void>;
unload(): void;
dispose(): void;
setAuthHeaderProvider?(provider: () => string | undefined | Promise<string | undefined>): void;
play(): Promise<void>;
pause(): void;
stop(): void;
currentTime(): number;
currentTime(seconds: number): void;
duration(): number;
buffered(): number;
bufferedRanges(): TimeRanges;
seekable(): TimeRanges;
playbackRate(): number;
playbackRate(rate: number): void;
volume(): number;
volume(level: number): void;
mute(): void;
unmute(): void;
videoWidth(): number;
videoHeight(): number;
audioTracks(): AudioTrack[];
setAudioTrack(idx: number): void;
subtitleTracks(): SubtitleTrack[];
setSubtitleTrack(idx: number | null): void;
qualityLevels(): QualityLevel[];
qualityLevels(opts: { includeUnsupported: true }): QualityLevel[];
setQuality(idx: number | 'auto'): void;
currentLevel(): number;
state(): BackendState;
mediaElement(): HTMLVideoElement;
captureStream(): MediaStream;
setSinkId(deviceId: string): Promise<void>;
getSinkId(): string;
outputNode?(ctx: AudioContext): AudioNode;
analysisNode?(ctx: AudioContext): AudioNode;
mediaKeys(): MediaKeys | undefined;
setMediaKeys(keys: MediaKeys): Promise<void>;
outputProtectionState(): 'unrestricted' | 'restricted' | 'unsupported';
pauseLoader(): void;
resumeLoader(): void;
loaderState(): BackendLoaderState;
on<E extends BackendEvent>(event: E, fn: (data?: BackendEventPayload[E]) => void): void;
off<E extends BackendEvent>(event: E, fn: (data?: BackendEventPayload[E]) => void): void;
}
Method conventions mirror the player class itself: stateful values are overloaded functions (volume() / volume(level)), actions are bare verbs (play(), pause(), mute()), and seeking goes through currentTime(seconds), there is no separate seek() method at this layer either.
The shipped implementation
Html5VideoBackend wraps a real <video> element. Native HLS on Safari, hls.js (dynamically imported, so the bundle cost is zero on Safari) everywhere else, one call surface either way, player.play() never branches on browser. It applies HDR-aware level constraints itself (capping or lifting HLS renditions as the display’s HDR capability changes) and emits the full BackendEventPayload map (levels, level-switched, audioTracks, stream:error, stream:recovering, subtitleCue, and the standard media element events) that NMVideoPlayer bridges onto its own public event surface. ABR bandwidth estimation (bandwidth() / bandwidthEstimator()) and dropped-frame metrics are backend-agnostic instead: the kit’s own abrMethods mixin owns the former, NMVideoPlayer’s own timeupdate bridging polls mediaElement().getVideoPlaybackQuality() for the latter, both work the same regardless of which IVideoBackend is active.
canStartAt reports whether the backend consumes startTime natively, when true, the kit skips its own post-load seek fallback, hls.js supports this directly (startPosition), so a resume-at-offset load fetches the correct segment from the first request instead of downloading and discarding the stream’s beginning.
Auth on manifest and segment requests
setAuthHeaderProvider(provider) is how Authorization: Bearer <token> reaches hls.js’s own manifest and segment requests, not just the kit’s fetch calls. NMVideoPlayer.backend() wires this automatically from setup({ auth }), resolving the bearer value fresh (sync or async getter) on every request rather than capturing it once, see Recipe: Auth Tokens via baseUrl + auth.
Rules & restrictions
backend()constructs the instance lazily, on first call, and caches it, calling it beforesetup()returns an instance not yet wired to a loaded source.- Only one backend is active per player at a time.
player.backend(kind)swaps it mid-session, disposing the previous instance and emittingbackend:changed, it is not itself a crossfade, whatever was playing stops and the new backend comes up with no source loaded, so you reload to resume playback. See Reference: Video Player Methods. Cast handoff is a separate,transferTo()/ plugin-level concern, unrelated to this swap. mediaKeys()/setMediaKeys()/outputProtectionState()exist for EME/DRM coordination (see Plugin: DRM), a backend that never touches encrypted content can implement them as no-ops.outputNode/analysisNodeare optional, only implement them if the backend wants to supportAudioGraphPlugin-style Web Audio taps (equalizer, spectrum visualizers, re-exported from the kit); a backend that never builds a Web Audio graph pays zero cost by omitting them.
How to extend it
Provide a backendFactory at setup(), it receives the resolved kind and full config, and returns any IVideoBackend implementation:
import nmplayer from '@nomercy-entertainment/nomercy-video-player';
import type { VideoBackendFactory } from '@nomercy-entertainment/nomercy-video-player';
const player = nmplayer('player');
const backendFactory: VideoBackendFactory = (kind, config) => {
if (kind === 'webcodecs') return new MyWebCodecsBackend(config);
return new Html5VideoBackend(container); // fall back to the default for anything else
};
player.setup({
baseUrl,
playlist,
backendFactory,
});
kind is currently always 'html5' from the kit’s own resolution unless your factory (or a plugin) requests otherwise, 'mse' and 'webcodecs' are reserved labels for a custom factory to branch on, not backends this package ships.
Next steps
- Adapter: Chapter Source: a much smaller, optional adapter for chapter lookup outside the built-in
chapters()surface.