Recipe: Persistence & Resume
The player never persists watch position on its own, it reads item.progress if your data already carries it, and it emits 'progress' so you can save new values, but the storage round-trip is your app’s job. This recipe covers both halves: writing progress out as it plays, and feeding it back in so the next session resumes where the last one left off.
Saving progress as it plays
'progress' fires { time, duration, percentage }, throttled by progressIntervalMs (5 seconds by default), cheap enough to persist on every fire without debouncing again in your own code:
import nmplayer from '@nomercy-entertainment/nomercy-video-player';
const player = nmplayer('player');
player.on('progress', ({ time, percentage }) => {
void saveProgress(player.item()?.id, {
time,
percentage,
timestamp: Date.now(),
});
});
'time' fires far more often (every native timeupdate, dozens of times a second) and is the wrong signal for a persistence write, it exists for scrubber UI, not for storage. Always save from 'progress'.
The WatchProgress shape
Attach the saved record to the matching field on the item the next time you build a playlist, this is the exact shape VideoPlaylistItem.progress expects:
interface WatchProgress {
timestamp: number; // Unix epoch ms of the last watch session
percentage: number; // 0-100 percent watched
time?: number; // playback position in seconds, to resume from
}
import nmplayer from '@nomercy-entertainment/nomercy-video-player';
// `items` is your own catalogue (a page's fetched list, a store, ...) and
// `progressStore` your own persistence layer — the one piece every app
// supplies itself. Everything below `playlist` is real, runnable v2 API.
const playlist: VideoPlaylistItem[] = items.map((item) => ({
...item,
progress: progressStore.get(item.id), // WatchProgress | undefined
}));
const player = nmplayer('player');
player.setup({
baseUrl: 'https://raw.githubusercontent.com/NoMercy-Entertainment/nomercy-media/master/Films',
playlist,
autoPlay: true,
});
What setup() does with it, and what autoPlay gates
Every setup() call with a non-empty playlist picks a start item from progress on its own, no extra config needed, and does it whether or not autoPlay is set: the most recently watched item (progress.timestamp) wins, over 90% watched rolls forward to the next item instead of replaying the credits, anything else resumes at progress.time. startAt rides the load down to the backend, so the stream begins fetching at the offset directly, no play-then-immediately-seek flash. autoPlay: true is the one thing this selection doesn’t do by itself: it decides whether that primed item also starts playing, or the player mounts paused and ready on the right item and position instead.
This means the feed-in half of the recipe is just: know the last-watched item and its WatchProgress before you call setup(). Everything else, picking which item, deciding whether to resume or roll forward, is already built.
Handling items outside the initial playlist
autoPlay’s selection only runs once, over the items present at setup(). If your app resumes a title that is not in the queue yet (a deep link, a “continue watching” row loaded separately), skip the config field and drive it directly:
const saved = await progressStore.get(itemId);
const item: VideoPlaylistItem = { ...fetchedItem, progress: saved };
player.item(item, { autoplay: true, startAt: saved?.time });
See it running
This demo proves the persistence half of the recipe, not the built-in autoPlay/progress/startAt path described above: it sets autoPlay: false and hand-rolls its own resume, reading localStorage on mount and calling player.time(saved.time) once 'mediaReady' confirms the source is ready to seek, because a docs preview can’t reload the page for you the way a real app’s next visit would. The badge shows the value read from localStorage on mount, then updates on every 'progress' tick. Reload this page (or open it in a new tab) and the badge on mount still reflects the position saved by your last visit, since it re-reads the same key.
/**
* Recipe: Persistence & Resume. Saves the watch position to `localStorage` on
* every `'progress'` event (throttled by `progressIntervalMs`, cheap enough
* to persist on every fire) and seeks back to it once the backend confirms
* the source is ready. This is the same mechanism `autoPlay` + `item.progress`
* automate across a real page reload — this example proves it within one
* mounted session since the docs preview can't reload the page for you.
*/
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 sintel = {
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',
};
const STORAGE_KEY = 'nm-docs-recipe-resume:sintel';
const config: VideoPlayerConfig = {
baseUrl: FILMS_BASE,
baseImageUrl: 'https://image.tmdb.org/t/p',
muted: true,
autoPlay: false,
controls: true,
playlist: [sintel],
};
interface SavedProgress {
time: number;
percentage: number;
timestamp: number;
}
function readSavedProgress(): SavedProgress | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as SavedProgress) : null;
} catch {
return null;
}
}
function onReady(player: IVideoPlayer, container: HTMLElement): () => void {
if (!container.style.position) container.style.position = 'relative';
const badge = player.createElement('div', 'nm-resume-badge').appendTo(container).get();
badge.style.cssText =
'position:absolute;right:1rem;top:1rem;padding:.35rem .6rem;border-radius:.5rem;' +
'background:rgba(0,0,0,.65);color:#fff;font:600 .75rem system-ui,sans-serif;pointer-events:none;';
const saved = readSavedProgress();
badge.textContent = saved ? `Resuming at ${Math.round(saved.time)}s` : 'No saved position yet';
// mediaReady is the kit's signal that the backend accepted the source and
// duration is known — seeking any earlier can be silently dropped.
const resumeOnce = (): void => {
if (saved && saved.time > 0) void player.time(saved.time);
player.off('mediaReady', resumeOnce);
};
player.on('mediaReady', resumeOnce);
const onProgress = ({
time,
percentage,
}: {
time: number;
duration: number;
percentage: number;
}): void => {
const record: SavedProgress = { time, percentage, timestamp: Date.now() };
localStorage.setItem(STORAGE_KEY, JSON.stringify(record));
badge.textContent = `Saved at ${Math.round(time)}s (${Math.round(percentage)}%)`;
};
player.on('progress', onProgress);
return () => {
player.off('mediaReady', resumeOnce);
player.off('progress', onProgress);
badge.remove();
};
}
const player = nmplayer('player').setup(config);
await player.ready();
const container = document.getElementById('player')!;
const cleanup = onReady(player, container);
Next steps
- Recipe: Manual Quality Selection: another consumer task built on a typed event plus a small UI.