Skip to content

Step 3: Progress Bar with Seeking

In Step 2 you wired play/pause controls and a buffering spinner. This step adds the progress bar: a thin track that fills as the video plays, a buffer bar showing how much is loaded, and a draggable thumb the user can grab to seek.

By the end of this step your control bar has a live seek slider sitting between the bottom bar shadow and the transport buttons.

New class fields

Add these two fields alongside the others you declared in Steps 1 and 2:

TypeScript
private sliderBar!: HTMLDivElement;
private isMouseDown = false;

sliderBar holds a reference to the clickable track element so the seek and scrub handlers can read its bounding rect. isMouseDown guards the time event handler: while the user is dragging, incoming playback-position updates should not fight the drag by snapping the bar back.

Building the progress bar DOM

Add createProgressBar() to the class and call it from use() after createBottomBar() and before createBottomRow(). The method creates four nested elements: the outer track, a buffer fill, a progress fill, and a thumb dot.

The outer track element must be an accessible slider so keyboard users can seek without a pointer device. That means:

  • role="slider" so assistive technology announces it as an interactive range control
  • tabindex="0" so it receives keyboard focus
  • aria-label for a human-readable name
  • aria-valuemin/aria-valuemax/aria-valuenow updated live as the position changes
  • a keydown handler for ArrowLeft/ArrowRight (±5 s), Home (start), and End (end)

Without these, keyboard and screen-reader users have no way to seek.

TypeScript
private createProgressBar(): void {
this.sliderBar = this.player
.createElement('div', 'slider-bar')
.addClasses([
'relative', 'w-full', 'h-1', 'mx-2',
'bg-white/20', 'rounded-full',
'cursor-pointer', 'group/slider',
'hover:h-2', 'transition-all', 'duration-150',
])
.appendTo(this.bottomBar)
.get();

// Accessible slider attributes — required for keyboard and screen-reader users.
this.sliderBar.setAttribute('role', 'slider');
this.sliderBar.setAttribute('tabindex', '0');
this.sliderBar.setAttribute('aria-label', 'Seek');
this.sliderBar.setAttribute('aria-valuemin', '0');
this.sliderBar.setAttribute('aria-valuemax', '100');
this.sliderBar.setAttribute('aria-valuenow', '0');

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

// Resolve a pointer or touch event's X position to a 0–100 percentage
// of the slider's width, clamped so dragging past the edges does not overflow.
const getPercentFromEvent = (e: MouseEvent | TouchEvent): number => {
const rect = this.sliderBar.getBoundingClientRect();
const clientX =
('clientX' in e ? e.clientX : undefined)
?? (e as TouchEvent).touches?.[0]?.clientX
?? (e as TouchEvent).changedTouches?.[0]?.clientX
?? 0;
const x = Math.max(0, Math.min(clientX - rect.left, rect.width));
return (x / rect.width) * 100;
};

this.listen(this.sliderBar, 'mousedown', () => {
this.isMouseDown = true;
});
this.listen(this.sliderBar, 'touchstart', () => {
this.isMouseDown = true;
});

// Click to seek: convert the click position to a time and jump there.
this.listen(this.sliderBar, 'click', (e: Event) => {
this.isMouseDown = false;
const percent = getPercentFromEvent(e as MouseEvent);
const duration = this.player.duration();
void this.player.time(duration * (percent / 100));
sliderNipple.style.left = `${percent}%`;
});

// Scrub while dragging: update the bar visually without seeking yet.
this.listen(this.sliderBar, 'mousemove', (e: Event) => {
if (!this.isMouseDown) return;
const percent = getPercentFromEvent(e as MouseEvent);
sliderNipple.style.left = `${percent}%`;
sliderProgress.style.width = `${percent}%`;
});
this.listen(this.sliderBar, 'touchmove', (e: Event) => {
if (!this.isMouseDown) return;
const percent = getPercentFromEvent(e as TouchEvent);
sliderNipple.style.left = `${percent}%`;
sliderProgress.style.width = `${percent}%`;
});

// Cancel the drag if the pointer leaves the slider without releasing.
this.listen(this.sliderBar, 'mouseleave', () => {
this.isMouseDown = false;
});

// Keyboard seek: ArrowLeft/Right ±5 s, Home = start, End = end.
// This makes the slider accessible to keyboard-only users.
this.listen(this.sliderBar, 'keydown', (e: Event) => {
const key = (e as KeyboardEvent).key;
const duration = this.player.duration();
if (key === 'ArrowLeft') {
e.preventDefault();
void this.player.rewind(5);
} else if (key === 'ArrowRight') {
e.preventDefault();
void this.player.forward(5);
} else if (key === 'Home') {
e.preventDefault();
void this.player.time(0);
} else if (key === 'End' && duration > 0) {
e.preventDefault();
void this.player.time(duration);
}
});

// Sync progress bar with playback position.
// Skip updates while the user is scrubbing so the bar does not fight the drag.
// Also update aria-valuenow so screen readers can announce the current position.
this.on('time', (data) => {
const duration = this.player.duration();
const pct = duration > 0 ? (data.time / duration) * 100 : 0;
if (this.isMouseDown) return;
sliderProgress.style.width = `${pct}%`;
sliderNipple.style.left = `${pct}%`;
this.sliderBar.setAttribute('aria-valuenow', String(Math.round(pct)));
});

// Reset slider when the playlist moves to a new item.
this.on('current', () => {
sliderBuffer.style.width = '0%';
sliderProgress.style.width = '0%';
});
}

