Skip to content

Recipe: Vanilla Integration

No framework needed at all: a single function that owns the player instance, wires whatever listeners it needs, and returns one destroy() that unwinds every one of them. Vue, React, and Svelte all wrap a version of exactly this pattern behind their own reactivity primitive; this page is the pattern itself, for a plain script tag, a Web Component, or any app that doesn’t reach for a framework.

The controller pattern

TypeScript
import type { IVideoPlayer } from '@nomercy-entertainment/nomercy-video-player';
import { PlayState } from '@nomercy-entertainment/nomercy-video-player';

interface PlayerSnapshot {
playing: boolean;
title: string;
currentTime: number;
duration: number;
}

function createPlayerController(
player: IVideoPlayer,
onChange: (snapshot: PlayerSnapshot) => void,
): () => void {
const emit = () => {
onChange({
playing: player.playState() === PlayState.PLAYING,
title: player.item()?.title ?? '',
currentTime: player.time(),
duration: player.duration(),
});
};

player.on('play', emit);
player.on('pause', emit);
player.on('playing', emit);
player.on('ended', emit);
player.on('item', emit);
player.on('time', emit);
player.on('duration', emit);
emit(); // initial snapshot

return () => {
player.off('play', emit);
player.off('pause', emit);
player.off('playing', emit);
player.off('ended', emit);
player.off('item', emit);
player.off('time', emit);
player.off('duration', emit);
};
}

One function in, one cleanup function out, no class, no subscription object to manage by hand. This is also the shape The Shell and every other Build a Player step already use for wiring a single button, createPlayerController is just that pattern generalized to a whole snapshot of state instead of one value.

Wiring it to real DOM

films is the real nomercy-media fixture catalogue (the same three shorts the Quickstart and the testbed use). The markup below already has a title, a progress slider, and a play/pause button in it, the same three elements every framework recipe in this section renders, createPlayerController’s snapshot just drives them directly instead of through a template:

TypeScript
import nmplayer from '@nomercy-entertainment/nomercy-video-player';
import type { VideoPlayerConfig } from '@nomercy-entertainment/nomercy-video-player';

const config: VideoPlayerConfig = {
baseUrl: 'https://raw.githubusercontent.com/NoMercy-Entertainment/nomercy-media/master/Films',
baseImageUrl: 'https://image.tmdb.org/t/p',
muted: true,
autoPlay: false,
playlist: [
{
id: 'sintel',
title: 'Sintel',
url: '/Sintel.(2010)/Sintel.(2010).NoMercy.m3u8',
image: '/w780/q2bVM5z90tCGbmXYtq2J38T5hSX.jpg',
},
{
id: 'cosmos-laundromat',
title: 'Cosmos Laundromat',
url: '/Cosmos.Laundromat.(2015)/Cosmos.Laundromat.(2015).NoMercy.m3u8',
image: '/w780/f2wABsgj2lIR2dkDEfBZX8p4Iyk.jpg',
},
{
id: 'big-buck-bunny',
title: 'Big Buck Bunny',
url: '/Big.Buck.Bunny.(2008)/Big.Buck.Bunny.(2008).NoMercy.m3u8',
image: '/w780/xtdybjRRZ15mCrPOvEld305myys.jpg',
},
],
};

const player = nmplayer('player')
.setup(config);

const title = document.querySelector<HTMLElement>('#now-playing')!;
const bar = document.querySelector<HTMLElement>('#progress')!;
const fill = document.querySelector<HTMLElement>('#progress-fill')!;
const button = document.querySelector<HTMLButtonElement>('#toggle')!;

const destroy = createPlayerController(player, (snapshot) => {
title.textContent = snapshot.title || 'Nothing playing';
const progress = snapshot.duration > 0 ? (snapshot.currentTime / snapshot.duration) * 100 : 0;
fill.style.width = `${progress}%`;
bar.setAttribute('aria-valuenow', String(progress));
button.textContent = snapshot.playing ? 'Pause' : 'Play';
});

function seek(event: MouseEvent): void {
const ratio = event.offsetX / bar.clientWidth;
player.seekByPercentage(ratio * 100);
}
bar.addEventListener('click', seek);

function toggle(): void {
void player.togglePlayback();
}
button.addEventListener('click', toggle);

// Later, when the player genuinely goes away (route change, modal close):
destroy();
bar.removeEventListener('click', seek);
button.removeEventListener('click', toggle);
void player.dispose();

destroy() only removes the listeners createPlayerController added, it does not dispose the player itself, the two are separate concerns on purpose. A page that mounts several independent controllers against the same player (a status readout, a queue panel, a volume indicator) can tear any one of them down without touching the others or the player instance they all share.

Wrapping it in a Web Component

