Build a Player: Progress Bar
Step 2 wired playback. This step adds the control everyone reaches for first: a scrubber. It sits inside the bottom bar, above the button row (flex-col on the bar from step 1 was for exactly this), and it is four elements: the bar, a buffer fill, a progress fill, and a hover nipple.
One field for the bar itself, one for the drag state, one call in use() placed before createBottomRow() so the DOM order matches the visual order:
private sliderBar!: HTMLDivElement; // NEW
private isMouseDown = false; // NEW
override use(): void {
// ...everything from step 2, unchanged, except one insertion:
this.createBottomBar();
this.createProgressBar(); // NEW, before the button row
this.createBottomRow();
this.createPlaybackButton();
}
Four elements
The bar is the interactive parent; the buffer fill, progress fill, and hover nipple are pointer-events-none children painted by width and position:
private createProgressBar(): void {
this.sliderBar = this.player
.createElement('div', 'slider-bar')
.addClasses([
'relative',
'w-full',
'h-1',
'bg-white/20',
'rounded-full',
'cursor-pointer',
'group/slider',
'hover:h-2',
'transition-all',
'duration-150',
])
.appendTo(this.bottomBar)
.get();
const sliderBuffer = this.player
.createElement('div', 'slider-buffer')
.addClasses([
'absolute',
'top-0',
'left-0',
'h-full',
'bg-white/30',
'rounded-full',
'pointer-events-none',
])
.appendTo(this.sliderBar)
.get();
const sliderProgress = this.player
.createElement('div', 'slider-progress')
.addClasses([
'absolute',
'top-0',
'left-0',
'h-full',
'bg-white',
'rounded-full',
'pointer-events-none',
])
.appendTo(this.sliderBar)
.get();
const sliderNipple = this.player
.createElement('div', 'slider-nipple')
.addClasses([
'absolute',
'top-1/2',
'-translate-y-1/2',
'-translate-x-1/2',
'w-3',
'h-3',
'rounded-full',
'bg-white',
'hidden',
'group-hover/slider:flex',
'pointer-events-none',
'left-0',
'z-20',
])
.appendTo(this.sliderBar)
.get();
// ...the handlers below all live inside this method too
}
The fills and nipple are consts, not fields: only the handlers inside this method ever touch them, so they stay local, hover:h-2 on the parent plus group-hover/slider:flex on the nipple give the fatten-on-hover feel with zero JavaScript.
Pointer position to percentage
Everything a scrubber does starts with “where is the pointer, as a fraction of the bar”. One helper handles mouse and touch (including changedTouches, which is what touchend carries), clamped so dragging past the edges holds at 0 and 100:
const getPercentFromEvent = (event: MouseEvent | TouchEvent): number => {
const rect = this.sliderBar.getBoundingClientRect();
const clientX = ('clientX' in event ? event.clientX : undefined)
?? ('touches' in event ? event.touches?.[0]?.clientX : undefined)
?? ('changedTouches' in event ? event.changedTouches?.[0]?.clientX : undefined)
?? 0;
const x = Math.max(0, Math.min(clientX - rect.left, rect.width));
return (x / rect.width) * 100;
};
Click to seek
The player exposes seekByPercentage() for exactly this, no duration() * percent / 100 math at the call site:
this.listen(this.sliderBar, 'click', (event) => {
this.isMouseDown = false;
const percent = getPercentFromEvent(event as MouseEvent);
this.player.seekByPercentage(percent);
sliderNipple.style.left = `${percent}%`;
});
Drag to scrub, and don’t fight the clock
mousedown/touchstart arm the drag, mousemove/touchmove paint it without seeking yet, and leaving the bar cancels it:
for (const eventName of ['mousedown', 'touchstart']) {
this.listen(this.sliderBar, eventName, () => {
this.isMouseDown = true;
}, { passive: true });
}
for (const eventName of ['mousemove', 'touchmove']) {
this.listen(this.sliderBar, eventName, (event) => {
if (!this.isMouseDown)
return;
const percent = getPercentFromEvent(event as MouseEvent | TouchEvent);
sliderNipple.style.left = `${percent}%`;
sliderProgress.style.width = `${percent}%`;
}, { passive: true });
}
this.listen(this.sliderBar, 'mouseleave', () => {
this.isMouseDown = false;
}, { passive: true });
The time listener that keeps the bar in sync during normal playback skips its updates while isMouseDown is true, otherwise every tick would yank the nipple back to the playhead mid-drag:
this.on('time', ({ position, duration, buffered, percentage }) => {
if (this.isMouseDown)
return;
sliderProgress.style.width = `${percentage}%`;
sliderNipple.style.left = `${percentage}%`;
if (duration > 0)
sliderBuffer.style.width = `${Math.min(100, ((position + buffered) / duration) * 100)}%`;
});
The time event carries the player’s full time snapshot, time/position, duration, buffered, remaining, and a ready-made percentage, the same shape timeData() returns, so there is no time math to derive inside the handler. The progress fill and nipple take percentage as-is; the buffer fill is the one derived value, because “how far ahead is buffered” is a UI choice: buffered is seconds ahead of position, so the fill ends at position + buffered.
And when the playlist moves to another item, the fills reset to zero through the same item event the title bar will use in step 6:
this.on('item', () => {
sliderBuffer.style.width = '0';
sliderProgress.style.width = '0';
});
See it running
Hover the bar: it thickens and the nipple appears. Click anywhere: playback jumps. Drag: the bar follows your pointer, not the clock.
Loading player…
/**
* Build a Player, step 3 of 10: Progress Bar.
*
* Adds to step 2: a slider bar above the button row with a buffer fill, a
* progress fill, and a hover nipple. Click to seek via `seekByPercentage()`,
* drag to scrub (the `time` listener skips updates while dragging so the
* bar doesn't fight the pointer), all three fills painted from the
* `timeData()` snapshot, and everything resets when the playlist moves to
* another item.
*/
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;
private sliderBar!: HTMLDivElement;
private isMouseDown = false;
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.createProgressBar();
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';
});
}
private createProgressBar(): void {
this.sliderBar = this.player
.createElement('div', 'slider-bar')
.addClasses([
'relative',
'w-full',
'h-1',
'bg-white/20',
'rounded-full',
'cursor-pointer',
'group/slider',
'hover:h-2',
'transition-all',
'duration-150',
])
.appendTo(this.bottomBar)
.get();
const sliderBuffer = this.player
.createElement('div', 'slider-buffer')
.addClasses([
'absolute',
'top-0',
'left-0',
'h-full',
'bg-white/30',
'rounded-full',
'pointer-events-none',
])
.appendTo(this.sliderBar)
.get();
const sliderProgress = this.player
.createElement('div', 'slider-progress')
.addClasses([
'absolute',
'top-0',
'left-0',
'h-full',
'bg-white',
'rounded-full',
'pointer-events-none',
])
.appendTo(this.sliderBar)
.get();
const sliderNipple = this.player
.createElement('div', 'slider-nipple')
.addClasses([
'absolute',
'top-1/2',
'-translate-y-1/2',
'-translate-x-1/2',
'w-3',
'h-3',
'rounded-full',
'bg-white',
'hidden',
'group-hover/slider:flex',
'pointer-events-none',
'left-0',
'z-20',
])
.appendTo(this.sliderBar)
.get();
// Converts a mouse or touch event's X position into a 0-100 percentage
// relative to the slider bar, clamped so dragging past the edges holds.
const getPercentFromEvent = (event: MouseEvent | TouchEvent): number => {
const rect = this.sliderBar.getBoundingClientRect();
const clientX =
('clientX' in event ? event.clientX : undefined) ??
('touches' in event ? event.touches?.[0]?.clientX : undefined) ??
('changedTouches' in event ? event.changedTouches?.[0]?.clientX : undefined) ??
0;
const x = Math.max(0, Math.min(clientX - rect.left, rect.width));
return (x / rect.width) * 100;
};
for (const eventName of ['mousedown', 'touchstart']) {
this.listen(
this.sliderBar,
eventName,
() => {
this.isMouseDown = true;
},
{ passive: true },
);
}
this.listen(this.sliderBar, 'click', (event) => {
this.isMouseDown = false;
const percent = getPercentFromEvent(event as MouseEvent);
this.player.seekByPercentage(percent);
sliderNipple.style.left = `${percent}%`;
});
for (const eventName of ['mousemove', 'touchmove']) {
this.listen(
this.sliderBar,
eventName,
(event) => {
if (!this.isMouseDown) return;
const percent = getPercentFromEvent(event as MouseEvent | TouchEvent);
sliderNipple.style.left = `${percent}%`;
sliderProgress.style.width = `${percent}%`;
},
{ passive: true },
);
}
this.listen(
this.sliderBar,
'mouseleave',
() => {
this.isMouseDown = false;
},
{ passive: true },
);
this.on('time', ({ position, duration, buffered, percentage }) => {
if (this.isMouseDown) return;
sliderProgress.style.width = `${percentage}%`;
sliderNipple.style.left = `${percentage}%`;
if (duration > 0)
sliderBuffer.style.width = `${Math.min(100, ((position + buffered) / duration) * 100)}%`;
});
this.on('item', () => {
sliderBuffer.style.width = '0';
sliderProgress.style.width = '0';
});
}
}
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 4: Time & Skip: the clock readout and ten-second skip buttons.