Skip to content

Recipe: Vue Integration

nomercy-video-player is not a Vue library and never will be, it ships one factory function and a typed method surface, nothing that knows what a component is. Vue owns nothing about the player; the player owns nothing about Vue. A composable is the seam between them: it subscribes to player events, writes the values into refs, and returns both the reactive state and the player instance itself so a template can call methods directly.

The composable

TypeScript
// composables/useVideoPlayer.ts
import { onMounted, onUnmounted, ref, shallowRef } from 'vue';
import nmplayer from '@nomercy-entertainment/nomercy-video-player';
import { PlayState } from '@nomercy-entertainment/nomercy-video-player';
import type { IVideoPlayer, VideoPlayerConfig, VideoPlaylistItem } from '@nomercy-entertainment/nomercy-video-player';

export function useVideoPlayer(containerId: string, config: VideoPlayerConfig) {
// `shallowRef`, not `ref` — the player instance is a plain class holding
// its own internal state; deep-proxying it through Vue's reactivity would
// be both wasteful and unsupported. Only reassigning `.value` (done once,
// in `onMounted`, below) is reactive; the instance's own methods and
// properties are read as-is.
const player = shallowRef<IVideoPlayer | null>(null);
const currentItem = ref<VideoPlaylistItem | undefined>(undefined);
const isPlaying = ref(false);
const currentTime = ref(0);
const duration = ref(0);
const volume = ref(100);

onMounted(() => {
// `nmplayer(containerId)` looks up the DOM element by id immediately —
// it throws `core:player/element-missing` if the element isn't there
// yet. `onMounted` is the one point in a component's lifecycle Vue
// guarantees the template's own elements (including `#${containerId}`)
// are in the DOM, so the lookup is created here, not at composable-call
// time (`<script setup>`'s top level runs during the *setup* phase,
// before the template has mounted anything).
const instance = nmplayer(containerId).setup(config);
player.value = instance;

instance.on('item', ({ item }) => { currentItem.value = item; });
instance.on('play', () => { isPlaying.value = true; });
instance.on('playing', () => { isPlaying.value = true; });
instance.on('pause', () => { isPlaying.value = false; });
instance.on('ended', () => { isPlaying.value = false; });
instance.on('time', ({ time }) => { currentTime.value = time; });
instance.on('duration', ({ duration: total }) => { duration.value = total; });
instance.on('volume', ({ level }) => { volume.value = level; });
});

onUnmounted(() => {
void player.value?.dispose();
});

return {
player,
currentItem,
isPlaying,
currentTime,
duration,
volume,
playState: () => player.value?.playState() === PlayState.PLAYING,
};
}

player is null until onMounted runs — a template reads it through player?.togglePlayback() the same way it reads any other ref that starts empty, and playState()/the seek() handler below guard the same way. This is the one shape that’s correct regardless of how the caller renders the container (always-present markup, v-if, a route transition): the composable never assumes the DOM is ready before Vue says it is.

Using it in a component

VideoPlayer.vue below takes baseUrl and playlist as props instead of owning them, the same two values useVideoPlayer’s config already needs, just threaded one level up so whoever mounts the component supplies the real catalogue. muted: true + autoPlay: false are the constant part of the config (the player loads the first item on mount but waits for a user gesture to play it, exactly like every other runnable example on this site) — only baseUrl and playlist vary by caller:

Vue
<!-- VideoPlayer.vue -->
<script setup lang="ts">
import { computed } from 'vue';
import type { VideoPlayerConfig, VideoPlaylistItem } from '@nomercy-entertainment/nomercy-video-player';
import { useVideoPlayer } from '@/composables/useVideoPlayer';

const props = defineProps<{
baseUrl: string;
playlist: VideoPlaylistItem[];
}>();

const config: VideoPlayerConfig = {
baseUrl: props.baseUrl,
baseImageUrl: 'https://image.tmdb.org/t/p',
muted: true,
autoPlay: false,
playlist: props.playlist,
};

const { player, currentItem, isPlaying, currentTime, duration } = useVideoPlayer('player', config);