The same controller works unchanged inside a custom element, connectedCallback / disconnectedCallback are the vanilla-DOM equivalent of a framework’s mount/unmount hooks:

TypeScript
import nmplayer from '@nomercy-entertainment/nomercy-video-player';

class VideoPlayerStatus extends HTMLElement {
#destroy: (() => void) | null = null;

connectedCallback() {
// `nmplayer(id)` returns the concrete `NMVideoPlayer` for an id already
// `.setup()` elsewhere on the page — it already satisfies `IVideoPlayer`
// structurally, no cast needed.
const player = nmplayer(this.getAttribute('for')!);
this.#destroy = createPlayerController(player, (snapshot) => {
this.textContent = snapshot.playing ? 'Playing' : 'Paused';
});
}

disconnectedCallback() {
this.#destroy?.();
this.#destroy = null;
}
}

customElements.define('video-player-status', VideoPlayerStatus);

See it running

The title, progress slider, and play/pause button below are createPlayerController wired to the live player, exactly the code on this page, nothing added for the preview.

TypeScript
/**
* Recipe: Vanilla Integration. No framework, no build-time component syntax,
* just a small controller function that owns the player instance and exposes
* a plain callback for state changes. This is the shape every framework
* wrapper (Vue composable, React hook, Svelte store) is built around
* underneath — see Recipes: Vue / React / Svelte for the same pattern
* adapted to each framework's own reactivity primitive.
*/

import nmplayer, {
type IVideoPlayer,
type VideoPlayerConfig,
} from '@nomercy-entertainment/nomercy-video-player';
import { PlayState } from '@nomercy-entertainment/nomercy-video-player';
const FILMS_BASE =
'https://raw.githubusercontent.com/NoMercy-Entertainment/nomercy-media/master/Films';

const films = [
{
id: 'sintel',
title: 'Sintel',
description:
'A short fantasy film by the Blender Foundation. Sintel searches for a baby dragon she calls Scales.',
url: '/Sintel.(2010)/Sintel.(2010).NoMercy.m3u8',
image: '/w780/q2bVM5z90tCGbmXYtq2J38T5hSX.jpg',
duration: 888,
year: 2010,
subtitles: [
{
id: 'eng',
label: 'English',
language: 'eng',
kind: 'subtitles',
url: '/Sintel.(2010)/subtitles/Sintel.(2010).NoMercy.eng.full.vtt',
},
{
id: 'dut',
label: 'Dutch',
language: 'dut',
kind: 'subtitles',
url: '/Sintel.(2010)/subtitles/Sintel.(2010).NoMercy.dut.full.vtt',
},
{
id: 'fre',
label: 'French',
language: 'fre',
kind: 'subtitles',
url: '/Sintel.(2010)/subtitles/Sintel.(2010).NoMercy.fre.full.vtt',
},
{
id: 'ger',
label: 'German',
language: 'ger',
kind: 'subtitles',
url: '/Sintel.(2010)/subtitles/Sintel.(2010).NoMercy.ger.full.vtt',
},
],
chapters: [
{ index: 0, start: 0, end: 107, title: 'Opening' },
{ index: 1, start: 107, end: 207, title: 'A Dangerous Quest' },
{ index: 2, start: 207, end: 338, title: 'Scales' },
{ index: 3, start: 338, end: 445, title: 'The Attack' },
{ index: 4, start: 445, end: 557, title: 'In Pursuit' },
{ index: 5, start: 557, end: 621, title: 'The Cave' },
{ index: 6, start: 621, end: 745, title: 'Eye to Eye' },
{ index: 7, start: 745, end: 888, title: 'End Credits' },
],
previewSpriteUrl: '/Sintel.(2010)/thumbs_256x109.vtt',
},
{
id: 'cosmos-laundromat',
title: 'Cosmos Laundromat',
description: 'On a desolate island, a suicidal sheep meets his fate.',
url: '/Cosmos.Laundromat.(2015)/Cosmos.Laundromat.(2015).NoMercy.m3u8',
image: '/w780/f2wABsgj2lIR2dkDEfBZX8p4Iyk.jpg',
duration: 724,
year: 2015,
subtitles: [
{
id: 'eng',
label: 'English',
language: 'eng',
kind: 'subtitles',
url: '/Cosmos.Laundromat.(2015)/subtitles/Cosmos.Laundromat.(2015).NoMercy.eng.full.vtt',
},
],
},
{
id: 'big-buck-bunny',
title: 'Big Buck Bunny',
description: 'A giant rabbit with a heart bigger than himself.',
url: '/Big.Buck.Bunny.(2008)/Big.Buck.Bunny.(2008).NoMercy.m3u8',
image: '/w780/xtdybjRRZ15mCrPOvEld305myys.jpg',
duration: 596,
year: 2008,
// Real chapter marks from the fixture's own `chapters.vtt`, in seconds.
chapters: [
{ index: 0, start: 0, end: 65, title: 'Opening' },
{ index: 1, start: 65, end: 162, title: 'A Beautiful Day' },
{ index: 2, start: 162, end: 250, title: 'The Bullies' },
{ index: 3, start: 250, end: 350, title: 'Plotting Revenge' },
{ index: 4, start: 350, end: 495, title: 'The Traps' },
{ index: 5, start: 495, end: 596, title: 'End Credits' },
],
},
];