A few things to note here:

  • this.listen(element, event, handler) is the plugin’s auto-cleaned DOM listener helper. Never use raw addEventListener inside a plugin, because raw listeners are not removed on dispose().
  • this.on('time', fn) subscribes to a player event through the plugin’s lifecycle. The time event fires on every timeupdate tick from the backend and carries { time: number } (seconds). There is no percentage field on this event, so the percentage is computed from time / duration.
  • this.player.time(seconds) is the two-form overload: called with an argument it seeks; called without an argument it reads. The seek returns a Promise<void>, which is why the void keyword precedes it.
  • The Tailwind classes here assume you have Tailwind in your project. If you are using plain CSS, replace the utility classes with your own stylesheet rules.

Update use() and dispose()

use() now calls createProgressBar() between createBottomBar() and createBottomRow(). The dispose() method stays the same as in Step 2, since the slider elements are children of this.bottomBar and are removed when the bar is removed.

TypeScript
use(): void {
this.overlay = this.mount('overlay');
this.createTopBar();
this.createCenterButton();
this.createSpinner();
this.createBottomBar();
this.createProgressBar();
this.createBottomRow();
this.createPlaybackButton();

if (this.player.videoElement?.paused === false) {
this.centerButton.style.display = 'none';
}
}

dispose(): void {
this.topBar?.remove();
this.bottomBar?.remove();
this.centerButton?.remove();
this.spinner?.remove();
}

The overlay root comes from this.mount('overlay'), not this.player.overlay. mount(name) is the plugin helper that claims or creates a named <div> on the player container and registers it for cleanup on dispose().

What the bar shows at this step

After this step, the time event handler sets only sliderProgress.style.width and sliderNipple.style.left. Time labels (currentTimeLabel, durationLabel) are not added until Step 4, so the bar shows position visually but has no numeric readout yet.

Full plugin so far

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