const progress = computed(() =>
duration.value > 0 ? (currentTime.value / duration.value) * 100 : 0,
);

function seek(event: MouseEvent) {
const bar = event.currentTarget as HTMLElement;
const ratio = event.offsetX / bar.clientWidth;
player.value?.seekByPercentage(ratio * 100);
}
</script>

<template>
<div id="player" class="player" />
<p>{{ currentItem?.title ?? 'Nothing playing' }}</p>

<div class="progress" role="slider" :aria-valuenow="progress" aria-valuemin="0" aria-valuemax="100" tabindex="0" @click="seek">
<div class="fill" :style="{ width: `${progress}%` }" />
</div>

<button type="button" @click="player?.togglePlayback()">
{{ isPlaying ? 'Pause' : 'Play' }}
</button>
</template>

player is a top-level <script setup> ref, so the template above reads it unwrapped (player?.togglePlayback(), no .value) — Vue does that unwrapping automatically for refs returned from a composable and used directly in the template. Inside <script> itself (the seek() handler), it stays an explicit ref: player.value?.seekByPercentage(...).

Mounting it

films is the real nomercy-media fixture catalogue (the same three shorts the Quickstart and the testbed use), passed down as a prop, not hardcoded inside the component above:

Vue
<!-- App.vue -->
<script setup lang="ts">
import VideoPlayer from './VideoPlayer.vue';

const baseUrl = 'https://raw.githubusercontent.com/NoMercy-Entertainment/nomercy-media/master/Films';
const films = [
{
id: 'sintel',
title: 'Sintel',
url: '/Sintel.(2010)/Sintel.(2010).NoMercy.m3u8',
image: '/w780/q2bVM5z90tCGbmXYtq2J38T5hSX.jpg',
},
{
id: 'cosmos-laundromat',
title: 'Cosmos Laundromat',
url: '/Cosmos.Laundromat.(2015)/Cosmos.Laundromat.(2015).NoMercy.m3u8',
image: '/w780/f2wABsgj2lIR2dkDEfBZX8p4Iyk.jpg',
},
{
id: 'big-buck-bunny',
title: 'Big Buck Bunny',
url: '/Big.Buck.Bunny.(2008)/Big.Buck.Bunny.(2008).NoMercy.m3u8',
image: '/original/xtdybjRRZ15mCrPOvEld305myys.jpg',
},
];
</script>

<template>
<VideoPlayer :baseUrl="baseUrl" :playlist="films" />
</template>

Providing the player globally

For an app where multiple components (a mini-player, a full-screen view, a queue sidebar) need the same instance, provide it once at the root instead of re-deriving state per component. Unlike the composable above, this is safe to call at main.ts’s top level, not inside onMounted: #global-player is a static element already present in index.html (a sibling of #app, not something Vue itself renders), so it exists in the DOM before this module even runs:

TypeScript
// main.ts
import { createApp } from 'vue';
import nmplayer from '@nomercy-entertainment/nomercy-video-player';
import type { VideoPlayerConfig } from '@nomercy-entertainment/nomercy-video-player';
import App from './App.vue';

export const VideoPlayerKey = Symbol('VideoPlayer');

const config: VideoPlayerConfig = {
baseUrl: 'https://raw.githubusercontent.com/NoMercy-Entertainment/nomercy-media/master/Films',
baseImageUrl: 'https://image.tmdb.org/t/p',
muted: true,
autoPlay: false,
playlist: [
{
id: 'sintel',
title: 'Sintel',
url: '/Sintel.(2010)/Sintel.(2010).NoMercy.m3u8',
image: '/w780/q2bVM5z90tCGbmXYtq2J38T5hSX.jpg',
},
{
id: 'cosmos-laundromat',
title: 'Cosmos Laundromat',
url: '/Cosmos.Laundromat.(2015)/Cosmos.Laundromat.(2015).NoMercy.m3u8',
image: '/w780/f2wABsgj2lIR2dkDEfBZX8p4Iyk.jpg',
},
{
id: 'big-buck-bunny',
title: 'Big Buck Bunny',
url: '/Big.Buck.Bunny.(2008)/Big.Buck.Bunny.(2008).NoMercy.m3u8',
image: '/original/xtdybjRRZ15mCrPOvEld305myys.jpg',
},
],
};