const config: VideoPlayerConfig = {
baseUrl: FILMS_BASE,
baseImageUrl: 'https://image.tmdb.org/t/p',
muted: true,
autoPlay: false,
playlist: films,
};

interface PlayerSnapshot {
playing: boolean;
title: string;
currentTime: number;
duration: number;
}

/**
* Owns one player instance plus every listener it needs, and exposes a
* single `onChange` callback instead of a reactive object — the lowest
* common denominator every framework's reactivity system can wrap. Returns
* one `destroy()` that unwinds every listener it added, the same contract
* `onUnmounted` (Vue), a cleanup function (React `useEffect`), or `onDestroy`
* (Svelte) all call into.
*/
function createPlayerController(
player: IVideoPlayer,
onChange: (snapshot: PlayerSnapshot) => void,
): () => void {
const emit = (): void => {
onChange({
playing: player.playState() === PlayState.PLAYING,
title: player.item()?.title ?? '',
currentTime: player.time(),
duration: player.duration(),
});
};

player.on('play', emit);
player.on('pause', emit);
player.on('playing', emit);
player.on('ended', emit);
player.on('item', emit);
player.on('time', emit);
player.on('duration', emit);
emit();

return () => {
player.off('play', emit);
player.off('pause', emit);
player.off('playing', emit);
player.off('ended', emit);
player.off('item', emit);
player.off('time', emit);
player.off('duration', emit);
};
}

/**
* Builds the title, progress slider, and play/pause button directly on the
* player's own container — the same three controls every framework recipe in
* this section renders, here addressed as plain DOM nodes instead of bound
* through a template.
*/
function onReady(player: IVideoPlayer, container: HTMLElement): () => void {
if (!container.style.position) container.style.position = 'relative';

const title = player.createElement('div', 'nm-vanilla-title').appendTo(container).get();
title.style.cssText =
'position:absolute;left:1rem;top:1rem;padding:.35rem .6rem;border-radius:.5rem;' +
'background:rgba(0,0,0,.65);color:#fff;font:600 .8rem system-ui,sans-serif;pointer-events:none;';

const bar = player.createElement('div', 'nm-vanilla-progress').appendTo(container).get();
bar.style.cssText =
'position:absolute;left:1rem;right:1rem;bottom:3.5rem;height:.35rem;border-radius:999px;' +
'background:rgba(255,255,255,.25);cursor:pointer;';
bar.setAttribute('role', 'slider');
bar.setAttribute('aria-valuemin', '0');
bar.setAttribute('aria-valuemax', '100');
bar.tabIndex = 0;

const fill = player.createElement('div', 'nm-vanilla-progress-fill').appendTo(bar).get();
fill.style.cssText = 'height:100%;border-radius:999px;background:#fff;width:0%;';

const button = player.createButton(
'nm-vanilla-toggle',
'Play',
() => void player.togglePlayback(),
);
button.style.cssText =
'position:absolute;left:1rem;bottom:1rem;padding:.35rem .75rem;border-radius:.5rem;border:0;' +
'background:rgba(0,0,0,.65);color:#fff;font:600 .8rem system-ui,sans-serif;cursor:pointer;';
container.appendChild(button);

function seek(event: MouseEvent): void {
const ratio = event.offsetX / bar.clientWidth;
player.seekByPercentage(ratio * 100);
}
bar.addEventListener('click', seek);

const destroyController = createPlayerController(player, (snapshot) => {
title.textContent = snapshot.title || 'Nothing playing';
const progress = snapshot.duration > 0 ? (snapshot.currentTime / snapshot.duration) * 100 : 0;
fill.style.width = `${progress}%`;
bar.setAttribute('aria-valuenow', String(progress));
button.textContent = snapshot.playing ? 'Pause' : 'Play';
button.setAttribute('aria-label', snapshot.playing ? 'Pause' : 'Play');
});

return () => {
destroyController();
bar.removeEventListener('click', seek);
title.remove();
bar.remove();
button.remove();
};
}

const player = nmplayer('player').setup(config);
await player.ready();

const container = document.getElementById('player')!;
const cleanup = onReady(player, container);

Next steps