Recipe: Playlist & Queue Panel
Guided Tour: The Queue & Playlist covers the method surface. This recipe is the UI on top of it, a real “up next” panel: list the queue, highlight the active item, let the user jump to any entry, and keep the list live as the queue itself changes underneath it.
Render on events, not polling
'item' fires when the cursor moves. 'queue' fires on every queue change: a full replace (player.queue(items)), and every append, remove, and reorder. Each mutation also fires its own typed event alongside 'queue': queue:append, queue:remove, queue:move. A panel that re-renders on these never drifts from the real state, and never needs a poll loop:
import nmplayer from '@nomercy-entertainment/nomercy-video-player';
const player = nmplayer('player');
function render() {
const activeId = player.item()?.id;
renderList(player.queue(), activeId);
}
player.on('item', render);
player.on('queue', render);
player.on('queue:append', render);
player.on('queue:remove', render);
player.on('queue:move', render);
render(); // initial paint
Jump with playItem(), not item()
queue(items); item(start); play() races: item() fires a fire-and-forget load(), and a play() called right after can reach the backend before the new source is even set. A click handler in a queue panel calls play() implicitly (the user expects clicking an item to start it), so use playItem(target) instead of item(target), it routes through item(target, { autoplay: true }) internally, which only calls play() inside the load’s own continuation:
// One row per queue item, rendered inside renderList() above.
player.queue().forEach((item) => {
const row = document.querySelector<HTMLElement>(`[data-item-id="${item.id}"]`)!;
row.addEventListener('click', () => {
player.playItem(item.id);
});
});
Mutating the queue from the panel
Every mutation method fires its own typed event as well as the shared 'queue' event, the listeners added above already cover them, so a panel wired the way the last section shows updates itself, no extra listener needed:
import type { VideoPlaylistItem } from '@nomercy-entertainment/nomercy-video-player';
const addToQueueButton = document.querySelector<HTMLElement>('#add-to-queue')!;
const newItem: VideoPlaylistItem = { id: 'cosmos-laundromat', title: 'Cosmos Laundromat', url: '/Cosmos.Laundromat.(2015)/Cosmos.Laundromat.(2015).NoMercy.m3u8' };
addToQueueButton.addEventListener('click', () => {
player.queueAppend(newItem); // fires 'queue:append', panel re-renders itself
});
// Per-row remove button, rendered alongside each row in renderList() above.
player.queue().forEach((item) => {
const removeButton = document.querySelector<HTMLElement>(`[data-remove-id="${item.id}"]`)!;
removeButton.addEventListener('click', () => {
player.queueRemove(item.id);
});
});
Removing the item before the cursor decrements the cursor automatically, so the “now playing” highlight never jumps to the wrong row after a removal earlier in the list.
Drag-to-reorder
queueMove(from, to) takes the two indexes directly, plug it into whatever drag library the panel uses without translating the drop event yourself:
function onDrop(fromIndex: number, toIndex: number) {
player.queueMove(fromIndex, toIndex);
}
See it running
The panel in the corner below lists all three films in the queue, highlights the one currently playing, and jumps to whichever one you click.
/**
* Recipe: Playlist / "Up Next" Panel. Renders `queue()` as a clickable list,
* highlights the active item from `item()`, and re-renders on `'item'`
* (cursor moved) and `'queue'` (items changed) instead of polling. Jumping
* uses `playItem(id)`, which closes the `item(); play()` race described in
* The Queue & Playlist — a plain `item(id)` here would risk `play()` reaching
* the backend before the new source is set.
*/
import nmplayer, {
type IVideoPlayer,
type VideoPlayerConfig,
} from '@nomercy-entertainment/nomercy-video-player';
const FILMS_BASE =
'https://raw.githubusercontent.com/NoMercy-Entertainment/nomercy-media/master/Films';
const films = [
{
id: 'sintel',
title: 'Sintel',
description:
'A short fantasy film by the Blender Foundation. Sintel searches for a baby dragon she calls Scales.',
url: '/Sintel.(2010)/Sintel.(2010).NoMercy.m3u8',
image: '/w780/q2bVM5z90tCGbmXYtq2J38T5hSX.jpg',
duration: 888,
year: 2010,
subtitles: [
{
id: 'eng',
label: 'English',
language: 'eng',
kind: 'subtitles',
url: '/Sintel.(2010)/subtitles/Sintel.(2010).NoMercy.eng.full.vtt',
},
{
id: 'dut',
label: 'Dutch',
language: 'dut',
kind: 'subtitles',
url: '/Sintel.(2010)/subtitles/Sintel.(2010).NoMercy.dut.full.vtt',
},
{
id: 'fre',
label: 'French',
language: 'fre',
kind: 'subtitles',
url: '/Sintel.(2010)/subtitles/Sintel.(2010).NoMercy.fre.full.vtt',
},
{
id: 'ger',
label: 'German',
language: 'ger',
kind: 'subtitles',
url: '/Sintel.(2010)/subtitles/Sintel.(2010).NoMercy.ger.full.vtt',
},
],
chapters: [
{ index: 0, start: 0, end: 107, title: 'Opening' },
{ index: 1, start: 107, end: 207, title: 'A Dangerous Quest' },
{ index: 2, start: 207, end: 338, title: 'Scales' },
{ index: 3, start: 338, end: 445, title: 'The Attack' },
{ index: 4, start: 445, end: 557, title: 'In Pursuit' },
{ index: 5, start: 557, end: 621, title: 'The Cave' },
{ index: 6, start: 621, end: 745, title: 'Eye to Eye' },
{ index: 7, start: 745, end: 888, title: 'End Credits' },
],
previewSpriteUrl: '/Sintel.(2010)/thumbs_256x109.vtt',
},
{
id: 'cosmos-laundromat',
title: 'Cosmos Laundromat',
description: 'On a desolate island, a suicidal sheep meets his fate.',
url: '/Cosmos.Laundromat.(2015)/Cosmos.Laundromat.(2015).NoMercy.m3u8',
image: '/w780/f2wABsgj2lIR2dkDEfBZX8p4Iyk.jpg',
duration: 724,
year: 2015,
subtitles: [
{
id: 'eng',
label: 'English',
language: 'eng',
kind: 'subtitles',
url: '/Cosmos.Laundromat.(2015)/subtitles/Cosmos.Laundromat.(2015).NoMercy.eng.full.vtt',
},
],
},
{
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 config: VideoPlayerConfig = {
baseUrl: FILMS_BASE,
baseImageUrl: 'https://image.tmdb.org/t/p',
muted: true,
autoPlay: false,
controls: true,
playlist: films,
};
function onReady(player: IVideoPlayer, container: HTMLElement): () => void {
if (!container.style.position) container.style.position = 'relative';
const panel = player.createElement('div', 'nm-queue-panel').appendTo(container).get();
panel.style.cssText =
'position:absolute;top:1rem;right:1rem;width:13rem;max-height:70%;overflow:auto;' +
'border-radius:.6rem;background:rgba(0,0,0,.72);padding:.35rem;';
const render = (): void => {
panel.replaceChildren();
const activeId = player.item()?.id;
player.queue().forEach((item, index) => {
const row = document.createElement('button');
row.type = 'button';
row.textContent = `${index + 1}. ${item.title ?? String(item.id)}`;
row.style.cssText =
'display:block;width:100%;text-align:left;padding:.35rem .5rem;border:0;' +
'border-radius:.4rem;background:transparent;color:#fff;' +
'font:600 .78rem system-ui,sans-serif;cursor:pointer;';
if (item.id === activeId) row.style.background = 'rgba(255,255,255,.2)';
row.addEventListener('click', () => player.playItem(item.id));
panel.appendChild(row);
});
};
player.on('item', render);
player.on('queue', render);
render();
return () => {
player.off('item', render);
player.off('queue', render);
panel.remove();
};
}
const player = nmplayer('player').setup(config);
await player.ready();
const container = document.getElementById('player')!;
const cleanup = onReady(player, container);
Next steps
- Recipe: Auth Tokens via baseUrl + auth: protecting the same catalogue this panel lists behind a bearer token.