export class PlayerUiPlugin extends Plugin<NMVideoPlayer> {
static readonly id = 'player-ui';

private overlay!: HTMLElement;
private topBar!: HTMLDivElement;
private centerButton!: HTMLButtonElement;
private playbackButton!: HTMLButtonElement;
private spinner!: HTMLDivElement;
private bottomBar!: HTMLDivElement;
private bottomRow!: HTMLDivElement;
private sliderBar!: HTMLDivElement;
private isMouseDown = false;

use(): void {
this.overlay = this.mount('overlay');
this.createTopBar();
this.createCenterButton();
this.createSpinner();
this.createBottomBar();
this.createProgressBar();
this.createBottomRow();
this.createPlaybackButton();

if (this.player.videoElement?.paused === false) {
this.centerButton.style.display = 'none';
}
}

dispose(): void {
this.topBar?.remove();
this.bottomBar?.remove();
this.centerButton?.remove();
this.spinner?.remove();
}

// createTopBar, createCenterButton, createSpinner, createBottomBar,
// createBottomRow, createPlaybackButton — carried over from Steps 1 and 2.

private createProgressBar(): void {
this.sliderBar = this.player
.createElement('div', 'slider-bar')
.addClasses([
'relative', 'w-full', 'h-1', 'mx-2',
'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();

// Accessible slider attributes.
this.sliderBar.setAttribute('role', 'slider');
this.sliderBar.setAttribute('tabindex', '0');
this.sliderBar.setAttribute('aria-label', 'Seek');
this.sliderBar.setAttribute('aria-valuemin', '0');
this.sliderBar.setAttribute('aria-valuemax', '100');
this.sliderBar.setAttribute('aria-valuenow', '0');

const getPercentFromEvent = (e: MouseEvent | TouchEvent): number => {
const rect = this.sliderBar.getBoundingClientRect();
const clientX =
('clientX' in e ? e.clientX : undefined)
?? (e as TouchEvent).touches?.[0]?.clientX
?? (e as TouchEvent).changedTouches?.[0]?.clientX
?? 0;
const x = Math.max(0, Math.min(clientX - rect.left, rect.width));
return (x / rect.width) * 100;
};

this.listen(this.sliderBar, 'mousedown', () => { this.isMouseDown = true; });
this.listen(this.sliderBar, 'touchstart', () => { this.isMouseDown = true; });

this.listen(this.sliderBar, 'click', (e: Event) => {
this.isMouseDown = false;
const percent = getPercentFromEvent(e as MouseEvent);
const duration = this.player.duration();
void this.player.time(duration * (percent / 100));
sliderNipple.style.left = `${percent}%`;
});

this.listen(this.sliderBar, 'mousemove', (e: Event) => {
if (!this.isMouseDown) return;
const percent = getPercentFromEvent(e as MouseEvent);
sliderNipple.style.left = `${percent}%`;
sliderProgress.style.width = `${percent}%`;
});
this.listen(this.sliderBar, 'touchmove', (e: Event) => {
if (!this.isMouseDown) return;
const percent = getPercentFromEvent(e as TouchEvent);
sliderNipple.style.left = `${percent}%`;
sliderProgress.style.width = `${percent}%`;
});

this.listen(this.sliderBar, 'mouseleave', () => { this.isMouseDown = false; });

this.listen(this.sliderBar, 'keydown', (e: Event) => {
const key = (e as KeyboardEvent).key;
const duration = this.player.duration();
if (key === 'ArrowLeft') { e.preventDefault(); void this.player.rewind(5); }
else if (key === 'ArrowRight') { e.preventDefault(); void this.player.forward(5); }
else if (key === 'Home') { e.preventDefault(); void this.player.time(0); }
else if (key === 'End' && duration > 0) { e.preventDefault(); void this.player.time(duration); }
});

this.on('time', (data) => {
const duration = this.player.duration();
const pct = duration > 0 ? (data.time / duration) * 100 : 0;
if (this.isMouseDown) return;
sliderProgress.style.width = `${pct}%`;
sliderNipple.style.left = `${pct}%`;
this.sliderBar.setAttribute('aria-valuenow', String(Math.round(pct)));
});

this.on('current', () => {
sliderBuffer.style.width = '0%';
sliderProgress.style.width = '0%';
});
}
}

Register the plugin before setup():

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

const player = nmplayer('nomercy-player')
.addPlugin(PlayerUiPlugin)
.setup({
baseUrl: 'https://raw.githubusercontent.com/NoMercy-Entertainment/nomercy-media/master/Films',
playlist: [
{
id: 'sintel',
title: 'Sintel',
url: '/Sintel.(2010)/Sintel.(2010).NoMercy.m3u8',
image: 'https://image.tmdb.org/t/p/w780/q2bVM5z90tCGbmXYtq2J38T5hSX.jpg',
duration: 888,
},
],
});

player.on('ready', () => {
player.item(0, { autoplay: true });
});

Next: Step 4: Time Display and Skip Buttons