Skip to content

Build a Player: Shell & Layout

This is a ten-step tutorial where you write your own control UI, one piece at a time: a shell, play and pause, a progress bar, time and skip, volume, a title bar, fullscreen and speed, the track selectors, a seek preview with chapters, and a touch layer. When you finish, you will understand every line the real DesktopUiPlugin runs, because you will have written its shape yourself.

Everything you write rides the player’s public method surface, the same play(), volume(), time(), and duration() calls any consumer has.

This first step is the skeleton: the overlay root, a top bar, and a bottom bar. Empty for now. The next steps fill them.

One class, extending Plugin

A UI is just a plugin. Extend core’s Plugin base, give it a static readonly id, and do your DOM work in use(), which the player calls once the plugin is registered and the container exists. The overlay and both bars are declared as fields up front, because later steps append controls into them:

TypeScript
import type { NMVideoPlayer } from '@nomercy-entertainment/nomercy-video-player';
import { Plugin } from '@nomercy-entertainment/nomercy-player-core';

class StepPlugin extends Plugin<NMVideoPlayer> {
static override readonly id = 'tutorial-ui';

private overlay!: HTMLElement;
private topBar!: HTMLDivElement;
private bottomBar!: HTMLDivElement;

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.createBottomBar();
}
}

Notice the two kinds of this already in play. this.mount() is the plugin’s own helper, bare this.*, because this is the plugin you are writing. this.player.addClasses() reaches the player, the collaborator this plugin extends. That split, what you are versus what you talk to, is the organising rule of the whole trio and decides where every behaviour lives. Player and Plugin is the short read.

The first line marks the player container as a Tailwind group parent. Every state-reactive class in this tutorial (group-[&.nomercyplayer.active]:opacity-100 on the bars below, the spinner’s .buffering variant in step 2) resolves against a .group ancestor, and the container is the element that carries the state classes, so it is the group.

mount('overlay') claims a <div> on the player container and, crucially, registers its own teardown. When the plugin disposes, that div and everything you append under it are removed for you, along with every listener you add through this.listen() and this.on(). That is why this class has no dispose() method anywhere in the tutorial: the v1 original had to remove() its bars by hand, and in v2 there is nothing left to write.

Build the bars

player.createElement(tag, id) returns a chainable builder, so you read the construction straight down: create it, style it, append it, and .get() unwraps the real element into the field:

TypeScript
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();
}

The bottom bar is the same shape flipped, bottom-0 and bg-gradient-to-t, with one structural difference: flex-col, because it will stack a progress bar above a row of buttons:

TypeScript
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();
}

The player owns the show-hide

Look at the last four classes on each bar. They key off .active and .paused on the .nomercyplayer container, and those classes are not yours to manage. The player maintains a set of state classes on its own container, in sync with every event:

  • playing / paused / stopped / ended / loading / buffering, the playback state, mutually exclusive
  • active / inactive, whether the viewer is interacting right now
  • muted, fullscreen, pip, theater, the matching toggles

Activity is a player concern: the player itself watches its container for pointer, touch, and key input, shows the controls on any of it, and drops to .inactive after four seconds of idle while playing. Pausing always keeps the controls up. Your plugin inherits all of that through those four CSS classes, with zero wiring; inactivityMs in the config tunes the delay, and bumpActivity() exists for interactions the DOM can’t see (see Reference: VideoPlayerConfig).

So the bars already fade in when you move the pointer and fade out when you leave playback running. You wrote no timer for that, and you never will in this tutorial.

The playlist item

The tutorial plays one real film, declared right in the example, because the item is where half of this tutorial’s data lives. Alongside the stream URL it carries the subtitle tracks step 8’s menu will list, the chapter list step 9 turns into scrubber ticks and tooltip titles, and the sprite-VTT manifest behind step 9’s seek thumbnails. Media paths are relative; the config’s baseUrl resolves them (and baseImageUrl resolves the poster):

TypeScript
const sintel: VideoPlaylistItem = {
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',
},
// ...Dutch, French, and German entries, same shape
],
chapters: [
{ index: 0, start: 0, end: 107, title: 'Opening' },
{ index: 1, start: 107, end: 207, title: 'A Dangerous Quest' },
// ...six more, through 'End Credits'
],
previewSpriteUrl: '/Sintel.(2010)/thumbs_256x109.vtt',
};

Nothing reads subtitles, chapters, or previewSpriteUrl yet, the item just declares what the film has, and each later step lights up the part it needs. The full example below carries every entry.

Register it

A plugin you wrote is registered exactly like a shipped one, with addPlugin():

TypeScript
const player = nmplayer('my-container')
.addPlugin(StepPlugin)
.setup({
baseUrl,
playlist: [sintel],
});
await player.ready();

addPlugin() is the method to reach for. The same registration also exists declaratively as plugins: [StepPlugin] in the setup() config; both do the same thing, so pick whichever reads better in your app.

See it running

Move the pointer over the video: two gradient bars fade in. Leave it running: they fade out. Pause: they stay. Empty bars, but real ones, on the player’s own show-hide contract. Step two puts the first control in them.

Loading player…

TypeScript
/**
* Build a Player, step 1 of 10: Shell & Layout.
*
* The v2 translation of the original examples.nomercy.tv tutorial step. You
* are writing your own UI plugin, not mounting the shipped one: an overlay
* root and two control bars that every later step fills with real controls.
*
* The bars key their visibility off the `.active` / `.paused` state classes
* the PLAYER maintains on `.nomercyplayer` — activity tracking is a player
* concern, so the plugin ships zero show/hide code. `inactivityMs` on the
* config tunes the fade delay.
*
* There is no `dispose()` on purpose: `mount('overlay')` registers its own
* teardown and the base cleans every `this.listen()` / `this.on()`
* subscription, so the v1 original's manual `remove()` calls have nothing
* left to do in v2.
*/

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';

class StepPlugin extends Plugin<NMVideoPlayer> {
static override readonly id = 'tutorial-ui';

private overlay!: HTMLElement;
private topBar!: HTMLDivElement;
private bottomBar!: HTMLDivElement;

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.createBottomBar();
}

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();
}
}

const config: VideoPlayerConfig = {
baseUrl: FILMS_BASE,
baseImageUrl: 'https://image.tmdb.org/t/p',
muted: true,
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 2: Play & Pause: the center play button, a buffering spinner, and the bottom-bar playback button.