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
PlayerUiPluginclass,bottomRowreference, and plugin registration pattern are all explained there.
Volume API quick reference
| Method | Description |
|---|---|
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:
| Event | Payload | When |
|---|---|---|
'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:
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:
use(): void {
// ... DOM built in previous steps ...
this.createVolumeControl();
}
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:
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:
| Element | Role |
|---|---|
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:
mousedown/touchstartsetsdragging = true.mousemove/touchmovereads the pointer position withlevelFromPointer(), sets the player volume, and updates the fill bar — but only whiledraggingis true.clickperforms a single-shot jump to the clicked position and resetsdragging.mouseleaveon the slider, andmouseup/touchendondocument, resetdraggingso 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:
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:
.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
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():
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 });
});