Skip to content

Step 8: Quality, Subtitle, and Audio Selectors

This is the most involved step in the tutorial. By the end you will have three popup menus, an upgraded progress bar with a buffer indicator, and a fully assembled use() and dispose(). The three menus share the same open/close pattern, but each has its own highlighting logic, and the quality and subtitle menus also toggle their button icons to reflect the active state.

Before starting, make sure you have the plugin skeleton and bottom row from the earlier steps. The class shape used throughout this tutorial is:

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

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

use(): void { /* built here */ }
dispose(): void { /* built here */ }
}

Register before setup():

TypeScript
const player = nmplayer('nomercy-player')
.addPlugin(PlayerUiPlugin)
.setup(config);

Updating toggleMenu

In step 7 you tracked a single speed menu. Now toggleMenu handles all four:

TypeScript
private activeMenu: string | null = null;

private toggleMenu(name: string | null): void {
this.speedMenu?.classList.add('hidden');
this.speedMenu?.classList.remove('flex');
this.qualityMenu?.classList.add('hidden');
this.qualityMenu?.classList.remove('flex');
this.subtitleMenu?.classList.add('hidden');
this.subtitleMenu?.classList.remove('flex');
this.audioMenu?.classList.add('hidden');
this.audioMenu?.classList.remove('flex');

if (name === this.activeMenu || name === null) {
this.activeMenu = null;
return;
}

this.activeMenu = name;
const menu = this.getMenuByName(name);
if (menu) {
menu.classList.remove('hidden');
menu.classList.add('flex');
}
}

private getMenuByName(name: string): HTMLDivElement | null {
switch (name) {
case 'speed': return this.speedMenu;
case 'quality': return this.qualityMenu;
case 'subtitles': return this.subtitleMenu;
case 'audio': return this.audioMenu;
default: return null;
}
}

Progress bar with buffer indicator

Before the selectors, upgrade the progress bar from step 3. Two layers sit inside the slider: a buffer bar showing how far the browser has downloaded, and the existing progress bar showing the current playback position. The buffer bar uses inline style for zIndex and backgroundColor because those values never change.

A standalone updateBuffer function is called from both the 'time' event and a native 'progress' event listener on the <video> element, so the buffer bar updates whether the video is playing or stalled. The 'time' event payload is { time: number } — just a raw seconds value. The fmt helper referenced below formats seconds as M:SS or H:MM:SS; define it once at the top of your plugin file:

