Skip to content

Step 5: Volume Control

This step adds a volume section to the bottom row: a mute toggle button with three icon states (muted, low, high) and a custom div-based horizontal slider that expands on hover.

Rather than using a native <input type="range">, the slider is built from plain <div> elements with drag handling wired by hand. That gives full control over the appearance and avoids the browser’s opinionated range-input styling. If you are fine with a native slider, swap the divs for an <input type="range"> and skip the drag logic.

Before you start: This page assumes you have completed Step 4. The PlayerUiPlugin class, bottomRow reference, and plugin registration pattern are all explained there.

Volume API quick reference

MethodDescription
player.volume()Returns the current volume as a number, 0-100.
player.volume(n)Sets volume to n (0-100).
player.mute()Mutes without changing the stored volume level.
player.unmute()Unmutes, restoring the stored level.
player.toggleMute()Flips between muted and unmuted.
player.volumeState()Returns VolumeState.MUTED or VolumeState.UNMUTED.

The player emits two separate events when volume state changes:

EventPayloadWhen
'volume'{ level: number }The numeric level changed (0-100).
'mute'{ muted: boolean }Mute state toggled without a level change.

Listen to both to keep the UI in sync regardless of which happened. The 'volume' event fires on player.volume(n) calls; the 'mute' event fires on player.mute(), player.unmute(), and player.toggleMute().

Track the slider element

Add a private field on the plugin class to hold the slider track element so the use() and event handlers can share it:

TypeScript
class PlayerUiPlugin extends Plugin {
static readonly id = 'player-ui';

// ... other private fields from previous steps ...
private volumeSlider!: HTMLDivElement;
}

Accessibility for the volume slider

The div-based volume slider needs the same ARIA treatment as the seek bar so keyboard and assistive-technology users can adjust volume without a pointer. After construction, apply role="slider", tabindex="0", aria-label, and aria-valuemin/aria-valuemax/aria-valuenow. Wire keydown for ArrowLeft/ArrowRight (±5 volume), Home (mute / 0), and End (100).

If you would prefer to skip this wiring, the native <input type="range"> alternative at the bottom of this section handles all of it for free.

Build the volume control

Add a createVolumeControl() method and call it from use() after the time display from Step 4:

