Build a Player: Seek Preview & Chapters
Two features left before the touch layer, and they share a home: the scrubber. A thumbnail tooltip that follows the pointer, and chapter marks, ticks on the bar plus the chapter’s title inside the tooltip.
Both are fields on the playlist item from step 1. previewSpriteUrl names a VTT manifest whose cues map time ranges to regions of one sprite image (thumbs.webp#xywh=x,y,w,h), and chapters carries the { index, start, end, title } entries the player exposes through chapters() and announces through the chapters event. This step is where both fields light up.
Six fields and a PreviewCue shape:
interface PreviewCue {
start: number;
end: number;
imageUrl: string;
x: number;
y: number;
w: number;
h: number;
}
private previewCues: PreviewCue[] = []; // NEW
private sliderPop!: HTMLDivElement; // NEW
private sliderPopImage!: HTMLDivElement; // NEW
private sliderPopChapter!: HTMLSpanElement; // NEW
private sliderPopText!: HTMLSpanElement; // NEW
private chapterLayer!: HTMLDivElement; // NEW
override use(): void {
// ...everything from step 8, unchanged
this.createChapterMarkers(); // NEW
this.createSeekPreview(); // NEW
}
Chapter ticks on the bar
A pointer-events-none layer over the step-3 slider bar holds one thin tick per chapter start (skipping the one at zero, the bar’s left edge already marks it). Ticks are re-rendered whenever the chapter list or the duration resolves, whichever arrives last:
private createChapterMarkers(): void {
this.chapterLayer = this.player
.createElement('div', 'chapter-markers')
.addClasses([
'absolute',
'inset-0',
'pointer-events-none',
'z-10',
])
.appendTo(this.sliderBar)
.get();
this.on('chapters', () => this.renderChapterMarkers());
this.on('duration', () => this.renderChapterMarkers());
}
private renderChapterMarkers(): void {
this.chapterLayer.innerHTML = '';
const { duration } = this.player.timeData();
if (duration <= 0)
return;
for (const chapter of this.player.chapters()) {
if (chapter.start <= 0)
continue;
const tick = this.player
.createElement('div', `chapter-tick-${chapter.index}`)
.addClasses([
'absolute',
'top-0',
'h-full',
'w-0.5',
'-translate-x-1/2',
'bg-black/70',
])
.appendTo(this.chapterLayer)
.get();
tick.style.left = `${(chapter.start / duration) * 100}%`;
}
}
Clicking a tick needs no code at all: the ticks don’t take pointer events, so the click lands on the slider bar underneath and the step-3 seek handler does what it always did.
The tooltip: a card above the bar
One card, hidden until hover: thumbnail on top, chapter title under it, timestamp at the bottom. The card carries the background so the sprite region and the labels read as one surface, the same construction the shipped plugin uses:
private createSeekPreview(): void {
this.sliderPop = this.player
.createElement('div', 'slider-pop')
.addClasses([
'absolute',
'bottom-full',
'mb-3',
'flex',
'flex-col',
'items-center',
'gap-1',
'rounded-md',
'overflow-hidden',
'bg-[rgba(20,20,25,0.95)]',
'pb-1',
'min-w-[60px]',
'pointer-events-none',
'z-30',
'opacity-0',
'transition-opacity',
'duration-150',
])
.appendTo(this.sliderBar)
.get();
this.sliderPopImage = this.player
.createElement('div', 'slider-pop-image')
.addClasses([
'mx-1.5',
'mt-1.5',
'rounded',
'bg-no-repeat',
])
.appendTo(this.sliderPop)
.get();
this.sliderPopChapter = this.player
.createElement('span', 'slider-pop-chapter')
.addClasses([
'text-white/80',
'text-xs',
'px-2',
'max-w-[240px]',
'truncate',
])
.appendTo(this.sliderPop)
.get();
this.sliderPopChapter.style.display = 'none';
this.sliderPopText = this.player
.createElement('span', 'slider-pop-text')
.addClasses([
'text-white',
'text-xs',
'tabular-nums',
'px-2',
])
.appendTo(this.sliderPop)
.get();
// ...the cue loading and hover wiring below live here too
}
The chapter span starts hidden and only shows while a chapter actually covers the hovered time, so items without chapters get the plain thumbnail-plus-timestamp card.
Fetching through the plugin
this.fetch() is the plugin base’s auth-aware fetch: it resolves the URL through the player’s auth config, attaches credentials when configured, retries per policy, and ties the request’s lifetime to the plugin. The sprite manifest is plain text, so the default text response is exactly right; scope: 'silent' keeps a missing manifest from surfacing as a player error, no thumbnails is a fine state:
private async fetchPreviewCues(): Promise<void> {
if (this.previewCues.length > 0)
return;
const item = this.player.item();
const spritePath = item?.previewSpriteUrl;
if (!spritePath)
return;
const vttUrl = `${this.player.options.baseUrl ?? ''}${spritePath}`;
try {
const raw = await this.fetch<string>(vttUrl, { scope: 'silent' });
this.previewCues = parsePreviewVtt(raw, vttUrl);
}
catch {
this.previewCues = [];
}
}
The parser is module-level code: split cue blocks, read the --> timestamps, match the #xywh= region, and resolve the sprite image against the VTT’s own URL, sprite paths are relative to their manifest, like every sprite format:
function parsePreviewVtt(raw: string, vttUrl: string): PreviewCue[] {
const cues: PreviewCue[] = [];
const timestamp = /(?:(\d+):)?(\d{2}):(\d{2})\.(\d{3})/gu;
const region = /^(?<file>[^#]+)#xywh=(?<x>\d+),(?<y>\d+),(?<w>\d+),(?<h>\d+)$/u;
const toSeconds = (match: RegExpExecArray): number => {
const [, hours, minutes, seconds, millis] = match;
return Number(hours ?? 0) * 3600 + Number(minutes) * 60 + Number(seconds) + Number(millis) / 1000;
};
for (const block of raw.split(/\r?\n\r?\n/)) {
const lines = block.trim().split(/\r?\n/);
const cueLineIndex = lines.findIndex(line => line.includes('-->'));
if (cueLineIndex === -1)
continue;
const cueLine = lines[cueLineIndex] ?? '';
timestamp.lastIndex = 0;
const startMatch = timestamp.exec(cueLine);
const endMatch = timestamp.exec(cueLine);
const regionMatch = region.exec(lines[cueLineIndex + 1] ?? '');
const { file, x, y, w, h } = regionMatch?.groups ?? {};
if (!startMatch || !endMatch || !file || !x || !y || !w || !h)
continue;
cues.push({
start: toSeconds(startMatch),
end: toSeconds(endMatch),
imageUrl: new URL(file, vttUrl).toString(),
x: Number(x),
y: Number(y),
w: Number(w),
h: Number(h),
});
}
return cues;
}
Hover maps X to a cue and a chapter
mousemove over the step-3 slider bar converts the pointer position to a time (the same math the scrubber uses), finds the covering cue, paints the tooltip by shifting the sprite’s background-position, and looks up the covering chapter for the title line. Clamping measures the card’s real width, offsetWidth, so the tooltip stays inside the bar at both ends, and leaving the bar fades it out:
this.listen(this.sliderBar, 'mousemove', (event) => {
if (this.previewCues.length === 0)
return;
const rect = this.sliderBar.getBoundingClientRect();
const x = Math.max(0, Math.min((event as MouseEvent).clientX - rect.left, rect.width));
const percent = x / rect.width;
const scrubTime = percent * this.player.duration();
const preview = this.previewCues.find(
cue => scrubTime >= cue.start && scrubTime < cue.end,
) ?? this.previewCues.at(-1);
if (preview) {
this.sliderPopImage.style.backgroundImage = `url('${preview.imageUrl}')`;
this.sliderPopImage.style.backgroundPosition = `-${preview.x}px -${preview.y}px`;
this.sliderPopImage.style.width = `${preview.w}px`;
this.sliderPopImage.style.height = `${preview.h}px`;
const popWidth = this.sliderPop.offsetWidth || preview.w;
const minLeft = popWidth / 2;
const maxLeft = rect.width - popWidth / 2;
const clampedX = Math.max(minLeft, Math.min(x, maxLeft));
this.sliderPop.style.left = `${clampedX}px`;
this.sliderPop.style.transform = 'translateX(-50%)';
const chapterTitle = this.player.chapters()
.find(chapter => scrubTime >= chapter.start && scrubTime <= chapter.end)
?.title ?? '';
this.sliderPopChapter.textContent = chapterTitle;
this.sliderPopChapter.style.display = chapterTitle ? '' : 'none';
this.sliderPopText.textContent = formatSeconds(scrubTime);
}
this.sliderPop.style.opacity = '1';
}, { passive: true });
this.listen(this.sliderBar, 'mouseleave', () => {
this.sliderPop.style.opacity = '0';
}, { passive: true });
Cues load at ready and reload on item (after clearing, so a playlist jump never shows the previous film’s thumbnails):
this.on('ready', () => void this.fetchPreviewCues());
this.on('item', () => {
this.previewCues = [];
void this.fetchPreviewCues();
});
See it running
Hover along the scrubber and Big Buck Bunny’s chapter ticks mark each act, from “Opening” through “The Bullies” to “End Credits”. The demo item ships chapters but no sprite manifest, so those ticks are what you see; give an item a previewSpriteUrl and the same hover fills the card with sprite frames, the covering chapter’s title, and the timestamp underneath.
Loading player…
/**
* Build a Player, step 9 of 10: Seek Preview & Chapters.
*
* Adds to step 8: a thumbnail tooltip above the slider bar, and chapter
* ticks on it. The playlist item's `previewSpriteUrl` names a VTT manifest
* whose cues carry `sprite.webp#xywh=x,y,w,h` regions; `this.fetch()` (the
* plugin's auth-aware fetch) loads it, a small parser maps time to region,
* and `mousemove` over the slider positions the tooltip — with the covering
* chapter's title from `chapters()` between thumbnail and timestamp.
*/
import nmplayer, {
type NMVideoPlayer,
type VideoPlayerConfig,
type VideoPlaylistItem,
} from '@nomercy-entertainment/nomercy-video-player';
import { formatSeconds, Plugin } from '@nomercy-entertainment/nomercy-player-core';
import { VolumeState } from '@nomercy-entertainment/nomercy-video-player';
const bigBuckBunnyItem = {
id: 'big-buck-bunny',
title: 'Big Buck Bunny',
description: 'A giant rabbit with a heart bigger than himself.',
url: '/Big.Buck.Bunny.(2008)/Big.Buck.Bunny.(2008).NoMercy.m3u8',
image: '/w780/xtdybjRRZ15mCrPOvEld305myys.jpg',
duration: 596,
year: 2008,
// Real chapter marks from the fixture's own `chapters.vtt`, in seconds.
chapters: [
{ index: 0, start: 0, end: 65, title: 'Opening' },
{ index: 1, start: 65, end: 162, title: 'A Beautiful Day' },
{ index: 2, start: 162, end: 250, title: 'The Bullies' },
{ index: 3, start: 250, end: 350, title: 'Plotting Revenge' },
{ index: 4, start: 350, end: 495, title: 'The Traps' },
{ index: 5, start: 495, end: 596, title: 'End Credits' },
],
};
const FILMS_BASE =
'https://raw.githubusercontent.com/NoMercy-Entertainment/nomercy-media/master/Films';
interface TutorialIcon {
title: string;
normal: string;
hover: string;
}
const icons = {
play: {
title: 'Pause',
normal:
'M7.60846 4.61586C7.1087 4.34394 6.5 4.7057 6.5 5.27466V18.727C6.5 19.2959 7.1087 19.6577 7.60846 19.3858L19.97 12.6596C20.4921 12.3755 20.4921 11.6261 19.97 11.342L7.60846 4.61586ZM5 5.27466C5 3.5678 6.82609 2.48249 8.32538 3.29828L20.687 10.0244C22.2531 10.8766 22.2531 13.125 20.687 13.9772L8.32538 20.7033C6.82609 21.5191 5 20.4338 5 18.727V5.27466Z',
hover:
'M5 5.27466C5 3.5678 6.82609 2.48249 8.32538 3.29828L20.687 10.0244C22.2531 10.8766 22.2531 13.125 20.687 13.9772L8.32538 20.7033C6.82609 21.5191 5 20.4338 5 18.727V5.27466Z',
},
pause: {
title: 'Play',
normal:
'M6.25 3C5.00736 3 4 4.00736 4 5.25V18.75C4 19.9926 5.00736 21 6.25 21H8.75C9.99264 21 11 19.9926 11 18.75V5.25C11 4.00736 9.99264 3 8.75 3H6.25ZM5.5 5.25C5.5 4.83579 5.83579 4.5 6.25 4.5H8.75C9.16421 4.5 9.5 4.83579 9.5 5.25V18.75C9.5 19.1642 9.16421 19.5 8.75 19.5H6.25C5.83579 19.5 5.5 19.1642 5.5 18.75V5.25ZM15.25 3C14.0074 3 13 4.00736 13 5.25V18.75C13 19.9926 14.0074 21 15.25 21H17.75C18.9926 21 20 19.9926 20 18.75V5.25C20 4.00736 18.9926 3 17.75 3H15.25ZM14.5 5.25C14.5 4.83579 14.8358 4.5 15.25 4.5H17.75C18.1642 4.5 18.5 4.83579 18.5 5.25V18.75C18.5 19.1642 18.1642 19.5 17.75 19.5H15.25C14.8358 19.5 14.5 19.1642 14.5 18.75V5.25Z',
hover:
'M5.74609 3C4.7796 3 3.99609 3.7835 3.99609 4.75V19.25C3.99609 20.2165 4.7796 21 5.74609 21H9.24609C10.2126 21 10.9961 20.2165 10.9961 19.25V4.75C10.9961 3.7835 10.2126 3 9.24609 3H5.74609ZM14.7461 3C13.7796 3 12.9961 3.7835 12.9961 4.75V19.25C12.9961 20.2165 13.7796 21 14.7461 21H18.2461C19.2126 21 19.9961 20.2165 19.9961 19.25V4.75C19.9961 3.7835 19.2126 3 18.2461 3H14.7461Z',
},
seekBack: {
title: 'Seek back',
normal:
'M2.74999 2.5C2.33578 2.5 2 2.83579 2 3.25V8.75C2 9.16421 2.33578 9.5 2.74999 9.5H8.25011C8.66432 9.5 9.00011 9.16421 9.00011 8.75C9.00011 8.33579 8.66432 8 8.25011 8H4.34273C5.40077 6.60212 6.77033 5.4648 8.47169 4.93832C10.5381 4.29885 12.7232 4.35354 14.7384 5.10317C16.7673 5.85787 18.6479 7.38847 19.5922 9.11081C19.7914 9.47401 20.2473 9.607 20.6105 9.40785C20.9736 9.20871 21.1066 8.75284 20.9075 8.38964C19.7655 6.30687 17.5773 4.55877 15.2614 3.69728C12.9318 2.83072 10.4069 2.7693 8.02826 3.50536C6.14955 4.08673 4.65345 5.26153 3.49999 6.64949V3.25C3.49999 2.83579 3.1642 2.5 2.74999 2.5ZM8.95266 11.0278C9.27643 11.1186 9.50022 11.4138 9.50022 11.75V20.25C9.50022 20.6642 9.16443 21 8.75022 21C8.33601 21 8.00023 20.6642 8.00023 20.25V13.8328C7.61793 14.202 7.16004 14.5788 6.63611 14.8931C6.28093 15.1062 5.82024 14.9911 5.60713 14.6359C5.39402 14.2807 5.5092 13.82 5.86438 13.6069C6.53976 13.2017 7.10401 12.6421 7.50653 12.1678C7.70552 11.9334 7.85963 11.7261 7.96279 11.5793C8.01427 11.5061 8.05276 11.4483 8.07751 11.4103C8.08989 11.3913 8.0988 11.3772 8.10417 11.3687L8.10951 11.3602L8.11019 11.359C8.28503 11.072 8.629 10.9371 8.95266 11.0278ZM13.1988 12.629C13.7527 11.6377 14.6822 11 16.002 11C17.3218 11 18.2513 11.6377 18.8052 12.629C19.3266 13.5624 19.502 14.7762 19.502 16C19.502 17.2238 19.3266 18.4376 18.8052 19.371C18.2513 20.3623 17.3218 21 16.002 21C14.6822 21 13.7527 20.3623 13.1988 19.371C12.6774 18.4376 12.502 17.2238 12.502 16C12.502 14.7762 12.6774 13.5624 13.1988 12.629ZM14.5083 13.3606C14.1704 13.9654 14.002 14.8766 14.002 16C14.002 17.1234 14.1704 18.0346 14.5083 18.6394C14.8139 19.1863 15.2593 19.5 16.002 19.5C16.7447 19.5 17.1901 19.1863 17.4957 18.6394C17.8336 18.0346 18.002 17.1234 18.002 16C18.002 14.8766 17.8336 13.9654 17.4957 13.3606C17.1901 12.8137 16.7447 12.5 16.002 12.5C15.2593 12.5 14.8139 12.8137 14.5083 13.3606Z',
hover:
'M3 2.25C2.44772 2.25 2 2.69772 2 3.25V9C2 9.55228 2.44772 10 3 10H8.25C8.80228 10 9.25 9.55228 9.25 9C9.25 8.44772 8.80228 8 8.25 8H4.86322C5.84764 6.82148 7.07149 5.88667 8.54543 5.43056C10.5599 4.80719 12.6883 4.86076 14.6512 5.5909C16.6322 6.3278 18.4615 7.82215 19.373 9.48443C19.6385 9.96869 20.2463 10.146 20.7306 9.88048C21.2149 9.61495 21.3922 9.00712 21.1267 8.52286C19.9517 6.38003 17.7122 4.59567 15.3485 3.71639C12.9665 2.83033 10.3848 2.76779 7.9542 3.51995C6.37802 4.00769 5.0679 4.8994 4 5.97875V3.25C4 2.69772 3.55228 2.25 3 2.25ZM9.75015 12C9.75015 11.5806 9.48843 11.2057 9.0947 11.0612C8.70097 10.9167 8.2589 11.0333 7.98758 11.3531C7.91356 11.4403 7.84033 11.5288 7.76611 11.6185C7.25079 12.2412 6.68817 12.921 5.48566 13.6425C5.01208 13.9266 4.85851 14.5409 5.14266 15.0145C5.42681 15.4881 6.04107 15.6416 6.51465 15.3575C6.9978 15.0676 7.40387 14.7762 7.75015 14.4929V19.9998C7.75015 20.5521 8.19795 20.9998 8.75028 20.9998C9.30252 20.9997 9.75015 20.552 9.75015 19.9998V12ZM16.25 11C14.8639 11 13.8505 11.6354 13.2417 12.6611C12.678 13.6107 12.5 14.8223 12.5 16C12.5 17.1777 12.678 18.3893 13.2417 19.3389C13.8505 20.3646 14.8639 21 16.25 21C17.6361 21 18.6495 20.3646 19.2583 19.3389C19.822 18.3893 20 17.1777 20 16C20 14.8223 19.822 13.6107 19.2583 12.6611C18.6495 11.6354 17.6361 11 16.25 11ZM14.5 16C14.5 14.9686 14.6658 14.1802 14.9615 13.682C15.212 13.26 15.5736 13 16.25 13C16.9264 13 17.288 13.26 17.5385 13.682C17.8342 14.1802 18 14.9686 18 16C18 17.0314 17.8342 17.8198 17.5385 18.318C17.288 18.74 16.9264 19 16.25 19C15.5736 19 15.212 18.74 14.9615 18.318C14.6658 17.8198 14.5 17.0314 14.5 16Z',
},
seekForward: {
title: 'Seek forward',
normal:
'M21.25 2.5C21.6642 2.5 22 2.83579 22 3.25V8.75C22 9.16421 21.6642 9.5 21.25 9.5H15.7499C15.3357 9.5 14.9999 9.16421 14.9999 8.75C14.9999 8.33578 15.3357 8 15.7499 8H19.6573C18.5992 6.60212 17.2297 5.4648 15.5283 4.93832C13.4619 4.29885 11.2768 4.35354 9.26156 5.10317C7.23271 5.85787 5.35214 7.38846 4.40776 9.11081C4.20861 9.47401 3.75274 9.607 3.38955 9.40785C3.02635 9.2087 2.89336 8.75283 3.09251 8.38964C4.23451 6.30687 6.42268 4.55877 8.73861 3.69728C11.0682 2.83072 13.5931 2.7693 15.9717 3.50536C17.8504 4.08673 19.3465 5.26153 20.5 6.64949V3.25C20.5 2.83579 20.8358 2.5 21.25 2.5ZM16.0018 11C14.6821 11 13.7525 11.6377 13.1987 12.629C12.6772 13.5624 12.5019 14.7762 12.5019 16C12.5019 17.2238 12.6772 18.4376 13.1987 19.371C13.7525 20.3623 14.6821 21 16.0018 21C17.3216 21 18.2512 20.3623 18.805 19.371C19.3265 18.4376 19.5018 17.2238 19.5018 16C19.5018 14.7762 19.3265 13.5624 18.805 12.629C18.2512 11.6377 17.3216 11 16.0018 11ZM14.0019 16C14.0019 14.8766 14.1703 13.9654 14.5082 13.3606C14.8137 12.8137 15.2591 12.5 16.0018 12.5C16.7446 12.5 17.19 12.8137 17.4955 13.3606C17.8334 13.9654 18.0018 14.8766 18.0018 16C18.0018 17.1234 17.8334 18.0346 17.4955 18.6394C17.19 19.1863 16.7446 19.5 16.0018 19.5C15.2591 19.5 14.8137 19.1863 14.5082 18.6394C14.1703 18.0346 14.0019 17.1234 14.0019 16ZM9.50005 11.75C9.50005 11.4138 9.27626 11.1186 8.9525 11.0278C8.62884 10.9371 8.28486 11.072 8.11003 11.359L8.10935 11.3602L8.10401 11.3687C8.09864 11.3772 8.08972 11.3913 8.07735 11.4103C8.05259 11.4483 8.0141 11.5061 7.96263 11.5793C7.85946 11.7261 7.70536 11.9334 7.50637 12.1678C7.10384 12.6421 6.5396 13.2016 5.86422 13.6069C5.50903 13.82 5.39386 14.2807 5.60697 14.6359C5.82008 14.9911 6.28077 15.1062 6.63595 14.8931C7.15988 14.5788 7.61776 14.202 8.00007 13.8328V20.25C8.00007 20.6642 8.33585 21 8.75006 21C9.16427 21 9.50005 20.6642 9.50005 20.25V11.75Z',
hover:
'M21 2.25C21.5523 2.25 22 2.69772 22 3.25001V9.00005C22 9.55234 21.5523 10.0001 21 10.0001H15.75C15.1977 10.0001 14.75 9.55234 14.75 9.00005C14.75 8.44777 15.1977 8.00005 15.75 8.00005H19.1369C18.1525 6.82145 16.9286 5.88657 15.4546 5.43043C13.4401 4.80706 11.3117 4.86063 9.34883 5.59077C7.3678 6.32768 5.53848 7.82204 4.62703 9.48433C4.3615 9.9686 3.75367 10.1459 3.2694 9.88039C2.78514 9.61486 2.60782 9.00703 2.87335 8.52276C4.04829 6.37991 6.28776 4.59554 8.65155 3.71625C11.0335 2.83019 13.6152 2.76764 16.0458 3.51981C17.622 4.00755 18.9321 4.89926 20 5.97863V3.25001C20 2.69772 20.4477 2.25 21 2.25ZM9.0947 11.0611C9.48843 11.2056 9.75015 11.5805 9.75015 11.9999V19.9998C9.75015 20.552 9.30252 20.9997 8.75028 20.9998C8.19795 20.9998 7.75015 20.5521 7.75015 19.9998V14.4929C7.40387 14.7762 6.9978 15.0675 6.51465 15.3574C6.04107 15.6416 5.42681 15.488 5.14266 15.0144C4.85851 14.5409 5.01208 13.9266 5.48566 13.6424C6.68817 12.9209 7.25079 12.2411 7.76611 11.6184C7.84033 11.5288 7.91356 11.4403 7.98758 11.353C8.2589 11.0332 8.70097 10.9166 9.0947 11.0611ZM13.2417 12.6611C13.8505 11.6354 14.8639 10.9999 16.25 10.9999C17.6361 10.9999 18.6495 11.6354 19.2583 12.6611C19.822 13.6106 20 14.8222 20 16C20 17.1777 19.822 18.3893 19.2583 19.3389C18.6495 20.3645 17.6361 21 16.25 21C14.8639 21 13.8505 20.3645 13.2417 19.3389C12.678 18.3893 12.5 17.1777 12.5 16C12.5 14.8222 12.678 13.6106 13.2417 12.6611ZM14.9615 13.682C14.6658 14.1801 14.5 14.9686 14.5 16C14.5 17.0314 14.6658 17.8198 14.9615 18.318C15.212 18.74 15.5736 19 16.25 19C16.9264 19 17.288 18.74 17.5385 18.318C17.8342 17.8198 18 17.0314 18 16C18 14.9686 17.8342 14.1801 17.5385 13.682C17.288 13.2599 16.9264 12.9999 16.25 12.9999C15.5736 12.9999 15.212 13.2599 14.9615 13.682Z',
},
volumeHigh: {
title: 'Mute',
normal:
'M15 4.25049C15 3.17187 13.7255 2.59964 12.9195 3.31631L8.42794 7.30958C8.29065 7.43165 8.11333 7.49907 7.92961 7.49907H4.25C3.00736 7.49907 2 8.50643 2 9.74907V14.247C2 15.4896 3.00736 16.497 4.25 16.497H7.92956C8.11329 16.497 8.29063 16.5644 8.42793 16.6865L12.9194 20.6802C13.7255 21.397 15 20.8247 15 19.7461V4.25049ZM9.4246 8.43059L13.5 4.80728V19.1893L9.42465 15.5655C9.01275 15.1993 8.48074 14.997 7.92956 14.997H4.25C3.83579 14.997 3.5 14.6612 3.5 14.247V9.74907C3.5 9.33486 3.83579 8.99907 4.25 8.99907H7.92961C8.48075 8.99907 9.01272 8.79679 9.4246 8.43059ZM18.9916 5.89782C19.3244 5.65128 19.7941 5.72126 20.0407 6.05411C21.2717 7.71619 22 9.77439 22 12.0005C22 14.2266 21.2717 16.2848 20.0407 17.9469C19.7941 18.2798 19.3244 18.3497 18.9916 18.1032C18.6587 17.8567 18.5888 17.387 18.8353 17.0541C19.8815 15.6416 20.5 13.8943 20.5 12.0005C20.5 10.1067 19.8815 8.35945 18.8353 6.9469C18.5888 6.61404 18.6587 6.14435 18.9916 5.89782ZM17.143 8.36982C17.5072 8.17262 17.9624 8.30806 18.1596 8.67233C18.6958 9.66294 19 10.7973 19 12.0005C19 13.2037 18.6958 14.338 18.1596 15.3287C17.9624 15.6929 17.5072 15.8284 17.143 15.6312C16.7787 15.434 16.6432 14.9788 16.8404 14.6146C17.2609 13.8378 17.5 12.9482 17.5 12.0005C17.5 11.0528 17.2609 10.1632 16.8404 9.38642C16.6432 9.02216 16.7787 8.56701 17.143 8.36982Z',
hover:
'M15 4.25049V19.7461C15 20.8247 13.7255 21.397 12.9194 20.6802L8.42793 16.6865C8.29063 16.5644 8.11329 16.497 7.92956 16.497H4.25C3.00736 16.497 2 15.4896 2 14.247V9.74907C2 8.50643 3.00736 7.49907 4.25 7.49907H7.92961C8.11333 7.49907 8.29065 7.43165 8.42794 7.30958L12.9195 3.31631C13.7255 2.59964 15 3.17187 15 4.25049ZM18.9916 5.89782C19.3244 5.65128 19.7941 5.72126 20.0407 6.05411C21.2717 7.71619 22 9.77439 22 12.0005C22 14.2266 21.2717 16.2848 20.0407 17.9469C19.7941 18.2798 19.3244 18.3497 18.9916 18.1032C18.6587 17.8567 18.5888 17.387 18.8353 17.0541C19.8815 15.6416 20.5 13.8943 20.5 12.0005C20.5 10.1067 19.8815 8.35945 18.8353 6.9469C18.5888 6.61404 18.6587 6.14435 18.9916 5.89782ZM17.143 8.36982C17.5072 8.17262 17.9624 8.30806 18.1596 8.67233C18.6958 9.66294 19 10.7973 19 12.0005C19 13.2037 18.6958 14.338 18.1596 15.3287C17.9624 15.6929 17.5072 15.8284 17.143 15.6312C16.7787 15.434 16.6432 14.9788 16.8404 14.6146C17.2609 13.8378 17.5 12.9482 17.5 12.0005C17.5 11.0528 17.2609 10.1632 16.8404 9.38642C16.6432 9.02216 16.7787 8.56701 17.143 8.36982Z',
},
volumeLow: {
title: 'Mute',
normal:
'M14.7041 3.44054C14.8952 3.66625 15 3.95238 15 4.24807V19.7497C15 20.4401 14.4404 20.9997 13.75 20.9997C13.4542 20.9997 13.168 20.8948 12.9423 20.7037L7.97513 16.4979H4.25C3.00736 16.4979 2 15.4905 2 14.2479V9.7479C2 8.50526 3.00736 7.4979 4.25 7.4979H7.97522L12.9425 3.29393C13.4694 2.84794 14.2582 2.91358 14.7041 3.44054ZM13.5 4.78718L8.52478 8.9979H4.25C3.83579 8.9979 3.5 9.33369 3.5 9.7479V14.2479C3.5 14.6621 3.83579 14.9979 4.25 14.9979H8.52487L13.5 19.2104V4.78718Z',
hover:
'M14.7041 3.44054C14.8952 3.66625 15 3.95238 15 4.24807V19.7497C15 20.4401 14.4404 20.9997 13.75 20.9997C13.4542 20.9997 13.168 20.8948 12.9423 20.7037L7.97513 16.4979H4.25C3.00736 16.4979 2 15.4905 2 14.2479V9.7479C2 8.50526 3.00736 7.4979 4.25 7.4979H7.97522L12.9425 3.29393C13.4694 2.84794 14.2582 2.91358 14.7041 3.44054Z',
},
volumeMuted: {
title: 'Unmute',
normal:
'M12.9195 3.31631C13.7255 2.59964 15 3.17187 15 4.25049V19.7461C15 20.8247 13.7255 21.397 12.9194 20.6802L8.42793 16.6865C8.29063 16.5644 8.11329 16.497 7.92956 16.497H4.25C3.00736 16.497 2 15.4896 2 14.247V9.74907C2 8.50643 3.00736 7.49907 4.25 7.49907H7.92961C8.11333 7.49907 8.29065 7.43165 8.42794 7.30958L12.9195 3.31631ZM13.5 4.80728L9.4246 8.43059C9.01272 8.79679 8.48075 8.99907 7.92961 8.99907H4.25C3.83579 8.99907 3.5 9.33486 3.5 9.74907V14.247C3.5 14.6612 3.83579 14.997 4.25 14.997H7.92956C8.48074 14.997 9.01275 15.1993 9.42465 15.5655L13.5 19.1893V4.80728ZM16.2197 9.22017C16.5126 8.92728 16.9874 8.92728 17.2803 9.22017L19 10.9398L20.7197 9.22017C21.0126 8.92728 21.4874 8.92728 21.7803 9.22017C22.0732 9.51307 22.0732 9.98794 21.7803 10.2808L20.0607 12.0005L21.7803 13.7202C22.0732 14.0131 22.0732 14.4879 21.7803 14.7808C21.4874 15.0737 21.0126 15.0737 20.7197 14.7808L19 13.0612L17.2803 14.7808C16.9874 15.0737 16.5126 15.0737 16.2197 14.7808C15.9268 14.4879 15.9268 14.0131 16.2197 13.7202L17.9393 12.0005L16.2197 10.2808C15.9268 9.98794 15.9268 9.51307 16.2197 9.22017Z',
hover:
'M15 4.25049C15 3.17187 13.7255 2.59964 12.9195 3.31631L8.42794 7.30958C8.29065 7.43165 8.11333 7.49907 7.92961 7.49907H4.25C3.00736 7.49907 2 8.50643 2 9.74907V14.247C2 15.4896 3.00736 16.497 4.25 16.497H7.92956C8.11329 16.497 8.29063 16.5644 8.42793 16.6865L12.9194 20.6802C13.7255 21.397 15 20.8247 15 19.7461V4.25049ZM16.2197 9.22016C16.5126 8.92727 16.9874 8.92727 17.2803 9.22016L19 10.9398L20.7197 9.22016C21.0126 8.92727 21.4874 8.92727 21.7803 9.22016C22.0732 9.51305 22.0732 9.98793 21.7803 10.2808L20.0607 12.0005L21.7803 13.7202C22.0732 14.0131 22.0732 14.4879 21.7803 14.7808C21.4874 15.0737 21.0126 15.0737 20.7197 14.7808L19 13.0611L17.2803 14.7808C16.9874 15.0737 16.5126 15.0737 16.2197 14.7808C15.9268 14.4879 15.9268 14.0131 16.2197 13.7202L17.9393 12.0005L16.2197 10.2808C15.9268 9.98793 15.9268 9.51305 16.2197 9.22016Z',
},
speed: {
title: 'Speed',
normal:
'M7.93413 16.0659C8.22703 16.3588 8.22703 16.8336 7.93413 17.1265C7.64124 17.4194 7.16637 17.4194 6.87347 17.1265C4.04217 14.2952 4.04217 9.70478 6.87347 6.87348C8.71833 5.02862 11.3099 4.38674 13.6723 4.94459C14.0755 5.03978 14.3251 5.44375 14.2299 5.84687C14.1347 6.25 13.7308 6.49963 13.3276 6.40444C11.45 5.96106 9.39622 6.47205 7.93413 7.93414C5.68862 10.1797 5.68862 13.8203 7.93413 16.0659ZM17.8879 9.1415C18.2789 9.00477 18.7067 9.21089 18.8435 9.60189C19.7333 12.1463 19.1624 15.0907 17.1265 17.1265C16.8336 17.4194 16.3588 17.4194 16.0659 17.1265C15.773 16.8336 15.773 16.3588 16.0659 16.0659C17.6791 14.4526 18.1344 12.1183 17.4276 10.097C17.2908 9.70604 17.4969 9.27824 17.8879 9.1415ZM15.8791 6.66732C16.1062 6.47297 16.439 6.46653 16.6734 6.65195C16.9078 6.83738 16.9781 7.16278 16.8412 7.42842L16.7119 7.67862C16.6295 7.83801 16.5113 8.06624 16.3681 8.34179C16.0818 8.89278 15.6954 9.63339 15.2955 10.3912C14.8959 11.1485 14.4815 11.9253 14.1395 12.5479C13.9686 12.8589 13.8142 13.1344 13.6879 13.3509C13.5703 13.5524 13.4548 13.7421 13.3688 13.8508C12.7263 14.6629 11.5471 14.8004 10.735 14.1579C9.92288 13.5154 9.78538 12.3362 10.4279 11.5241C10.5139 11.4154 10.672 11.2593 10.8409 11.0986C11.0226 10.9258 11.2552 10.7121 11.5185 10.4744C12.0457 9.9983 12.7063 9.41631 13.3514 8.85315C13.9969 8.28961 14.6288 7.74321 15.0991 7.33783C15.3343 7.1351 15.5292 6.96755 15.6654 6.85065L15.8791 6.66732ZM22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12ZM3.5 12C3.5 16.6944 7.30558 20.5 12 20.5C16.6944 20.5 20.5 16.6944 20.5 12C20.5 7.30558 16.6944 3.5 12 3.5C7.30558 3.5 3.5 7.30558 3.5 12Z',
hover:
'M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22ZM15.8791 6.66732C16.1062 6.47297 16.439 6.46653 16.6734 6.65195C16.9078 6.83738 16.9781 7.16278 16.8412 7.42842L16.7119 7.67862C16.6295 7.83801 16.5113 8.06624 16.3681 8.34179C16.0818 8.89278 15.6954 9.63339 15.2955 10.3912C14.8959 11.1485 14.4815 11.9253 14.1395 12.5479C13.9686 12.8589 13.8142 13.1344 13.6879 13.3509C13.5703 13.5524 13.4548 13.7421 13.3688 13.8508C12.7263 14.6629 11.5471 14.8004 10.735 14.1579C9.92288 13.5154 9.78538 12.3362 10.4279 11.5241C10.5139 11.4154 10.672 11.2593 10.8409 11.0986C11.0226 10.9258 11.2552 10.7121 11.5185 10.4744C12.0457 9.9983 12.7063 9.41631 13.3514 8.85315C13.9969 8.28961 14.6288 7.74321 15.0991 7.33783C15.3343 7.1351 15.5292 6.96755 15.6654 6.85065L15.8791 6.66732ZM7.93413 17.1265C7.64124 17.4194 7.16637 17.4194 6.87347 17.1265C4.04217 14.2952 4.04217 9.70478 6.87347 6.87348C8.71833 5.02862 11.3099 4.38674 13.6723 4.94459C14.0755 5.03978 14.3251 5.44375 14.2299 5.84687C14.1347 6.25 13.7308 6.49963 13.3276 6.40444C11.45 5.96106 9.39622 6.47205 7.93413 7.93414C5.68862 10.1797 5.68862 13.8203 7.93413 16.0659C8.22703 16.3588 8.22703 16.8336 7.93413 17.1265ZM17.8879 9.1415C18.2789 9.00477 18.7067 9.21089 18.8435 9.60189C19.7333 12.1463 19.1624 15.0907 17.1265 17.1265C16.8336 17.4194 16.3588 17.4194 16.0659 17.1265C15.773 16.8336 15.773 16.3588 16.0659 16.0659C17.6791 14.4526 18.1344 12.1183 17.4276 10.097C17.2908 9.70604 17.4969 9.27824 17.8879 9.1415Z',
},
fullscreen: {
title: 'Fullscreen',
normal:
'M12.748 3.00122L20.3018 3.00173L20.402 3.01565L20.5009 3.04325L20.562 3.06918C20.641 3.10407 20.7149 3.1546 20.7798 3.21953L20.8206 3.26357L20.8811 3.34505L20.9183 3.41008L20.957 3.50039L20.9761 3.56452L20.9897 3.62846L20.999 3.72165L20.9996 11.2551C20.9996 11.6693 20.6638 12.0051 20.2496 12.0051C19.8699 12.0051 19.5561 11.723 19.5064 11.3569L19.4996 11.2551L19.499 5.55922L5.55905 19.5042L11.2496 19.5051C11.6293 19.5051 11.9431 19.7873 11.9927 20.1533L11.9996 20.2551C11.9996 20.6348 11.7174 20.9486 11.3513 20.9983L11.2496 21.0051L3.71372 21.0043L3.68473 21.0015C3.61867 20.9969 3.55596 20.983 3.49668 20.9619L3.40655 20.923L3.38936 20.9125C3.18516 20.8021 3.03871 20.5991 3.00529 20.3597L2.99805 20.2551V12.7512C2.99805 12.337 3.33383 12.0012 3.74805 12.0012C4.12774 12.0012 4.44154 12.2834 4.4912 12.6495L4.49805 12.7512V18.4432L18.438 4.50022L12.748 4.50122C12.3684 4.50122 12.0546 4.21907 12.0049 3.85299L11.998 3.75122C11.998 3.37152 12.2802 3.05773 12.6463 3.00807L12.748 3.00122Z',
hover:
'M12.4958 3.00195L20.0515 3.00341L20.1724 3.01731L20.2601 3.03685L20.364 3.07133L20.4532 3.11166L20.5169 3.14728L20.5795 3.1888L20.6435 3.2387L20.7066 3.29703L20.801 3.40667L20.8726 3.51791L20.9261 3.63064L20.9615 3.73598L20.9771 3.80116L20.9863 3.85351L20.9973 4.00195V11.5058C20.9973 12.0581 20.5496 12.5058 19.9973 12.5058C19.4845 12.5058 19.0618 12.1198 19.004 11.6225L18.9973 11.5058L18.997 6.41595L6.41205 19L11.4996 19.0005C12.0124 19.0005 12.4351 19.3865 12.4928 19.8839L12.4996 20.0005C12.4996 20.5133 12.1135 20.936 11.6162 20.9938L11.4996 21.0005L3.93864 20.9987L3.84306 20.9885L3.76641 20.9735L3.68892 20.9517L3.61989 20.9265L3.5298 20.8843L3.44025 20.8306L3.34879 20.7611L3.3813 20.7877C3.31948 20.7392 3.26352 20.6836 3.21466 20.6221L3.16353 20.5517L3.12477 20.4882L3.09178 20.4237L3.05798 20.3423L3.03287 20.2627L3.00936 20.1509L3.00201 20.0897L2.99805 20.0005V12.4966C2.99805 11.9443 3.44576 11.4966 3.99805 11.4966C4.51088 11.4966 4.93355 11.8826 4.99132 12.38L4.99805 12.4966V17.585L17.582 5.00195H12.4958C11.9829 5.00195 11.5603 4.61591 11.5025 4.11857L11.4958 4.00195C11.4958 3.44967 11.9435 3.00195 12.4958 3.00195Z',
},
exitFullscreen: {
title: 'Exit fullscreen',
normal:
'M21.7776 2.22205C22.0437 2.48828 22.0679 2.90495 21.8503 3.19858L21.7778 3.28271L15.555 9.50644L21.2476 9.50739C21.6273 9.50739 21.9411 9.78954 21.9908 10.1556L21.9976 10.2574C21.9976 10.6371 21.7155 10.9509 21.3494 11.0005L21.2476 11.0074L13.6973 11.005L13.6824 11.0038C13.6141 10.9986 13.5486 10.984 13.487 10.9614L13.3892 10.9159C13.1842 10.8058 13.037 10.6023 13.0034 10.3623L12.9961 10.2574V2.7535C12.9961 2.33929 13.3319 2.0035 13.7461 2.0035C14.1258 2.0035 14.4396 2.28565 14.4893 2.65173L14.4961 2.7535L14.496 8.44444L20.7178 2.22217C21.0104 1.92925 21.4849 1.92919 21.7776 2.22205ZM11.0025 13.7547V21.2586C11.0025 21.6728 10.6667 22.0086 10.2525 22.0086C9.8728 22.0086 9.55901 21.7265 9.50935 21.3604L9.5025 21.2586L9.502 15.5634L3.28039 21.7794C2.98753 22.0723 2.51266 22.0724 2.21973 21.7795C1.95343 21.5133 1.92918 21.0966 2.147 20.803L2.21961 20.7189L8.44 14.5044L2.75097 14.5047C2.37128 14.5047 2.05748 14.2226 2.00782 13.8565L2.00097 13.7547C2.00097 13.3405 2.33676 13.0047 2.75097 13.0047L10.3053 13.0066L10.3788 13.0153L10.4763 13.0387L10.5291 13.0574L10.6154 13.0982L10.7039 13.1557C10.7598 13.1979 10.8095 13.2477 10.8517 13.3035L10.9185 13.4095L10.9592 13.503L10.9806 13.5739L10.9919 13.6286L10.998 13.6864L10.9986 13.678L11.0025 13.7547Z',
hover:
'M21.7776 2.22205C22.0437 2.48828 22.0679 2.90495 21.8503 3.19858L21.7778 3.28271L15.555 9.50644L21.2476 9.50739C21.6273 9.50739 21.9411 9.78954 21.9908 10.1556L21.9976 10.2574C21.9976 10.6371 21.7155 10.9509 21.3494 11.0005L21.2476 11.0074L13.6973 11.005L13.6824 11.0038C13.6141 10.9986 13.5486 10.984 13.487 10.9614L13.3892 10.9159C13.1842 10.8058 13.037 10.6023 13.0034 10.3623L12.9961 10.2574V2.7535C12.9961 2.33929 13.3319 2.0035 13.7461 2.0035C14.1258 2.0035 14.4396 2.28565 14.4893 2.65173L14.4961 2.7535L14.496 8.44444L20.7178 2.22217C21.0104 1.92925 21.4849 1.92919 21.7776 2.22205ZM11.0025 13.7547V21.2586C11.0025 21.6728 10.6667 22.0086 10.2525 22.0086C9.8728 22.0086 9.55901 21.7265 9.50935 21.3604L9.5025 21.2586L9.502 15.5634L3.28039 21.7794C2.98753 22.0723 2.51266 22.0724 2.21973 21.7795C1.95343 21.5133 1.92918 21.0966 2.147 20.803L2.21961 20.7189L8.44 14.5044L2.75097 14.5047C2.37128 14.5047 2.05748 14.2226 2.00782 13.8565L2.00097 13.7547C2.00097 13.3405 2.33676 13.0047 2.75097 13.0047L10.3053 13.0066L10.3788 13.0153L10.4763 13.0387L10.5291 13.0574L10.6154 13.0982L10.7039 13.1557C10.7598 13.1979 10.8095 13.2477 10.8517 13.3035L10.9185 13.4095L10.9592 13.503L10.9806 13.5739L10.9919 13.6286L10.998 13.6864L10.9986 13.678L11.0025 13.7547Z',
},
subtitles: {
title: 'Subtitles',
normal:
'M18.75 4C20.5449 4 22 5.45507 22 7.25V16.7546C22 18.5495 20.5449 20.0046 18.75 20.0046H5.25C3.45507 20.0046 2 18.5495 2 16.7546V7.25C2 5.51697 3.35645 4.10075 5.06558 4.00514L5.25 4H18.75ZM10.6216 8.59854C8.21322 7.22469 5.5 8.85442 5.5 12C5.5 15.1433 8.21539 16.7747 10.6208 15.4066C10.9809 15.2018 11.1067 14.7439 10.9019 14.3838C10.6971 14.0238 10.2392 13.8979 9.8792 14.1027C8.48411 14.8962 7 14.0046 7 12C7 9.99357 8.48071 9.10417 9.87838 9.90146C10.2382 10.1067 10.6962 9.98141 10.9015 9.62162C11.1067 9.26183 10.9814 8.80378 10.6216 8.59854ZM18.1216 8.59854C15.7132 7.22469 13 8.85442 13 12C13 15.1433 15.7154 16.7747 18.1208 15.4066C18.4809 15.2018 18.6067 14.7439 18.4019 14.3838C18.1971 14.0238 17.7392 13.8979 17.3792 14.1027C15.9841 14.8962 14.5 14.0046 14.5 12C14.5 9.99357 15.9807 9.10417 17.3784 9.90146C17.7382 10.1067 18.1962 9.98141 18.4015 9.62162C18.6067 9.26183 18.4814 8.80378 18.1216 8.59854Z',
hover:
'M18.75 4C20.5449 4 22 5.45507 22 7.25V16.7546C22 18.5495 20.5449 20.0046 18.75 20.0046H5.25C3.45507 20.0046 2 18.5495 2 16.7546V7.25C2 5.51697 3.35645 4.10075 5.06558 4.00514L5.25 4H18.75ZM18.75 5.5H5.25L5.10647 5.5058C4.20711 5.57881 3.5 6.33183 3.5 7.25V16.7546C3.5 17.7211 4.2835 18.5046 5.25 18.5046H18.75C19.7165 18.5046 20.5 17.7211 20.5 16.7546V7.25C20.5 6.2835 19.7165 5.5 18.75 5.5ZM5.5 12C5.5 8.85442 8.21322 7.22469 10.6216 8.59854C10.9814 8.80378 11.1067 9.26183 10.9015 9.62162C10.6962 9.98141 10.2382 10.1067 9.87838 9.90146C8.48071 9.10417 7 9.99357 7 12C7 14.0046 8.48411 14.8962 9.8792 14.1027C10.2392 13.8979 10.6971 14.0238 10.9019 14.3838C11.1067 14.7439 10.9809 15.2018 10.6208 15.4066C8.21539 16.7747 5.5 15.1433 5.5 12ZM13 12C13 8.85442 15.7132 7.22469 18.1216 8.59854C18.4814 8.80378 18.6067 9.26183 18.4015 9.62162C18.1962 9.98141 17.7382 10.1067 17.3784 9.90146C15.9807 9.10417 14.5 9.99357 14.5 12C14.5 14.0046 15.9841 14.8962 17.3792 14.1027C17.7392 13.8979 18.1971 14.0238 18.4019 14.3838C18.6067 14.7439 18.4809 15.2018 18.1208 15.4066C15.7154 16.7747 13 15.1433 13 12Z',
},
language: {
title: 'Language',
normal:
'M11.9996 1.99805C17.5233 1.99805 22.0011 6.47589 22.0011 11.9996C22.0011 17.5233 17.5233 22.0011 11.9996 22.0011C6.47589 22.0011 1.99805 17.5233 1.99805 11.9996C1.99805 6.47589 6.47589 1.99805 11.9996 1.99805ZM14.9385 16.4993H9.06069C9.71273 18.9135 10.8461 20.5011 11.9996 20.5011C13.1531 20.5011 14.2865 18.9135 14.9385 16.4993ZM7.50791 16.4999L4.78542 16.4998C5.74376 18.0328 7.17721 19.2384 8.87959 19.9104C8.35731 19.0906 7.92632 18.0643 7.60932 16.8949L7.50791 16.4999ZM19.2138 16.4998L16.4913 16.4999C16.1675 17.8337 15.6999 18.9995 15.1185 19.9104C16.7155 19.2804 18.0752 18.1814 19.0286 16.7833L19.2138 16.4998ZM7.09302 9.99895H3.73542L3.73066 10.0162C3.57858 10.6525 3.49805 11.3166 3.49805 11.9996C3.49805 13.0558 3.69064 14.0669 4.04261 14.9999L7.21577 14.9995C7.07347 14.0504 6.99805 13.0422 6.99805 11.9996C6.99805 11.3156 7.03051 10.6464 7.09302 9.99895ZM15.3965 9.99901H8.60267C8.53465 10.6393 8.49805 11.309 8.49805 11.9996C8.49805 13.0591 8.58419 14.0694 8.73778 14.9997H15.2614C15.415 14.0694 15.5011 13.0591 15.5011 11.9996C15.5011 11.309 15.4645 10.6393 15.3965 9.99901ZM20.2642 9.99811L16.9062 9.99897C16.9687 10.6464 17.0011 11.3156 17.0011 11.9996C17.0011 13.0422 16.9257 14.0504 16.7834 14.9995L19.9566 14.9999C20.3086 14.0669 20.5011 13.0558 20.5011 11.9996C20.5011 11.3102 20.4191 10.64 20.2642 9.99811ZM8.88065 4.08875L8.85774 4.09747C6.81043 4.91218 5.15441 6.49949 4.24975 8.49935L7.29787 8.49972C7.61122 6.74693 8.15807 5.221 8.88065 4.08875ZM11.9996 3.49805L11.8839 3.50335C10.6185 3.6191 9.39603 5.62107 8.82831 8.4993H15.1709C14.6048 5.62914 13.3875 3.63033 12.1259 3.50436L11.9996 3.49805ZM15.1196 4.08881L15.2264 4.2629C15.8957 5.37537 16.4038 6.83525 16.7013 8.49972L19.7494 8.49935C18.8848 6.58795 17.3338 5.05341 15.4108 4.21008L15.1196 4.08881Z',
hover:
'M8.90386 16.5008H15.0953C14.4754 19.7722 13.234 21.999 11.9996 21.999C10.8026 21.999 9.59902 19.9051 8.96191 16.7953L8.90386 16.5008H15.0953H8.90386ZM3.0654 16.501L7.37104 16.5008C7.73581 18.583 8.35409 20.3545 9.16323 21.5942C6.60039 20.8373 4.46673 19.0825 3.21175 16.7799L3.0654 16.501ZM16.6282 16.5008L20.9338 16.501C19.7025 18.9406 17.5013 20.8071 14.837 21.5939C15.5915 20.4362 16.1802 18.8162 16.5519 16.9129L16.6282 16.5008L20.9338 16.501L16.6282 16.5008ZM16.9311 10.0008L21.8011 10.0002C21.9323 10.6465 22.0011 11.3155 22.0011 12.0005C22.0011 13.0458 21.8408 14.0537 21.5433 15.0009H16.8407C16.946 14.0433 17.0011 13.0372 17.0011 12.0005C17.0011 11.5462 16.9906 11.0977 16.9698 10.6567L16.9311 10.0008L21.8011 10.0002L16.9311 10.0008ZM2.19811 10.0002L7.06814 10.0008C7.02189 10.6508 6.99805 11.319 6.99805 12.0005C6.99805 12.8299 7.03336 13.6396 7.10139 14.4207L7.15851 15.0009H2.45588C2.15841 14.0537 1.99805 13.0458 1.99805 12.0005C1.99805 11.3155 2.06692 10.6465 2.19811 10.0002ZM8.57509 10.0002H15.4241C15.4744 10.6459 15.5011 11.3147 15.5011 12.0005C15.5011 12.8381 15.4612 13.6505 15.3873 14.4262L15.3256 15.0009H8.67355C8.5605 14.0551 8.49805 13.0476 8.49805 12.0005C8.49805 11.4862 8.51312 10.9814 8.54185 10.4887L8.57509 10.0002H15.4241H8.57509ZM14.9439 2.57707L14.836 2.40684C17.8543 3.29781 20.2783 5.57442 21.3715 8.50016L16.7806 8.50045C16.4651 6.08353 15.8242 4.00785 14.9439 2.57707L14.836 2.40684L14.9439 2.57707ZM9.04137 2.44365L9.16315 2.40688C8.28239 3.75639 7.62778 5.736 7.28013 8.06062L7.21856 8.50045L2.62767 8.50016C3.70614 5.6139 6.07973 3.35936 9.04137 2.44365L9.16315 2.40688L9.04137 2.44365ZM11.9996 2.00195C13.3184 2.00195 14.6452 4.5437 15.2136 8.1854L15.2604 8.5002H8.73878C9.27819 4.69102 10.6431 2.00195 11.9996 2.00195Z',
},
quality: {
title: 'Quality',
normal:
'M7.68182 8C8.05838 8 8.36364 8.33579 8.36364 8.75V11.5H10.1818V8.75C10.1818 8.33579 10.4871 8 10.8636 8C11.2402 8 11.5455 8.33579 11.5455 8.75V15.25C11.5455 15.6642 11.2402 16 10.8636 16C10.4871 16 10.1818 15.6642 10.1818 15.25V13H8.36364V15.25C8.36364 15.6642 8.05838 16 7.68182 16C7.30526 16 7 15.6642 7 15.25V8.75C7 8.33579 7.30526 8 7.68182 8ZM13.5909 8C13.2144 8 12.9091 8.33579 12.9091 8.75V15.25C12.9091 15.6642 13.2144 16 13.5909 16H14.5C16.1318 16 17.4545 14.5449 17.4545 12.75V11.25C17.4545 9.45507 16.1318 8 14.5 8H13.5909ZM14.2727 14.5V9.5H14.5C15.3786 9.5 16.0909 10.2835 16.0909 11.25V12.75C16.0909 13.7165 15.3786 14.5 14.5 14.5H14.2727ZM2 6.25C2 4.45507 3.3228 3 4.95455 3H19.0455C20.6772 3 22 4.45507 22 6.25V17.75C22 19.5449 20.6772 21 19.0455 21H4.95455C3.32279 21 2 19.5449 2 17.75V6.25ZM4.95455 4.5C4.07591 4.5 3.36364 5.2835 3.36364 6.25V17.75C3.36364 18.7165 4.07591 19.5 4.95455 19.5H19.0455C19.9241 19.5 20.6364 18.7165 20.6364 17.75V6.25C20.6364 5.2835 19.9241 4.5 19.0455 4.5H12H4.95455Z',
hover:
'M14.5 14.5V9.5H14.75C15.7165 9.5 16.5 10.2835 16.5 11.25V12.75C16.5 13.7165 15.7165 14.5 14.75 14.5H14.5ZM5.25 3C3.45507 3 2 4.45507 2 6.25V17.75C2 19.5449 3.45507 21 5.25 21H18.75C20.5449 21 22 19.5449 22 17.75V6.25C22 4.45507 20.5449 3 18.75 3H5.25ZM7.25 8C7.66421 8 8 8.33579 8 8.75V11.5H10V8.75C10 8.33579 10.3358 8 10.75 8C11.1642 8 11.5 8.33579 11.5 8.75V15.25C11.5 15.6642 11.1642 16 10.75 16C10.3358 16 10 15.6642 10 15.25V13H8V15.25C8 15.6642 7.66421 16 7.25 16C6.83579 16 6.5 15.6642 6.5 15.25V8.75C6.5 8.33579 6.83579 8 7.25 8ZM13.75 8H14.75C16.5449 8 18 9.45507 18 11.25V12.75C18 14.5449 16.5449 16 14.75 16H13.75C13.3358 16 13 15.6642 13 15.25V8.75C13 8.33579 13.3358 8 13.75 8Z',
},
} satisfies Record<string, TutorialIcon>;
/**
* Inline-SVG renderer for the icon table: both variants render stacked as
* `icon-normal` + `icon-hover` paths, and the button's hover state swaps
* which one is visible — the same mechanism the shipped plugin uses.
*/
function svgFromIcon(icon: TutorialIcon, size = 24): string {
return (
`<svg viewBox="0 0 24 24" fill="currentColor" width="${size}" height="${size}" aria-hidden="true">` +
`<path class="icon-normal" d="${icon.normal}"/>` +
`<path class="icon-hover" d="${icon.hover}"/>` +
`</svg>`
);
}
const SPINNER_SVG = `
<svg class="animate-spin text-white" viewBox="0 0 100 101" fill="none">
<path d="M100 50.59C100 78.2 77.6 100.59 50 100.59S0 78.2 0 50.59 22.39.59 50 .59s50 22.39 50 50z" fill="currentColor" opacity="0.25"/>
<path d="M93.97 39.04c2.42-.64 3.89-3.13 3.04-5.49A50 50 0 0041.73 1.28c-2.47.41-3.92 2.92-3.28 5.34.66 2.43 3.14 3.85 5.62 3.48a40 40 0 0146.62 22.32c.9 2.24 3.36 3.7 5.79 3.06z" fill="currentColor"/>
</svg>
`;
interface PreviewCue {
start: number;
end: number;
imageUrl: string;
x: number;
y: number;
w: number;
h: number;
}
/**
* Parse a thumbnail-sprite VTT: each cue maps a time range to a
* `sprite.webp#xywh=x,y,w,h` region. Image paths resolve relative to the
* VTT's own folder, exactly how every player treats sprite manifests.
*/
function parsePreviewVtt(raw: string, vttUrl: string): PreviewCue[] {
const cues: PreviewCue[] = [];
const timestamp = /(?:(\d+):)?(\d{2}):(\d{2})\.(\d{3})/gu;
const region = /^(?<file>[^#]+)#xywh=(?<x>\d+),(?<y>\d+),(?<w>\d+),(?<h>\d+)$/u;
const toSeconds = (match: RegExpExecArray): number => {
const [, hours, minutes, seconds, millis] = match;
return (
Number(hours ?? 0) * 3600 + Number(minutes) * 60 + Number(seconds) + Number(millis) / 1000
);
};
for (const block of raw.split(/\r?\n\r?\n/)) {
const lines = block.trim().split(/\r?\n/);
const cueLineIndex = lines.findIndex((line) => line.includes('-->'));
if (cueLineIndex === -1) continue;
const cueLine = lines[cueLineIndex] ?? '';
timestamp.lastIndex = 0;
const startMatch = timestamp.exec(cueLine);
const endMatch = timestamp.exec(cueLine);
const regionMatch = region.exec(lines[cueLineIndex + 1] ?? '');
const { file, x, y, w, h } = regionMatch?.groups ?? {};
if (!startMatch || !endMatch || !file || !x || !y || !w || !h) continue;
cues.push({
start: toSeconds(startMatch),
end: toSeconds(endMatch),
imageUrl: new URL(file, vttUrl).toString(),
x: Number(x),
y: Number(y),
w: Number(w),
h: Number(h),
});
}
return cues;
}
class StepPlugin extends Plugin<NMVideoPlayer> {
static override readonly id = 'tutorial-ui';
private overlay!: HTMLElement;
private topBar!: HTMLDivElement;
private bottomBar!: HTMLDivElement;
private centerButton!: HTMLButtonElement;
private spinner!: HTMLDivElement;
private bottomRow!: HTMLDivElement;
private playbackButton!: HTMLButtonElement;
private sliderBar!: HTMLDivElement;
private isMouseDown = false;
private currentTimeLabel!: HTMLSpanElement;
private durationLabel!: HTMLSpanElement;
private volumeSlider!: HTMLDivElement;
private titleLabel!: HTMLDivElement;
private speedMenu: HTMLDivElement | null = null;
private activeMenu: string | null = null;
private menus = new Map<string, HTMLDivElement>();
private subtitleMenu: HTMLDivElement | null = null;
private audioMenu: HTMLDivElement | null = null;
private qualityMenu: HTMLDivElement | null = null;
private previewCues: PreviewCue[] = [];
private sliderPop!: HTMLDivElement;
private sliderPopImage!: HTMLDivElement;
private sliderPopChapter!: HTMLSpanElement;
private sliderPopText!: HTMLSpanElement;
private chapterLayer!: HTMLDivElement;
override use(): void {
this.player.addClasses(this.player.container, ['group']);
this.overlay = this.mount('overlay');
this.player.addClasses(this.overlay, ['overlay', 'absolute', 'inset-0', 'pointer-events-none']);
this.createTopBar();
this.createTitle();
this.createCenterButton();
this.createSpinner();
this.createBottomBar();
this.createProgressBar();
this.createBottomRow();
this.createPlaybackButton();
this.createSkipButtons();
this.createTimeDisplay();
this.createVolumeControl();
this.createRightSpacer();
this.createSubtitleButton();
this.createAudioButton();
this.createQualityButton();
this.createSpeedButton();
this.createFullscreenButton();
this.createChapterMarkers();
this.createSeekPreview();
}
private createTopBar(): void {
this.topBar = this.player
.createElement('div', 'top-bar')
.addClasses([
'absolute',
'top-0',
'left-0',
'right-0',
'flex',
'items-center',
'gap-2',
'p-4',
'pb-12',
'bg-gradient-to-b',
'from-black/80',
'to-transparent',
'opacity-0',
'transition-opacity',
'duration-300',
'pointer-events-none',
'group-[&.nomercyplayer.active]:opacity-100',
'group-[&.nomercyplayer.active]:pointer-events-auto',
'group-[&.nomercyplayer.paused]:opacity-100',
'group-[&.nomercyplayer.paused]:pointer-events-auto',
])
.appendTo(this.overlay)
.get();
}
private createBottomBar(): void {
this.bottomBar = this.player
.createElement('div', 'bottom-bar')
.addClasses([
'absolute',
'bottom-0',
'left-0',
'right-0',
'flex',
'flex-col',
'gap-1',
'px-4',
'pt-12',
'pb-2',
'bg-gradient-to-t',
'from-black/80',
'to-transparent',
'opacity-0',
'transition-opacity',
'duration-300',
'pointer-events-none',
'group-[&.nomercyplayer.active]:opacity-100',
'group-[&.nomercyplayer.active]:pointer-events-auto',
'group-[&.nomercyplayer.paused]:opacity-100',
'group-[&.nomercyplayer.paused]:pointer-events-auto',
])
.appendTo(this.overlay)
.get();
}
private createUiButton(parent: HTMLElement, id: string, label: string): HTMLButtonElement {
const button = this.player
.createElement('button', id)
.addClasses([
'w-10',
'h-10',
'rounded-full',
'flex',
'items-center',
'justify-center',
'text-white',
'hover:bg-white/20',
'cursor-pointer',
'[&_.icon-hover]:hidden',
'[&:hover_.icon-normal]:hidden',
'[&:hover_.icon-hover]:block',
])
.appendTo(parent)
.get();
button.ariaLabel = label;
return button;
}
private createCenterButton(): void {
this.centerButton = this.player
.createElement('button', 'center-play')
.addClasses([
'absolute',
'top-1/2',
'left-1/2',
'-translate-x-1/2',
'-translate-y-1/2',
'w-16',
'h-16',
'rounded-full',
'bg-black/50',
'text-white',
'flex',
'items-center',
'justify-center',
'transition-opacity',
'duration-300',
'hover:bg-black/70',
'hover:scale-110',
'cursor-pointer',
'pointer-events-auto',
'[&_.icon-hover]:hidden',
'[&:hover_.icon-normal]:hidden',
'[&:hover_.icon-hover]:block',
])
.appendTo(this.overlay)
.get();
this.centerButton.ariaLabel = 'Play';
this.centerButton.innerHTML = svgFromIcon(icons.play);
this.listen(this.centerButton, 'click', (event) => {
event.stopPropagation();
this.player.togglePlayback();
});
this.on('play', () => {
this.centerButton.style.display = 'none';
});
}
private createSpinner(): void {
this.spinner = this.player
.createElement('div', 'spinner')
.addClasses([
'absolute',
'top-1/2',
'left-1/2',
'-translate-x-1/2',
'-translate-y-1/2',
'w-12',
'h-12',
'hidden',
'group-[&.nomercyplayer.buffering]:block',
'pointer-events-none',
])
.appendTo(this.overlay)
.get();
this.spinner.innerHTML = SPINNER_SVG;
}
private createBottomRow(): void {
this.bottomRow = this.player
.createElement('div', 'bottom-row')
.addClasses(['flex', 'items-center', 'gap-1', 'h-10'])
.appendTo(this.bottomBar)
.get();
}
private createPlaybackButton(): void {
this.playbackButton = this.createUiButton(this.bottomRow, 'playback', 'Play');
const playIcon = this.player
.createElement('span', 'playback-play')
.appendTo(this.playbackButton)
.get();
playIcon.innerHTML = svgFromIcon(icons.play);
const pauseIcon = this.player
.createElement('span', 'playback-pause')
.appendTo(this.playbackButton)
.get();
pauseIcon.innerHTML = svgFromIcon(icons.pause);
pauseIcon.style.display = 'none';
this.listen(this.playbackButton, 'click', (event) => {
event.stopPropagation();
this.player.togglePlayback();
});
this.on('pause', () => {
pauseIcon.style.display = 'none';
playIcon.style.display = 'flex';
});
this.on('play', () => {
playIcon.style.display = 'none';
pauseIcon.style.display = 'flex';
});
}
private createProgressBar(): void {
this.sliderBar = this.player
.createElement('div', 'slider-bar')
.addClasses([
'relative',
'w-full',
'h-1',
'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();
// Converts a mouse or touch event's X position into a 0-100 percentage
// relative to the slider bar, clamped so dragging past the edges holds.
const getPercentFromEvent = (event: MouseEvent | TouchEvent): number => {
const rect = this.sliderBar.getBoundingClientRect();
const clientX =
('clientX' in event ? event.clientX : undefined) ??
('touches' in event ? event.touches?.[0]?.clientX : undefined) ??
('changedTouches' in event ? event.changedTouches?.[0]?.clientX : undefined) ??
0;
const x = Math.max(0, Math.min(clientX - rect.left, rect.width));
return (x / rect.width) * 100;
};
for (const eventName of ['mousedown', 'touchstart']) {
this.listen(
this.sliderBar,
eventName,
() => {
this.isMouseDown = true;
},
{ passive: true },
);
}
this.listen(this.sliderBar, 'click', (event) => {
this.isMouseDown = false;
const percent = getPercentFromEvent(event as MouseEvent);
this.player.seekByPercentage(percent);
sliderNipple.style.left = `${percent}%`;
});
for (const eventName of ['mousemove', 'touchmove']) {
this.listen(
this.sliderBar,
eventName,
(event) => {
if (!this.isMouseDown) return;
const percent = getPercentFromEvent(event as MouseEvent | TouchEvent);
sliderNipple.style.left = `${percent}%`;
sliderProgress.style.width = `${percent}%`;
},
{ passive: true },
);
}
this.listen(
this.sliderBar,
'mouseleave',
() => {
this.isMouseDown = false;
},
{ passive: true },
);
this.on('time', ({ position, duration, buffered, percentage }) => {
if (this.isMouseDown) return;
sliderProgress.style.width = `${percentage}%`;
sliderNipple.style.left = `${percentage}%`;
if (duration > 0)
sliderBuffer.style.width = `${Math.min(100, ((position + buffered) / duration) * 100)}%`;
});
this.on('item', () => {
sliderBuffer.style.width = '0';
sliderProgress.style.width = '0';
});
}
private createSkipButtons(): void {
const skipBack = this.createUiButton(this.bottomRow, 'skip-back', 'Skip back 10 seconds');
skipBack.innerHTML = svgFromIcon(icons.seekBack);
this.listen(skipBack, 'click', (event) => {
event.stopPropagation();
void this.player.rewind(10);
});
const skipForward = this.createUiButton(
this.bottomRow,
'skip-forward',
'Skip forward 10 seconds',
);
skipForward.innerHTML = svgFromIcon(icons.seekForward);
this.listen(skipForward, 'click', (event) => {
event.stopPropagation();
void this.player.forward(10);
});
}
private createTimeDisplay(): void {
this.currentTimeLabel = this.player
.createElement('span', 'current-time')
.addClasses(['text-white', 'text-xs', 'tabular-nums', 'ml-2'])
.appendTo(this.bottomRow)
.get();
this.currentTimeLabel.textContent = '0:00';
const separator = this.player
.createElement('span', 'time-separator')
.addClasses(['text-white/50', 'text-xs', 'mx-1'])
.appendTo(this.bottomRow)
.get();
separator.textContent = '/';
this.durationLabel = this.player
.createElement('span', 'duration')
.addClasses(['text-white', 'text-xs', 'tabular-nums'])
.appendTo(this.bottomRow)
.get();
this.durationLabel.textContent = '0:00';
this.on('time', ({ position, duration }) => {
this.currentTimeLabel.textContent = formatSeconds(position);
this.durationLabel.textContent = formatSeconds(duration);
});
}
private createVolumeControl(): void {
const volumeContainer = this.player
.createElement('div', 'volume-container')
.addClasses(['flex', 'items-center', 'group/volume', 'ml-1'])
.appendTo(this.bottomRow)
.get();
const volumeButton = this.createUiButton(volumeContainer, 'volume', 'Mute');
const volHigh = this.player.createElement('span', 'vol-high').appendTo(volumeButton).get();
volHigh.innerHTML = svgFromIcon(icons.volumeHigh);
const volLow = this.player.createElement('span', 'vol-low').appendTo(volumeButton).get();
volLow.innerHTML = svgFromIcon(icons.volumeLow);
volLow.style.display = 'none';
const volMuted = this.player.createElement('span', 'vol-muted').appendTo(volumeButton).get();
volMuted.innerHTML = svgFromIcon(icons.volumeMuted);
volMuted.style.display = 'none';
this.listen(volumeButton, 'click', (event) => {
event.stopPropagation();
this.player.toggleMute();
});
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();
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();
const volumeNipple = this.player
.createElement('div', 'volume-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/vol-slider:flex',
'pointer-events-none',
'z-20',
])
.appendTo(this.volumeSlider)
.get();
const updateVolSliderUi = (vol: number): void => {
const pct = Math.max(0, Math.min(vol, 100));
volumeProgress.style.width = `${pct}%`;
volumeNipple.style.left = `${pct}%`;
};
let volDragging = false;
const getVolFromEvent = (event: MouseEvent | TouchEvent): number => {
const rect = this.volumeSlider.getBoundingClientRect();
const clientX =
('clientX' in event ? event.clientX : undefined) ??
('touches' in event ? event.touches?.[0]?.clientX : undefined) ??
('changedTouches' in event ? event.changedTouches?.[0]?.clientX : undefined) ??
0;
const x = Math.max(0, Math.min(clientX - rect.left, rect.width));
return Math.round((x / rect.width) * 100);
};
for (const eventName of ['mousedown', 'touchstart']) {
this.listen(
this.volumeSlider,
eventName,
() => {
volDragging = true;
},
{ passive: true },
);
}
this.listen(this.volumeSlider, 'click', (event) => {
volDragging = false;
const vol = getVolFromEvent(event as MouseEvent);
this.player.volume(vol);
updateVolSliderUi(vol);
});
for (const eventName of ['mousemove', 'touchmove']) {
this.listen(
this.volumeSlider,
eventName,
(event) => {
if (!volDragging) return;
const vol = getVolFromEvent(event as MouseEvent | TouchEvent);
this.player.volume(vol);
updateVolSliderUi(vol);
},
{ passive: true },
);
}
this.listen(
this.volumeSlider,
'mouseleave',
() => {
volDragging = false;
},
{ passive: true },
);
this.listen(
document,
'mouseup',
() => {
volDragging = false;
},
{ passive: true },
);
this.listen(
document,
'touchend',
() => {
volDragging = false;
},
{ passive: true },
);
let muted = this.player.volumeState() === VolumeState.MUTED;
const updateVolumeIcon = (volume: number): void => {
volHigh.style.display = 'none';
volLow.style.display = 'none';
volMuted.style.display = 'none';
if (muted || volume === 0) {
volMuted.style.display = 'flex';
} else if (volume < 50) {
volLow.style.display = 'flex';
} else {
volHigh.style.display = 'flex';
}
};
this.on('volume', ({ level }) => {
updateVolSliderUi(level);
updateVolumeIcon(level);
});
this.on('mute', (payload) => {
muted = payload.muted;
updateVolumeIcon(this.player.volume());
});
const initialVol = this.player.volume();
updateVolSliderUi(initialVol);
updateVolumeIcon(initialVol);
}
private createTitle(): void {
this.titleLabel = this.player
.createElement('div', 'title-display')
.addClasses(['text-white', 'text-sm', 'font-medium', 'truncate'])
.appendTo(this.topBar)
.get();
this.updateTitle();
this.on('item', () => this.updateTitle());
}
private updateTitle(): void {
const item = this.player.item();
if (!item) return;
let text = item.title;
if (item.show) {
text = item.show;
if (item.season !== undefined && item.episode !== undefined) {
text += ` - S${item.season}E${item.episode}: ${item.title}`;
}
}
this.titleLabel.textContent = text ?? '';
}
private createRightSpacer(): void {
this.player.createElement('div', 'spacer').addClasses(['flex-1']).appendTo(this.bottomRow);
}
private createSpeedButton(): void {
const button = this.createUiButton(this.bottomRow, 'speed', 'Playback speed');
button.innerHTML = svgFromIcon(icons.speed);
this.listen(button, 'click', (event) => {
event.stopPropagation();
this.toggleMenu('speed');
});
this.speedMenu = this.player
.createElement('div', 'speed-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.menus.set('speed', this.speedMenu);
const rates = this.player.playbackRates();
for (const rate of rates) {
const option = this.player
.createElement('button', `speed-${rate}`)
.addClasses([
'text-white',
'text-sm',
'px-3',
'py-1.5',
'rounded',
'hover:bg-white/20',
'text-left',
'cursor-pointer',
])
.appendTo(this.speedMenu!)
.get();
option.textContent = rate === 1 ? 'Normal' : `${rate}x`;
this.listen(option, 'click', (event) => {
event.stopPropagation();
void this.player.playbackRate(rate);
this.toggleMenu(null);
});
}
this.on('playbackRate', () => this.updateSpeedMenu());
this.updateSpeedMenu();
}
private updateSpeedMenu(): void {
if (!this.speedMenu) return;
const current = this.player.playbackRate();
const buttons = this.speedMenu.querySelectorAll('button');
const rates = this.player.playbackRates();
buttons.forEach((button, index) => {
button.classList.toggle('bg-white/20', rates[index] === current);
});
}
private toggleMenu(name: string | null): void {
for (const menu of this.menus.values()) {
menu.classList.add('hidden');
menu.classList.remove('flex');
}
if (name === this.activeMenu || name === null) {
this.activeMenu = null;
return;
}
this.activeMenu = name;
const menu = this.menus.get(name);
menu?.classList.remove('hidden');
menu?.classList.add('flex');
}
private createFullscreenButton(): void {
const button = this.createUiButton(this.bottomRow, 'fullscreen', 'Fullscreen');
const enterIcon = this.player.createElement('span', 'fs-enter').appendTo(button).get();
enterIcon.innerHTML = svgFromIcon(icons.fullscreen);
const exitIcon = this.player.createElement('span', 'fs-exit').appendTo(button).get();
exitIcon.innerHTML = svgFromIcon(icons.exitFullscreen);
exitIcon.style.display = 'none';
this.listen(button, 'click', (event) => {
event.stopPropagation();
this.player.toggleFullscreen();
});
this.on('fullscreen', ({ active }) => {
enterIcon.style.display = active ? 'none' : 'flex';
exitIcon.style.display = active ? 'flex' : 'none';
button.ariaLabel = active ? 'Exit fullscreen' : 'Fullscreen';
});
}
private createMenuPanel(id: string): HTMLDivElement {
return this.player
.createElement('div', id)
.addClasses([
'absolute',
'bottom-12',
'right-0',
'bg-black/90',
'rounded-lg',
'p-2',
'hidden',
'flex-col',
'gap-1',
'min-w-[160px]',
'max-h-56',
'overflow-y-auto',
'pointer-events-auto',
])
.appendTo(this.bottomRow)
.get();
}
private renderMenuOptions(
menu: HTMLDivElement,
labels: string[],
activeIndex: number,
onPick: (index: number) => void,
): void {
menu.innerHTML = '';
labels.forEach((label, index) => {
const option = this.player
.createElement('button', `${menu.id}-option-${index}`)
.addClasses([
'text-white',
'text-sm',
'px-3',
'py-1.5',
'rounded',
'hover:bg-white/20',
'text-left',
'cursor-pointer',
])
.appendTo(menu)
.get();
option.textContent = label;
option.classList.toggle('bg-white/20', index === activeIndex);
this.listen(option, 'click', (event) => {
event.stopPropagation();
onPick(index);
this.toggleMenu(null);
});
});
}
private markActiveOption(menu: HTMLDivElement | null, activeIndex: number): void {
menu?.querySelectorAll('button').forEach((button, index) => {
button.classList.toggle('bg-white/20', index === activeIndex);
});
}
private createSubtitleButton(): void {
const button = this.createUiButton(this.bottomRow, 'subtitles', 'Subtitles');
button.innerHTML = svgFromIcon(icons.subtitles);
button.style.display = 'none';
this.subtitleMenu = this.createMenuPanel('subtitle-menu');
this.menus.set('subtitles', this.subtitleMenu);
this.listen(button, 'click', (event) => {
event.stopPropagation();
this.toggleMenu('subtitles');
});
const activeIndex = (): number => (this.player.subtitle()?.index ?? -1) + 1;
const rebuild = (): void => {
const tracks = this.player.subtitles();
button.style.display = tracks.length > 0 ? '' : 'none';
this.renderMenuOptions(
this.subtitleMenu!,
['Off', ...tracks.map((track) => track.label)],
activeIndex(),
(index) => {
void this.player.subtitle(index === 0 ? null : index - 1);
},
);
};
this.on('mediaReady', rebuild);
this.on('item', rebuild);
this.on('subtitle', () => this.markActiveOption(this.subtitleMenu, activeIndex()));
}
private createAudioButton(): void {
const button = this.createUiButton(this.bottomRow, 'audio', 'Audio track');
button.innerHTML = svgFromIcon(icons.language);
button.style.display = 'none';
this.audioMenu = this.createMenuPanel('audio-menu');
this.menus.set('audio', this.audioMenu);
this.listen(button, 'click', (event) => {
event.stopPropagation();
this.toggleMenu('audio');
});
const activeIndex = (): number => this.player.audioTrack()?.index ?? 0;
const rebuild = (): void => {
const tracks = this.player.audioTracks();
button.style.display = tracks.length > 1 ? '' : 'none';
this.renderMenuOptions(
this.audioMenu!,
tracks.map((track) => track.label),
activeIndex(),
(index) => {
void this.player.audioTrack(index);
},
);
};
this.on('audioTracks', rebuild);
this.on('mediaReady', rebuild);
this.on('audioTrack', () => this.markActiveOption(this.audioMenu, activeIndex()));
}
private createQualityButton(): void {
const button = this.createUiButton(this.bottomRow, 'quality', 'Quality');
button.innerHTML = svgFromIcon(icons.quality);
button.style.display = 'none';
this.qualityMenu = this.createMenuPanel('quality-menu');
this.menus.set('quality', this.qualityMenu);
this.listen(button, 'click', (event) => {
event.stopPropagation();
this.toggleMenu('quality');
});
const activeIndex = (): number => {
const current = this.player.quality();
return current === 'auto' ? 0 : current.index + 1;
};
const rebuild = (): void => {
const levels = this.player.qualityLevels();
button.style.display = levels.length > 1 ? '' : 'none';
this.renderMenuOptions(
this.qualityMenu!,
['Auto', ...levels.map((level) => level.label)],
activeIndex(),
(index) => {
const level = levels[index - 1];
this.player.quality(index === 0 || !level ? 'auto' : level.index);
},
);
};
this.on('levels', rebuild);
this.on('mediaReady', rebuild);
this.on('quality:requested', () => this.markActiveOption(this.qualityMenu, activeIndex()));
this.on('level-switched', () => this.markActiveOption(this.qualityMenu, activeIndex()));
}
private createChapterMarkers(): void {
this.chapterLayer = this.player
.createElement('div', 'chapter-markers')
.addClasses(['absolute', 'inset-0', 'pointer-events-none', 'z-10'])
.appendTo(this.sliderBar)
.get();
this.on('chapters', () => this.renderChapterMarkers());
this.on('duration', () => this.renderChapterMarkers());
}
private renderChapterMarkers(): void {
this.chapterLayer.innerHTML = '';
const { duration } = this.player.timeData();
if (duration <= 0) return;
for (const chapter of this.player.chapters()) {
if (chapter.start <= 0) continue;
const tick = this.player
.createElement('div', `chapter-tick-${chapter.index}`)
.addClasses(['absolute', 'top-0', 'h-full', 'w-0.5', '-translate-x-1/2', 'bg-black/70'])
.appendTo(this.chapterLayer)
.get();
tick.style.left = `${(chapter.start / duration) * 100}%`;
}
}
private createSeekPreview(): void {
this.sliderPop = this.player
.createElement('div', 'slider-pop')
.addClasses([
'absolute',
'bottom-full',
'mb-3',
'flex',
'flex-col',
'items-center',
'gap-1',
'rounded-md',
'overflow-hidden',
'bg-[rgba(20,20,25,0.95)]',
'pb-1',
'min-w-[60px]',
'pointer-events-none',
'z-30',
'opacity-0',
'transition-opacity',
'duration-150',
])
.appendTo(this.sliderBar)
.get();
this.sliderPopImage = this.player
.createElement('div', 'slider-pop-image')
.addClasses(['mx-1.5', 'mt-1.5', 'rounded', 'bg-no-repeat'])
.appendTo(this.sliderPop)
.get();
this.sliderPopChapter = this.player
.createElement('span', 'slider-pop-chapter')
.addClasses(['text-white/80', 'text-xs', 'px-2', 'max-w-[240px]', 'truncate'])
.appendTo(this.sliderPop)
.get();
this.sliderPopChapter.style.display = 'none';
this.sliderPopText = this.player
.createElement('span', 'slider-pop-text')
.addClasses(['text-white', 'text-xs', 'tabular-nums', 'px-2'])
.appendTo(this.sliderPop)
.get();
this.on('ready', () => void this.fetchPreviewCues());
this.on('item', () => {
this.previewCues = [];
void this.fetchPreviewCues();
});
this.listen(
this.sliderBar,
'mousemove',
(event) => {
if (this.previewCues.length === 0) return;
const rect = this.sliderBar.getBoundingClientRect();
const x = Math.max(0, Math.min((event as MouseEvent).clientX - rect.left, rect.width));
const percent = x / rect.width;
const scrubTime = percent * this.player.duration();
const preview =
this.previewCues.find((cue) => scrubTime >= cue.start && scrubTime < cue.end) ??
this.previewCues.at(-1);
if (preview) {
this.sliderPopImage.style.backgroundImage = `url('${preview.imageUrl}')`;
this.sliderPopImage.style.backgroundPosition = `-${preview.x}px -${preview.y}px`;
this.sliderPopImage.style.width = `${preview.w}px`;
this.sliderPopImage.style.height = `${preview.h}px`;
const popWidth = this.sliderPop.offsetWidth || preview.w;
const minLeft = popWidth / 2;
const maxLeft = rect.width - popWidth / 2;
const clampedX = Math.max(minLeft, Math.min(x, maxLeft));
this.sliderPop.style.left = `${clampedX}px`;
this.sliderPop.style.transform = 'translateX(-50%)';
const chapterTitle =
this.player
.chapters()
.find((chapter) => scrubTime >= chapter.start && scrubTime <= chapter.end)?.title ??
'';
this.sliderPopChapter.textContent = chapterTitle;
this.sliderPopChapter.style.display = chapterTitle ? '' : 'none';
this.sliderPopText.textContent = formatSeconds(scrubTime);
}
this.sliderPop.style.opacity = '1';
},
{ passive: true },
);
this.listen(
this.sliderBar,
'mouseleave',
() => {
this.sliderPop.style.opacity = '0';
},
{ passive: true },
);
}
private async fetchPreviewCues(): Promise<void> {
if (this.previewCues.length > 0) return;
const item = this.player.item();
const spritePath = item?.previewSpriteUrl;
if (!spritePath) return;
const vttUrl = `${this.player.options.baseUrl ?? ''}${spritePath}`;
try {
const raw = await this.fetch<string>(vttUrl, { scope: 'silent' });
this.previewCues = parsePreviewVtt(raw, vttUrl);
} catch {
this.previewCues = [];
}
}
}
const config: VideoPlayerConfig = {
baseUrl: FILMS_BASE,
baseImageUrl: 'https://image.tmdb.org/t/p',
muted: false,
autoPlay: false,
controls: false,
playbackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2],
playlist: [bigBuckBunnyItem],
};
function configure(player: NMVideoPlayer): void {
player.addPlugin(StepPlugin);
}
const player = nmplayer('player');
configure(player);
player.setup(config);
await player.ready();
Next steps
- Step 10: Full Plugin: the touch layer, double-tap seeking, and the finished plugin.