const player = nmplayer('global-player').setup(config);
const app = createApp(App);
app.provide(VideoPlayerKey, player);
app.mount('#app');
TypeScript
// any component
import { inject } from 'vue';
import type { IVideoPlayer } from '@nomercy-entertainment/nomercy-video-player';
import { VideoPlayerKey } from '@/main';

const player = inject<IVideoPlayer>(VideoPlayerKey)!;

A typed Symbol key avoids the string-collision risk a bare 'player' provide key carries in a larger app. Wrap the injected instance in its own local composable (subscribing to the events that component actually cares about) rather than passing raw player state through more provide/inject layers.

Pinia store pattern

When several unrelated components need the same reactive state, not just the same instance, a Pinia store centralizes it once instead of every component re-subscribing to the same events:

TypeScript
// stores/videoPlayer.ts
import { defineStore } from 'pinia';
import nmplayer from '@nomercy-entertainment/nomercy-video-player';
import { PlayState } from '@nomercy-entertainment/nomercy-video-player';
import type { NMVideoPlayer, VideoPlayerConfig, VideoPlaylistItem } from '@nomercy-entertainment/nomercy-video-player';

export const useVideoPlayerStore = defineStore('videoPlayer', {
state: () => ({
player: null as NMVideoPlayer | null,
isPlaying: false,
currentItem: null as VideoPlaylistItem | null,
currentTime: 0,
duration: 0,
}),

getters: {
progress: (state) => (state.duration > 0 ? (state.currentTime / state.duration) * 100 : 0),
},

actions: {
// `config` is the caller's whole `VideoPlayerConfig`, not just a bare
// `baseUrl` — a store this generic shouldn't hardcode an empty playlist,
// the app that owns the actual catalogue supplies it at the one call site.
init(config: VideoPlayerConfig) {
const player = nmplayer('global-player').setup(config);

player.on('item', ({ item }) => { this.currentItem = item ?? null; });
player.on('play', () => { this.isPlaying = true; });
player.on('playing', () => { this.isPlaying = true; });
player.on('pause', () => { this.isPlaying = false; });
player.on('time', ({ time }) => { this.currentTime = time; });
player.on('duration', ({ duration }) => { this.duration = duration; });

this.player = player;
},

togglePlayback() {
void this.player?.togglePlayback();
},

dispose() {
void this.player?.dispose();
this.player = null;
this.$reset();
},
},
});

Call init() once, after Pinia is installed, and dispose() once when the app genuinely tears down the player, not on every component unmount, that would kill playback every time the user navigates away from the view that happened to call init() first:

TypeScript
// App.vue, once at startup
import { useVideoPlayerStore } from '@/stores/videoPlayer';

useVideoPlayerStore().init({
baseUrl: 'https://raw.githubusercontent.com/NoMercy-Entertainment/nomercy-media/master/Films',
baseImageUrl: 'https://image.tmdb.org/t/p',
muted: true,
autoPlay: false,
playlist: [
{
id: 'sintel',
title: 'Sintel',
url: '/Sintel.(2010)/Sintel.(2010).NoMercy.m3u8',
image: '/w780/q2bVM5z90tCGbmXYtq2J38T5hSX.jpg',
},
{
id: 'cosmos-laundromat',
title: 'Cosmos Laundromat',
url: '/Cosmos.Laundromat.(2015)/Cosmos.Laundromat.(2015).NoMercy.m3u8',
image: '/w780/f2wABsgj2lIR2dkDEfBZX8p4Iyk.jpg',
},
{
id: 'big-buck-bunny',
title: 'Big Buck Bunny',
url: '/Big.Buck.Bunny.(2008)/Big.Buck.Bunny.(2008).NoMercy.m3u8',
image: '/original/xtdybjRRZ15mCrPOvEld305myys.jpg',
},
],
});

Next steps