Skip to content

Recipe: React Integration

React does not own the player instance, nomercy-video-player mutates a <video> element directly and pushes updates through its own event emitter, neither of which is a React concern. The hook below creates the instance once (inside useEffect, after the container exists), mirrors the events it cares about into useState, and disposes on unmount. Everything imperative (play(), time()) goes through the player instance itself, not through React state.

The hook and a component using it

<div id="player" /> renders in the same component’s JSX above the hook’s effect. React commits the DOM before running effects, so nmplayer('player') always finds the element, no separate mount step needed.

TypeScript
/**
* Recipe: React Integration — a useVideoPlayer hook mirroring player events
* into state, plus a component using it. Copy both verbatim into your app.
*
* StrictMode: React 18 double-invokes effects in development (mount ->
* cleanup -> mount, synchronously, before the fire-and-forget dispose()
* below has actually resolved — a synchronous cleanup can't await it). The
* second mount's nmplayer(containerId) call resolves the SAME
* still-registered instance; calling setup() on it again throws
* core:lifecycle/already-setup. Guarding on setupState() fixes the crash: a
* re-adopted instance skips setup() and just reattaches listeners. This does
* not eliminate every StrictMode-only edge case (a listener gap can still
* occur in dev between the first mount's deferred dispose() clearing all
* listeners and any code assuming they're still attached) — it is dev-only
* double-invoke behavior, stripped from production builds, and the fix here
* targets the crash, the failure mode that actually reaches users testing
* the page.
*/

// useVideoPlayer.tsx
import type { MouseEvent } from 'react';
import type {
IVideoPlayer,
VideoPlayerConfig,
VideoPlaylistItem,
} from '@nomercy-entertainment/nomercy-video-player';
import { SetupState } from '@nomercy-entertainment/nomercy-player-core';
import { useEffect, useMemo, useRef, useState } from 'react';
import nmplayer, { PlayState } from '@nomercy-entertainment/nomercy-video-player';
const FILMS_BASE =
'https://raw.githubusercontent.com/NoMercy-Entertainment/nomercy-media/master/Films';

export function useVideoPlayer(containerId: string, config: VideoPlayerConfig) {
const playerRef = useRef<IVideoPlayer | null>(null);
const [currentItem, setCurrentItem] = useState<VideoPlaylistItem | undefined>(undefined);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);