TypeScript
use(): void {
// ... DOM built in previous steps ...
this.createVolumeControl();
}
TypeScript
private createVolumeControl(): void {
// Outer container. `group/volume` is a Tailwind group context so the
// slider children can expand when this container is hovered or focused.
const volumeContainer = this.player.createElement('div', 'volume-container')
.addClasses(['flex', 'items-center', 'group/volume', 'ml-1'])
.appendTo(this.bottomRow)
.get();

// The mute toggle button. createButton(id, label, onClick) — all three args required.
const volumeButton = this.player.createButton('volume', 'Mute', (e) => {
e.stopPropagation();
this.player.toggleMute();
});
volumeContainer.appendChild(volumeButton);

// Three SVG icon states, only one visible at a time.
// createSVG(id, viewBox) returns a bare SVGSVGElement — append and fill it manually.
const iconHigh = this.player.createSVG('vol-high', '0 0 24 24');
iconHigh.innerHTML = icons.volumeHigh;
volumeButton.appendChild(iconHigh);

const iconLow = this.player.createSVG('vol-low', '0 0 24 24');
iconLow.innerHTML = icons.volumeLow;
volumeButton.appendChild(iconLow);

const iconMuted = this.player.createSVG('vol-muted', '0 0 24 24');
iconMuted.innerHTML = icons.volumeMuted;
volumeButton.appendChild(iconMuted);

// The slider track. Collapsed to zero width by default; expands when
// the parent group/volume container is hovered or has keyboard focus.
this.volumeSlider = this.player.createElement('div', 'volume-slider')
.addClasses([
'relative', 'h-1', 'rounded-full', 'bg-white/20',
'cursor-pointer', 'group/vol-slider',
'w-0', 'opacity-0',
'group-hover/volume:w-20', 'group-hover/volume:mx-2', 'group-hover/volume:opacity-100',
'group-focus-within/volume:w-20', 'group-focus-within/volume:mx-2',
'group-focus-within/volume:opacity-100',
'hover:h-2',
'transition-all', 'duration-150',
])
.appendTo(volumeContainer)
.get();

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

// White fill bar — width is set as a percentage by updateSliderUI().
const volumeProgress = this.player.createElement('div', 'volume-progress')
.addClasses([
'absolute', 'top-0', 'left-0', 'h-full',
'bg-white', 'rounded-full', 'pointer-events-none',
])
.appendTo(this.volumeSlider)
.get();

// Circular drag handle — hidden by default, visible on slider hover.
const volumeHandle = this.player.createElement('div', 'volume-handle')
.addClasses([
'absolute', 'top-1/2', '-translate-y-1/2', '-translate-x-1/2',
'w-3', 'h-3', 'rounded-full', 'bg-white',
'hidden', 'group-hover/vol-slider:flex',
'pointer-events-none', 'z-20',
])
.appendTo(this.volumeSlider)
.get();

// Sync both child elements to a volume percentage (0-100).
const updateSliderUI = (pct: number): void => {
const clamped = Math.max(0, Math.min(pct, 100));
volumeProgress.style.width = `${clamped}%`;
volumeHandle.style.left = `${clamped}%`;
};

// Update the icon set based on current level and mute state.
const updateIcon = (level: number, muted: boolean): void => {
iconHigh.style.display = 'none';
iconLow.style.display = 'none';
iconMuted.style.display = 'none';

if (muted || level === 0) {
iconMuted.style.display = 'flex';
} else if (level < 50) {
iconLow.style.display = 'flex';
} else {
iconHigh.style.display = 'flex';
}
};

// ── Drag handling ──────────────────────────────────────────────────

let dragging = false;

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

this.listen(this.volumeSlider, 'mousedown', () => { dragging = true; }, { passive: true });
this.listen(this.volumeSlider, 'touchstart', () => { dragging = true; }, { passive: true });

this.listen(this.volumeSlider, 'click', (e) => {
dragging = false;
const level = levelFromPointer(e as MouseEvent);
this.player.volume(level);
updateSliderUI(level);
});

this.listen(this.volumeSlider, 'mousemove', (e) => {
if (!dragging) return;
const level = levelFromPointer(e as MouseEvent);
this.player.volume(level);
updateSliderUI(level);
}, { passive: true });

this.listen(this.volumeSlider, 'touchmove', (e) => {
if (!dragging) return;
const level = levelFromPointer(e as TouchEvent);
this.player.volume(level);
updateSliderUI(level);
}, { passive: true });

this.listen(this.volumeSlider, 'mouseleave', () => { dragging = false; }, { passive: true });
this.listen(document, 'mouseup', () => { dragging = false; }, { passive: true });
this.listen(document, 'touchend', () => { dragging = false; }, { passive: true });

// Keyboard control: ArrowLeft/Right ±5, Home = 0, End = 100.
this.listen(this.volumeSlider, 'keydown', (e) => {
const key = (e as KeyboardEvent).key;
if (key === 'ArrowLeft') {
e.preventDefault();
this.player.volumeDown(5);
} else if (key === 'ArrowRight') {
e.preventDefault();
this.player.volumeUp(5);
} else if (key === 'Home') {
e.preventDefault();
this.player.volume(0);
} else if (key === 'End') {
e.preventDefault();
this.player.volume(100);
}
});

// ── Player event listeners ─────────────────────────────────────────

// `'volume'` fires when the numeric level changes.
this.on('volume', ({ level }) => {
updateSliderUI(level);
const muted = this.player.volumeState() === VolumeState.MUTED;
updateIcon(level, muted);
this.volumeSlider.setAttribute('aria-valuenow', String(level));
});

// `'mute'` fires when the mute state toggles without a level change.
this.on('mute', ({ muted }) => {
const level = this.player.volume();
updateSliderUI(level);
updateIcon(level, muted);
this.volumeSlider.setAttribute('aria-valuenow', String(level));
});

// Set initial UI from current player state.
const initialLevel = this.player.volume();
const initialMuted = this.player.volumeState() === VolumeState.MUTED;
updateSliderUI(initialLevel);
updateIcon(initialLevel, initialMuted);
}

