Build a Player: Play & Pause
Step 1 built the shell: an overlay, a top bar, a bottom bar, all empty. This step puts the first real control in them, twice over. A big center button for starting playback, and a small button in the bottom bar for toggling it afterwards. Both call the same method.
The icon table
Controls need icons, and icons are data, not code. The plugin keeps them in one module-level table, each entry a normal and a hover variant of the same glyph (the paths are the design system’s own, the ones the shipped plugin renders), plus one renderer that stacks both variants into an inline SVG:
interface TutorialIcon {
title: string;
normal: string;
hover: string;
}
const icons = {
play: {
title: 'Pause',
normal: 'M7.60846 4.61586C7.1087 4.34394...',
hover: 'M5 5.27466C5 3.5678 6.82609...',
},
pause: {
title: 'Play',
normal: 'M6.25 3C5.00736 3 4 4.00736...',
hover: 'M5.74609 3C4.7796 3 3.99609...',
},
} satisfies Record<string, TutorialIcon>;
/**
* Inline-SVG renderer for the icon table: both variants render stacked as
* `icon-normal` + `icon-hover` paths, and the button's hover state swaps
* which one is visible — the same mechanism the shipped plugin uses.
*/
function svgFromIcon(icon: TutorialIcon, size = 24): string {
return `<svg viewBox="0 0 24 24" fill="currentColor" width="${size}" height="${size}" aria-hidden="true">`
+ `<path class="icon-normal" d="${icon.normal}"/>`
+ `<path class="icon-hover" d="${icon.hover}"/>`
+ `</svg>`;
}
The path data is elided here; the full example at the bottom carries every entry verbatim, and each later step adds the entries its controls need. Nothing reads title yet, it documents what the glyph depicts, and the outline-to-filled swap on hover is three utility classes on the button, coming right up.
Four new fields join the class, and use() grows four calls:
private centerButton!: HTMLButtonElement; // NEW
private spinner!: HTMLDivElement; // NEW
private bottomRow!: HTMLDivElement; // NEW
private playbackButton!: HTMLButtonElement; // NEW
override use(): void {
// ...overlay and both bars from step 1, unchanged
this.createCenterButton(); // NEW
this.createSpinner(); // NEW
this.createBottomRow(); // NEW
this.createPlaybackButton(); // NEW
}
The center play button
togglePlayback() flips between playing and paused based on the player’s own state, no separate play()/pause() branching needed in the click handler. And the button doesn’t maintain an “am I visible” flag, it just listens: whatever changed the state (a click, a keyboard shortcut, a remote-control command over NoMercy Connect) fires the same play event, and it hides itself:
private createCenterButton(): void {
this.centerButton = this.player
.createElement('button', 'center-play')
.addClasses([
'absolute',
'top-1/2',
'left-1/2',
'-translate-x-1/2',
'-translate-y-1/2',
'w-16',
'h-16',
'rounded-full',
'bg-black/50',
'text-white',
'flex',
'items-center',
'justify-center',
'transition-opacity',
'duration-300',
'hover:bg-black/70',
'hover:scale-110',
'cursor-pointer',
'pointer-events-auto',
'[&_.icon-hover]:hidden',
'[&:hover_.icon-normal]:hidden',
'[&:hover_.icon-hover]:block',
])
.appendTo(this.overlay)
.get();
this.centerButton.ariaLabel = 'Play';
this.centerButton.innerHTML = svgFromIcon(icons.play);
this.listen(this.centerButton, 'click', (event) => {
event.stopPropagation();
this.player.togglePlayback();
});
this.on('play', () => {
this.centerButton.style.display = 'none';
});
}
event.stopPropagation() matters because the buttons sit over the video, and step ten wires tap behavior onto the surface underneath. this.listen() instead of addEventListener for the same reason there is no dispose(): the base unwinds it at teardown.
The last three classes are the icon hover swap: hide every icon-hover path by default, and on hover hide the normal path and show the hover twin. Filled-on-hover, straight from the table, zero JavaScript.
One helper for every round button
Every control button from here to step ten is the same 40x40 round shape, so it gets a helper once, shared classes and the aria-label in one place:
private createUiButton(parent: HTMLElement, id: string, label: string): HTMLButtonElement {
const button = this.player
.createElement('button', id)
.addClasses([
'w-10',
'h-10',
'rounded-full',
'flex',
'items-center',
'justify-center',
'text-white',
'hover:bg-white/20',
'cursor-pointer',
'[&_.icon-hover]:hidden',
'[&:hover_.icon-normal]:hidden',
'[&:hover_.icon-hover]:block',
])
.appendTo(parent)
.get();
button.ariaLabel = label;
return button;
}
The button row
Step 1’s bottom bar stays exactly as built — it is a column, and this step nests a horizontal row inside it as the home for every control button. Step 3 will drop the progress bar into the same column, above this row, which is the whole reason the bar is flex-col:
private createBottomRow(): void {
this.bottomRow = this.player
.createElement('div', 'bottom-row')
.addClasses([
'flex',
'items-center',
'gap-1',
'h-10',
])
.appendTo(this.bottomBar)
.get();
}
The playback toggle
The bottom-bar playback button holds two icon spans and swaps them on the play/pause pair, the same react-don’t-track pattern as the center button:
private createPlaybackButton(): void {
this.playbackButton = this.createUiButton(this.bottomRow, 'playback', 'Play');
const playIcon = this.player
.createElement('span', 'playback-play')
.appendTo(this.playbackButton)
.get();
playIcon.innerHTML = svgFromIcon(icons.play);
const pauseIcon = this.player
.createElement('span', 'playback-pause')
.appendTo(this.playbackButton)
.get();
pauseIcon.innerHTML = svgFromIcon(icons.pause);
pauseIcon.style.display = 'none';
this.listen(this.playbackButton, 'click', (event) => {
event.stopPropagation();
this.player.togglePlayback();
});
this.on('pause', () => {
pauseIcon.style.display = 'none';
playIcon.style.display = 'flex';
});
this.on('play', () => {
playIcon.style.display = 'none';
pauseIcon.style.display = 'flex';
});
}
createUiButton() is a small private helper (in the full example below) that builds the round 40x40 button every later control reuses: one place for the shared classes and the aria-label.
The spinner is pure CSS
No listener at all. Step 1 covered the state classes the player maintains on its container; .buffering is one of them, driven by the backend’s waiting/stalled/canplay signals. The spinner just declares itself visible in that state:
private createSpinner(): void {
this.spinner = this.player
.createElement('div', 'spinner')
.addClasses([
'absolute',
'top-1/2',
'left-1/2',
'-translate-x-1/2',
'-translate-y-1/2',
'w-12',
'h-12',
'hidden',
'group-[&.nomercyplayer.buffering]:block',
'pointer-events-none',
])
.appendTo(this.overlay)
.get();
this.spinner.innerHTML = SPINNER_SVG;
}
Buffering starts: the player swaps the container to .buffering and the spinner appears. Playback recovers: class gone, spinner gone. Zero JavaScript on your side.
SPINNER_SVG is the one glyph that lives outside the icon table: it is an animated two-path ring, not a normal/hover pair, so it stays a module-level constant (the full example carries it verbatim).
See it running
Click the center button to start, then use the small one in the bottom bar. Both toggle the same playback state. Throttle the network in devtools and the spinner shows itself.
Loading player…
/**
* Build a Player, step 2 of 10: Play / Pause.
*
* Adds to the step-1 shell: a big center play button, a buffering spinner
* that is pure CSS riding the player's `.buffering` container class, a
* bottom row inside the bottom bar, and a playback toggle button with a
* play and a pause icon swapped by the `play` / `pause` events. Icons come
* from a module-level table rendered by `svgFromIcon()`, v1's pattern.
*/
import nmplayer, {
type NMVideoPlayer,
type VideoPlayerConfig,
type VideoPlaylistItem,
} from '@nomercy-entertainment/nomercy-video-player';
import { Plugin } from '@nomercy-entertainment/nomercy-player-core';
const bigBuckBunnyItem = {
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 FILMS_BASE =
'https://raw.githubusercontent.com/NoMercy-Entertainment/nomercy-media/master/Films';
interface TutorialIcon {
title: string;
normal: string;
hover: string;
}
const icons = {
play: {
title: 'Pause',
normal:
'M7.60846 4.61586C7.1087 4.34394 6.5 4.7057 6.5 5.27466V18.727C6.5 19.2959 7.1087 19.6577 7.60846 19.3858L19.97 12.6596C20.4921 12.3755 20.4921 11.6261 19.97 11.342L7.60846 4.61586ZM5 5.27466C5 3.5678 6.82609 2.48249 8.32538 3.29828L20.687 10.0244C22.2531 10.8766 22.2531 13.125 20.687 13.9772L8.32538 20.7033C6.82609 21.5191 5 20.4338 5 18.727V5.27466Z',
hover:
'M5 5.27466C5 3.5678 6.82609 2.48249 8.32538 3.29828L20.687 10.0244C22.2531 10.8766 22.2531 13.125 20.687 13.9772L8.32538 20.7033C6.82609 21.5191 5 20.4338 5 18.727V5.27466Z',
},
pause: {
title: 'Play',
normal:
'M6.25 3C5.00736 3 4 4.00736 4 5.25V18.75C4 19.9926 5.00736 21 6.25 21H8.75C9.99264 21 11 19.9926 11 18.75V5.25C11 4.00736 9.99264 3 8.75 3H6.25ZM5.5 5.25C5.5 4.83579 5.83579 4.5 6.25 4.5H8.75C9.16421 4.5 9.5 4.83579 9.5 5.25V18.75C9.5 19.1642 9.16421 19.5 8.75 19.5H6.25C5.83579 19.5 5.5 19.1642 5.5 18.75V5.25ZM15.25 3C14.0074 3 13 4.00736 13 5.25V18.75C13 19.9926 14.0074 21 15.25 21H17.75C18.9926 21 20 19.9926 20 18.75V5.25C20 4.00736 18.9926 3 17.75 3H15.25ZM14.5 5.25C14.5 4.83579 14.8358 4.5 15.25 4.5H17.75C18.1642 4.5 18.5 4.83579 18.5 5.25V18.75C18.5 19.1642 18.1642 19.5 17.75 19.5H15.25C14.8358 19.5 14.5 19.1642 14.5 18.75V5.25Z',
hover:
'M5.74609 3C4.7796 3 3.99609 3.7835 3.99609 4.75V19.25C3.99609 20.2165 4.7796 21 5.74609 21H9.24609C10.2126 21 10.9961 20.2165 10.9961 19.25V4.75C10.9961 3.7835 10.2126 3 9.24609 3H5.74609ZM14.7461 3C13.7796 3 12.9961 3.7835 12.9961 4.75V19.25C12.9961 20.2165 13.7796 21 14.7461 21H18.2461C19.2126 21 19.9961 20.2165 19.9961 19.25V4.75C19.9961 3.7835 19.2126 3 18.2461 3H14.7461Z',
},
} satisfies Record<string, TutorialIcon>;
/**
* Inline-SVG renderer for the icon table: both variants render stacked as
* `icon-normal` + `icon-hover` paths, and the button's hover state swaps
* which one is visible — the same mechanism the shipped plugin uses.
*/
function svgFromIcon(icon: TutorialIcon, size = 24): string {
return (
`<svg viewBox="0 0 24 24" fill="currentColor" width="${size}" height="${size}" aria-hidden="true">` +
`<path class="icon-normal" d="${icon.normal}"/>` +
`<path class="icon-hover" d="${icon.hover}"/>` +
`</svg>`
);
}
const SPINNER_SVG = `
<svg class="animate-spin text-white" viewBox="0 0 100 101" fill="none">
<path d="M100 50.59C100 78.2 77.6 100.59 50 100.59S0 78.2 0 50.59 22.39.59 50 .59s50 22.39 50 50z" fill="currentColor" opacity="0.25"/>
<path d="M93.97 39.04c2.42-.64 3.89-3.13 3.04-5.49A50 50 0 0041.73 1.28c-2.47.41-3.92 2.92-3.28 5.34.66 2.43 3.14 3.85 5.62 3.48a40 40 0 0146.62 22.32c.9 2.24 3.36 3.7 5.79 3.06z" fill="currentColor"/>
</svg>
`;
class StepPlugin extends Plugin<NMVideoPlayer> {
static override readonly id = 'tutorial-ui';
private overlay!: HTMLElement;
private topBar!: HTMLDivElement;
private bottomBar!: HTMLDivElement;
private centerButton!: HTMLButtonElement;
private spinner!: HTMLDivElement;
private bottomRow!: HTMLDivElement;
private playbackButton!: HTMLButtonElement;
override use(): void {
this.player.addClasses(this.player.container, ['group']);
this.overlay = this.mount('overlay');
this.player.addClasses(this.overlay, ['overlay', 'absolute', 'inset-0', 'pointer-events-none']);
this.createTopBar();
this.createCenterButton();
this.createSpinner();
this.createBottomBar();
this.createBottomRow();
this.createPlaybackButton();
}
private createTopBar(): void {
this.topBar = this.player
.createElement('div', 'top-bar')
.addClasses([
'absolute',
'top-0',
'left-0',
'right-0',
'flex',
'items-center',
'gap-2',
'p-4',
'pb-12',
'bg-gradient-to-b',
'from-black/80',
'to-transparent',
'opacity-0',
'transition-opacity',
'duration-300',
'pointer-events-none',
'group-[&.nomercyplayer.active]:opacity-100',
'group-[&.nomercyplayer.active]:pointer-events-auto',
'group-[&.nomercyplayer.paused]:opacity-100',
'group-[&.nomercyplayer.paused]:pointer-events-auto',
])
.appendTo(this.overlay)
.get();
}
private createBottomBar(): void {
this.bottomBar = this.player
.createElement('div', 'bottom-bar')
.addClasses([
'absolute',
'bottom-0',
'left-0',
'right-0',
'flex',
'flex-col',
'gap-1',
'px-4',
'pt-12',
'pb-2',
'bg-gradient-to-t',
'from-black/80',
'to-transparent',
'opacity-0',
'transition-opacity',
'duration-300',
'pointer-events-none',
'group-[&.nomercyplayer.active]:opacity-100',
'group-[&.nomercyplayer.active]:pointer-events-auto',
'group-[&.nomercyplayer.paused]:opacity-100',
'group-[&.nomercyplayer.paused]:pointer-events-auto',
])
.appendTo(this.overlay)
.get();
}
private createUiButton(parent: HTMLElement, id: string, label: string): HTMLButtonElement {
const button = this.player
.createElement('button', id)
.addClasses([
'w-10',
'h-10',
'rounded-full',
'flex',
'items-center',
'justify-center',
'text-white',
'hover:bg-white/20',
'cursor-pointer',
'[&_.icon-hover]:hidden',
'[&:hover_.icon-normal]:hidden',
'[&:hover_.icon-hover]:block',
])
.appendTo(parent)
.get();
button.ariaLabel = label;
return button;
}
private createCenterButton(): void {
this.centerButton = this.player
.createElement('button', 'center-play')
.addClasses([
'absolute',
'top-1/2',
'left-1/2',
'-translate-x-1/2',
'-translate-y-1/2',
'w-16',
'h-16',
'rounded-full',
'bg-black/50',
'text-white',
'flex',
'items-center',
'justify-center',
'transition-opacity',
'duration-300',
'hover:bg-black/70',
'hover:scale-110',
'cursor-pointer',
'pointer-events-auto',
'[&_.icon-hover]:hidden',
'[&:hover_.icon-normal]:hidden',
'[&:hover_.icon-hover]:block',
])
.appendTo(this.overlay)
.get();
this.centerButton.ariaLabel = 'Play';
this.centerButton.innerHTML = svgFromIcon(icons.play);
this.listen(this.centerButton, 'click', (event) => {
event.stopPropagation();
this.player.togglePlayback();
});
this.on('play', () => {
this.centerButton.style.display = 'none';
});
}
private createSpinner(): void {
this.spinner = this.player
.createElement('div', 'spinner')
.addClasses([
'absolute',
'top-1/2',
'left-1/2',
'-translate-x-1/2',
'-translate-y-1/2',
'w-12',
'h-12',
'hidden',
'group-[&.nomercyplayer.buffering]:block',
'pointer-events-none',
])
.appendTo(this.overlay)
.get();
this.spinner.innerHTML = SPINNER_SVG;
}
private createBottomRow(): void {
this.bottomRow = this.player
.createElement('div', 'bottom-row')
.addClasses(['flex', 'items-center', 'gap-1', 'h-10'])
.appendTo(this.bottomBar)
.get();
}
private createPlaybackButton(): void {
this.playbackButton = this.createUiButton(this.bottomRow, 'playback', 'Play');
const playIcon = this.player
.createElement('span', 'playback-play')
.appendTo(this.playbackButton)
.get();
playIcon.innerHTML = svgFromIcon(icons.play);
const pauseIcon = this.player
.createElement('span', 'playback-pause')
.appendTo(this.playbackButton)
.get();
pauseIcon.innerHTML = svgFromIcon(icons.pause);
pauseIcon.style.display = 'none';
this.listen(this.playbackButton, 'click', (event) => {
event.stopPropagation();
this.player.togglePlayback();
});
this.on('pause', () => {
pauseIcon.style.display = 'none';
playIcon.style.display = 'flex';
});
this.on('play', () => {
playIcon.style.display = 'none';
pauseIcon.style.display = 'flex';
});
}
}
const config: VideoPlayerConfig = {
baseUrl: FILMS_BASE,
baseImageUrl: 'https://image.tmdb.org/t/p',
muted: false,
autoPlay: false,
controls: false,
playlist: [bigBuckBunnyItem],
};
function configure(player: NMVideoPlayer): void {
player.addPlugin(StepPlugin);
}
const player = nmplayer('player');
configure(player);
player.setup(config);
await player.ready();
Next steps
- Step 3: Progress Bar: a draggable scrubber wired to the
timeevent andseekByPercentage().