useEffect(() => {
const player = nmplayer(containerId);
if (player.setupState() === SetupState.NOT_SETUP) {
player.setup(config);
}
playerRef.current = player;

const onItem = ({ item }: { item: VideoPlaylistItem | undefined }): void =>
setCurrentItem(item);
const onPlay = (): void => setIsPlaying(true);
const onPause = (): void => setIsPlaying(false);
const onTime = ({ time }: { time: number }): void => setCurrentTime(time);
const onDuration = ({ duration: total }: { duration: number }): void => setDuration(total);

player.on('item', onItem);
player.on('play', onPlay);
player.on('playing', onPlay);
player.on('pause', onPause);
player.on('ended', onPause);
player.on('time', onTime);
player.on('duration', onDuration);

return () => {
// Events cleanup with dispose
void player.dispose();
playerRef.current = null;
};
// config is intentionally captured once — see "Config identity" on the page.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [containerId]);

return {
player: playerRef,
currentItem,
isPlaying,
currentTime,
duration,
playState: () => playerRef.current?.playState() === PlayState.PLAYING,
};
}

export function VideoPlayerView({ playlist }: { playlist: VideoPlaylistItem[] }) {
const config = useMemo<VideoPlayerConfig>(
() => ({ baseUrl: FILMS_BASE, muted: true, autoPlay: false, playlist }),
[playlist],
);
const { player, currentItem, isPlaying, currentTime, duration } = useVideoPlayer(
'player',
config,
);
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;

function seek(event: MouseEvent<HTMLDivElement>): void {
const bar = event.currentTarget;
const ratio = event.nativeEvent.offsetX / bar.clientWidth;
void player.current?.time(ratio * duration);
}

return (
<div>
<div id="player" />
<p>{currentItem?.title ?? 'Nothing playing'}</p>
<div
role="slider"
aria-valuenow={progress}
aria-valuemin={0}
aria-valuemax={100}
tabIndex={0}
onClick={seek}
>
<div style={{ width: `${progress}%` }} />
</div>
<button type="button" onClick={() => void player.current?.togglePlayback()}>
{isPlaying ? 'Pause' : 'Play'}
</button>
</div>
);
}

Mounting it

playlist is a prop, not hardcoded — VideoPlayerView above takes real playlist data from whoever renders it. films is the real nomercy-media fixture catalogue (the same three shorts the testbed uses; the Quickstart loads the first, Sintel):

TypeScript
/**
* Recipe: React Integration — mounting `VideoPlayerView` (previous step) at
* the root of a real app, with the real `nomercy-media` catalogue as its
* `playlist` prop.
*/

import { createRoot } from 'react-dom/client';
import { VideoPlayerView } from './useVideoPlayer';
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 root = createRoot(document.getElementById('root')!);
root.render(<VideoPlayerView playlist={films} />);

Config identity

config is deliberately left out of the effect’s dependency array. VideoPlayerConfig objects are rarely stable across renders (an inline { baseUrl, playlist } literal is a new object every render), and re-running setup() on every render is both wrong (it throws once the player is already set up) and wasteful. VideoPlayerView above memoizes it with useMemo, keyed on playlist, instead of fighting the hook.

StrictMode

useVideoPlayer guards setup() with setupState() === SetupState.NOT_SETUP. React 18 StrictMode double-invokes effects in development — mount, cleanup, mount, synchronously, before the cleanup’s fire-and-forget dispose() has actually resolved (a synchronous cleanup can’t await it). Without the guard, the second mount’s nmplayer(containerId) call resolves the same still-registered instance and calling setup() on it again throws core:lifecycle/already-setup. The guard fixes that crash; it does not eliminate every StrictMode-only edge case (a brief listener gap is still possible in dev between the first mount’s deferred dispose() clearing listeners and the second mount’s own subscriptions), but that window is dev-only double-invoke behavior, stripped entirely from production builds.

Context provider for a shared instance

For a layout where a persistent mini-player and a full-screen view share one instance, wrap the hook in a context instead of calling it twice. Creation happens in the render body (the sanctioned lazy-useRef-init pattern), disposal in the effect’s cleanup — checking setupState() === SetupState.DISPOSED on every render recovers from a StrictMode dev double-invoke disposing the shared instance out from under a still-mounted provider:

TypeScript
/**
* Recipe: React Integration — context provider for a shared instance. For a
* layout where a persistent mini-player and a full-screen view share one
* instance, wrap it in a context instead of calling the hook twice.
*
* StrictMode: creation happens in the render body (the sanctioned
* lazy-`useRef`-init pattern — `if (!playerRef.current)` makes it idempotent
* across React's render-phase double-invoke); disposal happens in the
* effect's cleanup. Those two live in DIFFERENT phases, so the render-body
* guard alone doesn't know disposal ran — after React 18 StrictMode's dev
* mount -> cleanup -> mount cycle disposes the instance the ref still
* points at, nothing recreates it until the NEXT render. Checking
* `setupState() === SetupState.DISPOSED` closes that gap on every
* subsequent render (the common case — most trees re-render for unrelated
* reasons), though a child mounted in the narrow dev-only window between
* disposal completing and the next render can still see a disposed
* instance; that window doesn't exist in production, where StrictMode's
* double-invoke is stripped entirely.
*/

import type { ReactNode } from 'react';
import type { IVideoPlayer, VideoPlayerConfig } from '@nomercy-entertainment/nomercy-video-player';
import { SetupState } from '@nomercy-entertainment/nomercy-player-core';
import { createContext, useContext, useEffect, useRef } from 'react';
import nmplayer from '@nomercy-entertainment/nomercy-video-player';

const VideoPlayerContext = createContext<IVideoPlayer | null>(null);

export function VideoPlayerProvider({
config,
children,
}: {
config: VideoPlayerConfig;
children: ReactNode;
}) {
const playerRef = useRef<IVideoPlayer | null>(null);

if (!playerRef.current || playerRef.current.setupState() === SetupState.DISPOSED) {
const player = nmplayer('global-player');
if (player.setupState() === SetupState.NOT_SETUP) {
player.setup(config);
}
playerRef.current = player;
}

useEffect(
() => () => {
void playerRef.current?.dispose();
},
[],
);

return (
<VideoPlayerContext.Provider value={playerRef.current}>{children}</VideoPlayerContext.Provider>
);
}

export function useVideoPlayerContext(): IVideoPlayer {
const player = useContext(VideoPlayerContext);
if (!player) throw new Error('useVideoPlayerContext must be used inside VideoPlayerProvider');
return player;
}

Next steps