The imports you need at the top of the plugin file:

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

How the slider is built

Three nested <div> elements replace the native range input:

ElementRole
volumeSlider (the track)The outer container. group/vol-slider lets children react to its hover state. Starts at w-0 and expands to w-20 when the parent group/volume is hovered or contains keyboard focus. Grows from h-1 to h-2 on its own hover.
volumeProgress (the fill)Absolutely positioned white bar. Its width is set as a percentage by updateSliderUI().
volumeHandle (the dot)Small white circle. Hidden by default, shown via group-hover/vol-slider:flex. Its left position tracks the same percentage as the fill bar.

How dragging works

A dragging boolean gates the move handlers:

  1. mousedown / touchstart sets dragging = true.
  2. mousemove / touchmove reads the pointer position with levelFromPointer(), sets the player volume, and updates the fill bar — but only while dragging is true.
  3. click performs a single-shot jump to the clicked position and resets dragging.
  4. mouseleave on the slider, and mouseup / touchend on document, reset dragging so the drag ends even if the pointer leaves the track.

levelFromPointer() handles both mouse and touch events by checking clientX, touches, and changedTouches in that order, returning a clamped 0-100 integer.

Listening to both volume events

The player separates volume changes into two events. 'volume' carries { level: number } and fires when player.volume(n) is called. 'mute' carries { muted: boolean } and fires when mute state is toggled. To read current mute state inside a 'volume' listener, call player.volumeState() and compare against VolumeState.MUTED.

Wiring both keeps the slider and icon current regardless of whether the change came from the control bar, a keyboard shortcut plugin, or any external call to the player API.

Native <input type="range"> alternative

If you want accessible keyboard behavior without writing drag logic or ARIA attributes by hand, swap the div-based slider for a native <input type="range">. The browser handles keyboard seek, focus ring, screen-reader announcements, and touch input automatically:

TypeScript
const nativeSlider = document.createElement('input');
nativeSlider.type = 'range';
nativeSlider.min = '0';
nativeSlider.max = '100';
nativeSlider.step = '1';
nativeSlider.value = String(this.player.volume());
nativeSlider.setAttribute('aria-label', 'Volume');
volumeContainer.appendChild(nativeSlider);

this.listen(nativeSlider, 'input', () => {
this.player.volume(Number(nativeSlider.value));
});
this.on('volume', ({ level }) => {
nativeSlider.value = String(level);
});

The trade-off is less control over the visual appearance — styling input[type=range] cross-browser requires extra CSS. The div-based slider gives you pixel-perfect styling; the native input gives you accessibility for free.

Styling without Tailwind

If you are not using Tailwind, the Tailwind utility classes in the code samples map to plain CSS properties. The hover-expansion behaviour is driven by CSS group contexts (group/volume, group/vol-slider). Without Tailwind you can replicate that with a normal CSS hover rule on the container:

CSS
.volume-slider {
width: 0;
opacity: 0;
transition: width 150ms, opacity 150ms, margin 150ms, height 150ms;
}

.volume-container:hover .volume-slider,
.volume-container:focus-within .volume-slider {
width: 5rem; /* w-20 */
opacity: 1;
margin: 0 0.5rem;
}

.volume-slider:hover {
height: 0.5rem; /* h-2 */
}

.volume-handle {
display: none;
}

.volume-slider:hover .volume-handle {
display: flex;
}

Full plugin shape so far

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

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

private overlay!: HTMLDivElement;
private bottomRow!: HTMLDivElement;
private volumeSlider!: HTMLDivElement;

use(): void {
this.buildLayout();
// Steps 1-4: play button, seek bar, time display ...
this.createVolumeControl();
}

dispose(): void {}

// createVolumeControl() from above goes here.
}

Register the plugin before calling setup():

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

const player = nmplayer('player-container')
.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 6: Top Bar with Title