TypeScript
function fmt(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return '0:00';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
return hours > 0
? `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
: `${minutes}:${secs.toString().padStart(2, '0')}`;
}
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();

const sliderBuffer = this.player
.createElement('div', 'slider-buffer')
.addClasses(['absolute', 'top-0', 'left-0', 'h-full', 'rounded-full', 'pointer-events-none'])
.appendTo(this.sliderBar)
.get();
sliderBuffer.style.zIndex = '1';
sliderBuffer.style.backgroundColor = 'rgba(255, 255, 255, 0.4)';

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();
sliderProgress.style.zIndex = '2';

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 pointer position into a 0–100 percentage of the slider width.
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;
};

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

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

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

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

const updateBuffer = (): void => {
const video = this.player.videoElement;
if (video && video.buffered.length > 0 && video.duration > 0) {
const bufferedEnd = video.buffered.end(video.buffered.length - 1);
sliderBuffer.style.width = `${(bufferedEnd / video.duration) * 100}%`;
}
};

this.on('time', (data) => {
if (this.isMouseDown) return;
const duration = this.player.duration();
const percentage = duration > 0 ? (data.time / duration) * 100 : 0;
sliderProgress.style.width = `${percentage}%`;
sliderNipple.style.left = `${percentage}%`;
this.currentTimeLabel.textContent = fmt(data.time);
this.durationLabel.textContent = fmt(duration);
updateBuffer();
});

// Update the buffer bar even when the video is paused but still downloading.
// Use this.listen() so the handler is removed automatically on dispose.
const video = this.player.videoElement;
if (video) {
this.listen(video, 'progress', updateBuffer);
}

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

Note: Use this.listen(target, event, fn) for all DOM listeners so the plugin base removes them automatically on dispose(). This includes the <video> 'progress' listener — always use this.listen rather than raw addEventListener.

Key points:

  • sliderBuffer sits at zIndex: 1 and sliderProgress at zIndex: 2, so the playback position always draws on top.
  • The 'time' event payload carries only { time: number } — a raw seconds value. Percentage and formatted strings are computed locally: divide by player.duration() for percentage, and use a fmt() helper (e.g. M:SS formatter) for display.
  • updateBuffer reads the browser’s buffered TimeRanges to compute download progress.
  • The native 'progress' event on the <video> element fires even while paused, keeping the buffer bar live during downloads.

Accessibility requirements for popup menus

Each selector button and its popup menu need ARIA roles so keyboard and assistive-technology users can navigate them.

On the trigger button:

  • aria-haspopup="menu" — announces to screen readers that clicking opens a menu
  • aria-expanded="true"/"false" — updated as the menu opens and closes
  • aria-controls="<menu-id>" — links to the menu element

On the menu container:

  • role="menu"

On each menu item:

  • role="menuitem"
  • A aria-checked="true" or a visible checkmark on the active item — color highlight alone does not satisfy WCAG 1.4.1 (Use of Color), which requires a non-color cue for state

Focus behavior:

  • Focus moves into the menu when it opens (call .focus() on the first item)
  • Escape closes the menu and returns focus to the trigger button

The code examples in this step use background-highlight (bg-white/20) as the only selection indicator. That satisfies the visual affordance for sighted users, but you must also add aria-checked="true" to the active item and aria-checked="false" to all others, or prefix the label with a checkmark character. The examples below include aria-checked.

Quality selector

The quality menu has an Auto entry as its first row. When Auto is active, HLS picks the best level automatically. Picking a specific level switches to manual mode.

Two properties track this:

TypeScript
private isAutoQuality = true;
private selectedQualityIndex = -1;

'auto' is the initial state. When the user picks a level, isAutoQuality becomes false and selectedQualityIndex stores which level they chose. The player exposes player.quality('auto') to restore adaptive behavior.

TypeScript
private qualityMenu: HTMLDivElement | null = null;
private qualityButton: HTMLButtonElement | null = null;

private createQualityButton(): void {
this.qualityButton = this.player.createButton('quality-btn', 'Quality', () => {});
this.qualityButton.style.display = 'none';
this.qualityButton.setAttribute('aria-haspopup', 'menu');
this.qualityButton.setAttribute('aria-expanded', 'false');
this.qualityButton.setAttribute('aria-controls', 'quality-menu');
this.bottomRow.appendChild(this.qualityButton);
this.qualityButton.innerHTML = svgIcon(icons.quality.normal);

this.listen(this.qualityButton, 'click', (e) => {
e.stopPropagation();
this.toggleMenu('quality');
const open = this.activeMenu === 'quality';
this.qualityButton?.setAttribute('aria-expanded', String(open));
if (open) this.qualityMenu?.querySelector<HTMLElement>('[role=menuitem]')?.focus();
});

this.qualityMenu = this.player
.createElement('div', 'quality-menu')
.addClasses([
'absolute', 'bottom-12', 'right-0',
'bg-black/90', 'rounded-lg', 'p-2',
'hidden', 'flex-col', 'gap-1', 'min-w-[120px]',
'pointer-events-auto',
])
.appendTo(this.bottomRow)
.get();

this.qualityMenu.setAttribute('role', 'menu');
this.qualityMenu.id = 'quality-menu';

// Close menu on Escape, return focus to trigger button.
this.listen(this.qualityMenu, 'keydown', (e) => {
if ((e as KeyboardEvent).key === 'Escape') {
this.toggleMenu(null);
this.qualityButton?.setAttribute('aria-expanded', 'false');
this.qualityButton?.focus();
}
});

this.on('levels', ({ levels }) => {
if (!this.qualityMenu || !this.qualityButton) return;
this.qualityButton.style.display = levels.length > 1 ? '' : 'none';
this.qualityMenu.innerHTML = '';

// Auto option — lets HLS pick the best quality.
const autoOption = this.player
.createElement('button', 'quality-auto')
.addClasses(['text-white', 'text-sm', 'px-3', 'py-1.5', 'rounded', 'hover:bg-white/20', 'text-left', 'cursor-pointer'])
.appendTo(this.qualityMenu!)
.get();
autoOption.setAttribute('role', 'menuitem');
autoOption.setAttribute('aria-checked', 'true');
autoOption.textContent = 'Auto';
this.listen(autoOption, 'click', (e) => {
e.stopPropagation();
this.player.quality('auto');
this.isAutoQuality = true;
this.highlightCurrentQuality();
this.toggleMenu(null);
this.qualityButton?.setAttribute('aria-expanded', 'false');
this.qualityButton?.focus();
});

levels.forEach((level, index) => {
const option = this.player
.createElement('button', `quality-${index}`)
.addClasses(['text-white', 'text-sm', 'px-3', 'py-1.5', 'rounded', 'hover:bg-white/20', 'text-left', 'cursor-pointer'])
.appendTo(this.qualityMenu!)
.get();
option.setAttribute('role', 'menuitem');
option.setAttribute('aria-checked', 'false');
option.textContent = level.label || (level.height ? `${level.height}p` : `Level ${index + 1}`);
this.listen(option, 'click', (e) => {
e.stopPropagation();
this.player.quality(index);
this.isAutoQuality = false;
this.selectedQualityIndex = index;
this.highlightCurrentQuality();
this.toggleMenu(null);
this.qualityButton?.setAttribute('aria-expanded', 'false');
this.qualityButton?.focus();
});
});

this.isAutoQuality = true;
this.highlightCurrentQuality();
});

// Re-highlight on every level switch (HLS swaps levels in Auto mode).
this.on('level-switched', () => this.highlightCurrentQuality());
}

Three things to note:

  1. The Auto button is always index 0 in the rendered list. It calls quality('auto'), which is the v2 API for handing control back to HLS adaptive bitrate.
  2. Manual selection calls quality(index) with a zero-based index into the levels array.
  3. After the 'levels' event fires, isAutoQuality resets to true because the player defaults to Auto on every new source.

Highlighting and icon toggling

highlightCurrentQuality has to account for the Auto button sitting at DOM index 0 while the level buttons start at DOM index 1:

TypeScript
private highlightCurrentQuality(): void {
if (!this.qualityMenu) return;

const buttons = this.qualityMenu.querySelectorAll('button');
buttons.forEach((btn, domIndex) => {
if (domIndex === 0) {
// The Auto button is active when no explicit level has been picked.
const active = this.isAutoQuality;
btn.classList.toggle('bg-white/20', active);
btn.setAttribute('aria-checked', String(active));
} else {
// Level buttons are offset by 1 to account for the Auto button.
const levelIndex = domIndex - 1;
const active = !this.isAutoQuality && levelIndex === this.selectedQualityIndex;
btn.classList.toggle('bg-white/20', active);
btn.setAttribute('aria-checked', String(active));
}
});

// Outlined icon when a specific level is locked; filled icon in Auto mode.
const iconEl = this.qualityButton?.querySelector('path');
if (iconEl) {
iconEl.setAttribute('d', this.isAutoQuality ? icons.quality.normal : icons.quality.hover);
}
}

The icon swap gives users a persistent signal: the filled “HD” shape means the player is managing quality automatically; the outlined shape means a manual level is locked.

Subtitle selector

The subtitle menu always starts with an Off button. The 'subtitle' event on BaseEventMap fires when the active track index changes. To know which tracks are available, use player.subtitles() inside the 'current' handler (called when a new playlist item loads), then rebuild the menu.

Some sources include tracks whose label is 'off', 'disabled', or 'none'. Those duplicate the Off button and should be filtered out:

TypeScript
private subtitleMenu: HTMLDivElement | null = null;
private subtitleButton: HTMLButtonElement | null = null;

private createSubtitleButton(): void {
this.subtitleButton = this.player.createButton('subtitle-btn', 'Subtitles', () => {});
this.subtitleButton.style.display = 'none';
this.subtitleButton.setAttribute('aria-haspopup', 'menu');
this.subtitleButton.setAttribute('aria-expanded', 'false');
this.subtitleButton.setAttribute('aria-controls', 'subtitle-menu');
this.bottomRow.appendChild(this.subtitleButton);
this.subtitleButton.innerHTML = svgIcon(icons.subtitles.hover);

this.listen(this.subtitleButton, 'click', (e) => {
e.stopPropagation();
this.toggleMenu('subtitles');
const open = this.activeMenu === 'subtitles';
this.subtitleButton?.setAttribute('aria-expanded', String(open));
if (open) this.subtitleMenu?.querySelector<HTMLElement>('[role=menuitem]')?.focus();
});

this.subtitleMenu = this.player
.createElement('div', 'subtitle-menu')
.addClasses([
'absolute', 'bottom-12', 'right-0',
'bg-black/90', 'rounded-lg', 'p-2',
'hidden', 'flex-col', 'gap-1', 'min-w-[120px]',
'pointer-events-auto',
])
.appendTo(this.bottomRow)
.get();

this.subtitleMenu.setAttribute('role', 'menu');
this.subtitleMenu.id = 'subtitle-menu';

this.listen(this.subtitleMenu, 'keydown', (e) => {
if ((e as KeyboardEvent).key === 'Escape') {
this.toggleMenu(null);
this.subtitleButton?.setAttribute('aria-expanded', 'false');
this.subtitleButton?.focus();
}
});

// Rebuild the menu whenever a new item loads (new item = new track list).
this.on('current', () => this.rebuildSubtitleMenu());

// Re-highlight when the active subtitle changes (e.g. via KeyHandlerPlugin).
this.on('subtitle', () => this.highlightCurrentSubtitle());
}

private rebuildSubtitleMenu(): void {
if (!this.subtitleMenu || !this.subtitleButton) return;

const tracks = this.player.subtitles();

const filteredTracks = tracks.filter((track) => {
const label = (track.label || track.language || '').toLowerCase();
return label !== 'off' && label !== 'disabled' && label !== 'none';
});

this.subtitleButton.style.display = filteredTracks.length > 0 ? '' : 'none';
this.subtitleMenu.innerHTML = '';

// Off option — disables all subtitle tracks.
const offOption = this.player
.createElement('button', 'subs-off')
.addClasses(['text-white', 'text-sm', 'px-3', 'py-1.5', 'rounded', 'hover:bg-white/20', 'text-left', 'cursor-pointer'])
.appendTo(this.subtitleMenu!)
.get();
offOption.setAttribute('role', 'menuitem');
offOption.setAttribute('aria-checked', 'false');
offOption.textContent = 'Off';
this.listen(offOption, 'click', (e) => {
e.stopPropagation();
this.player.subtitle(null);
this.toggleMenu(null);
this.subtitleButton?.setAttribute('aria-expanded', 'false');
this.subtitleButton?.focus();
});

filteredTracks.forEach((track, index) => {
const option = this.player
.createElement('button', `subs-${index}`)
.addClasses(['text-white', 'text-sm', 'px-3', 'py-1.5', 'rounded', 'hover:bg-white/20', 'text-left', 'cursor-pointer'])
.appendTo(this.subtitleMenu!)
.get();
option.setAttribute('role', 'menuitem');
option.setAttribute('aria-checked', 'false');
option.textContent = track.label || track.language || `Track ${index + 1}`;
this.listen(option, 'click', (e) => {
e.stopPropagation();
this.player.subtitle(index);
this.toggleMenu(null);
this.subtitleButton?.setAttribute('aria-expanded', 'false');
this.subtitleButton?.focus();
});
});

this.highlightCurrentSubtitle();
}

Note: player.subtitle(null) is the v2 call that turns subtitles off. The older v1 convention of passing -1 is not the v2 contract. The getter form player.subtitle() returns a CurrentSubtitleSelection object (with .index and .track) when a track is active, or null when off — not a bare number.

Subtitle highlighting and icon toggling

player.subtitle() returns a CurrentSubtitleSelection object (with an .index property) when a track is active, or null when subtitles are off. The Off button is at DOM index 0; the track buttons start at DOM index 1:

TypeScript
private highlightCurrentSubtitle(): void {
if (!this.subtitleMenu) return;

const current = this.player.subtitle();
const isOff = current === null;
const currentIndex = isOff ? -1 : current.index;

this.subtitleMenu.querySelectorAll('button').forEach((btn, domIndex) => {
if (domIndex === 0) {
const active = isOff;
btn.classList.toggle('bg-white/20', active);
btn.setAttribute('aria-checked', String(active));
} else {
const active = !isOff && (domIndex - 1) === currentIndex;
btn.classList.toggle('bg-white/20', active);
btn.setAttribute('aria-checked', String(active));
}
});

// Filled icon when a subtitle is active; outlined icon when off.
const iconEl = this.subtitleButton?.querySelector('path');
if (iconEl) {
iconEl.setAttribute('d', !isOff ? icons.subtitles.normal : icons.subtitles.hover);
}
}

Audio selector

The audio selector is the simplest of the three. There is no Off option because at least one audio track is always active. The button is only visible when the source carries more than one track, which is uncommon for most content but expected for multi-language HLS manifests.

TypeScript
private audioMenu: HTMLDivElement | null = null;
private audioButton: HTMLButtonElement | null = null;

private createAudioButton(): void {
this.audioButton = this.player.createButton('audio-btn', 'Audio', () => {});
this.audioButton.style.display = 'none';
this.audioButton.setAttribute('aria-haspopup', 'menu');
this.audioButton.setAttribute('aria-expanded', 'false');
this.audioButton.setAttribute('aria-controls', 'audio-menu');
this.bottomRow.appendChild(this.audioButton);
this.audioButton.innerHTML = svgIcon(icons.audio);

this.listen(this.audioButton, 'click', (e) => {
e.stopPropagation();
this.toggleMenu('audio');
const open = this.activeMenu === 'audio';
this.audioButton?.setAttribute('aria-expanded', String(open));
if (open) this.audioMenu?.querySelector<HTMLElement>('[role=menuitem]')?.focus();
});

this.audioMenu = this.player
.createElement('div', 'audio-menu')
.addClasses([
'absolute', 'bottom-12', 'right-0',
'bg-black/90', 'rounded-lg', 'p-2',
'hidden', 'flex-col', 'gap-1', 'min-w-[120px]',
'pointer-events-auto',
])
.appendTo(this.bottomRow)
.get();

this.audioMenu.setAttribute('role', 'menu');
this.audioMenu.id = 'audio-menu';

this.listen(this.audioMenu, 'keydown', (e) => {
if ((e as KeyboardEvent).key === 'Escape') {
this.toggleMenu(null);
this.audioButton?.setAttribute('aria-expanded', 'false');
this.audioButton?.focus();
}
});

this.on('audioTracks', ({ tracks }) => {
if (!this.audioMenu || !this.audioButton) return;
this.audioButton.style.display = tracks.length > 1 ? '' : 'none';
this.audioMenu.innerHTML = '';

tracks.forEach((track, index) => {
const option = this.player
.createElement('button', `audio-${index}`)
.addClasses(['text-white', 'text-sm', 'px-3', 'py-1.5', 'rounded', 'hover:bg-white/20', 'text-left', 'cursor-pointer'])
.appendTo(this.audioMenu!)
.get();
option.setAttribute('role', 'menuitem');
option.setAttribute('aria-checked', 'false');
option.textContent = track.label || track.language || `Track ${index + 1}`;
this.listen(option, 'click', (e) => {
e.stopPropagation();
this.player.audioTrack(index);
this.toggleMenu(null);
this.audioButton?.setAttribute('aria-expanded', 'false');
this.audioButton?.focus();
});
});

this.highlightCurrentAudio();
});

this.on('audioTracks', () => this.highlightCurrentAudio());
}

private highlightCurrentAudio(): void {
if (!this.audioMenu) return;
const current = this.player.audioTrack();
const activeIndex = current !== null ? current.index : 0;
this.audioMenu.querySelectorAll('button').forEach((btn, index) => {
const active = index === activeIndex;
btn.classList.toggle('bg-white/20', active);
btn.setAttribute('aria-checked', String(active));
});
}

The 'audioTracks' event fires when the HLS manifest has been parsed and the track list is available. That is the right time to build the menu, not at plugin startup when the list is empty.

Assembling use()

The use() method runs in declaration order. Build the layout first, then the controls, then wire the initial state:

TypeScript
use(): void {
// Layout containers
this.createTopBar();
this.createTitle();
this.createCenterButton();
this.createSpinner();
this.createBottomBar();

// Progress row (above the button row)
this.createProgressBar();

// Button row — left to right
this.createBottomRow();
this.createPlaybackButton();
this.createSkipButtons();
this.createTimeDisplay();
this.createVolumeControl();

// Right-side controls
this.createSpeedButton();
this.createQualityButton();
this.createSubtitleButton();
this.createAudioButton();
this.createFullscreenButton();

// Close menus when the user clicks outside them.
this.listen(document, 'click', this.onDocumentClick);

// When autoplay is off the video starts paused but no 'play' event fires,
// so hide the center button only when the element is already playing.
if (this.player.videoElement?.paused === false) {
this.centerButton.style.display = 'none';
}
}

The this.listen(document, 'click', fn) call routes through the plugin base’s listener registry so it is removed automatically in dispose().

dispose()

All listeners registered via this.listen() are removed automatically by the lifecycle registry, including document-level listeners. The dispose() only needs to remove DOM elements not tracked by this.mount():

TypeScript
dispose(): void {
// this.listen() auto-removes every listener, including the document click handler.
// Remove DOM elements — removing a parent also removes all child elements.
this.topBar?.remove();
this.bottomBar?.remove();
this.centerButton?.remove();
this.spinner?.remove();
this.speedMenu?.remove();
this.qualityMenu?.remove();
this.subtitleMenu?.remove();
this.audioMenu?.remove();
}

Full working example

Put it together with Sintel from the public media catalog:

TypeScript
import nmplayer from '@nomercy-entertainment/nomercy-video-player';
import { Plugin } from '@nomercy-entertainment/nomercy-player-core';
import type {
NMVideoPlayer,
VideoPlayerConfig,
VideoPlaylistItem,
QualityLevel,
} from '@nomercy-entertainment/nomercy-video-player';
import type { SubtitleTrack, AudioTrack } from '@nomercy-entertainment/nomercy-player-core';

// ... icons object (your SVG path strings) ...
// ... svgIcon(path) helper ...
// ... full PlayerUiPlugin class with all steps 1–8 assembled ...

const config: VideoPlayerConfig = {
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,
subtitles: [
{
id: 'eng-full',
kind: 'subtitles',
language: 'eng',
label: 'English',
url: '/Sintel.(2010)/subtitles/Sintel.(2010).NoMercy.eng.full.vtt',
},
],
},
],
};

const player = nmplayer('nomercy-player')
.addPlugin(PlayerUiPlugin)
.setup(config);

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

After step 8 your plugin provides:

  • A center play/pause button and buffering spinner.
  • A progress bar with buffer indicator, click-to-seek, and drag-to-scrub.
  • Time display showing current position and duration.
  • Skip back and forward buttons.
  • A volume control with mute toggle and expanding slider.
  • A title bar showing the current item.
  • A fullscreen toggle.
  • A playback speed selector.
  • A quality-level selector with Auto mode and icon feedback, visible only when the source has multiple levels.
  • A subtitle selector with track filtering and icon feedback, visible only when tracks are available.
  • An audio track selector, visible only when the source carries multiple audio tracks.
  • Auto-hiding controls driven by CSS classes on .nomercyplayer.
  • Clean teardown in dispose().

Tailwind note: the class lists above use Tailwind utility classes, matching the tutorial’s default styling approach. If your project does not use Tailwind, replace those class arrays with plain CSS rules targeting the element ids and your own class names.

Next: Step 9: Seek Preview Thumbnails