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:
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 controltabindex="0"so it receives keyboard focusaria-labelfor a human-readable namearia-valuemin/aria-valuemax/aria-valuenowupdated live as the position changes- a
keydownhandler forArrowLeft/ArrowRight(±5 s),Home(start), andEnd(end)
Without these, keyboard and screen-reader users have no way to seek.
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 rawaddEventListenerinside a plugin, because raw listeners are not removed ondispose().this.on('time', fn)subscribes to a player event through the plugin’s lifecycle. Thetimeevent fires on everytimeupdatetick from the backend and carries{ time: number }(seconds). There is nopercentagefield on this event, so the percentage is computed fromtime / 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 aPromise<void>, which is why thevoidkeyword 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.
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
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():
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 });
});