Playlists
The Playground includes a Playlists example with item selection, Previous / Next, and optional whole-list repeat. It keeps one VideoPlayerRoot and replaces only its media element when selecting an item.
This is an application recipe: copy both files below into the same directory and render <PlaylistExample />. usePlaylist is local example code, not an export from videojs-vue. Queue data and URL resolution belong to the application; playback state stays in the upstream player store.
Behavior
- Items have stable IDs, titles, URLs, and optional posters. Selection starts at the beginning of an item.
- Initial selection and selection while paused stay paused. Selecting while playing preserves playback intent;
endedadvances and attempts to play the next item after metadata loads. - The last item stops by default. Repeat playlist wraps to the first item, including a one-item list. The native media
loopflag stays disabled so it cannot preventended. - A source or media failure stops on the selected item. Retry it or select another item; there is no automatic error-skipping loop.
- Browser playback rejection is shown as an error. The source is not re-fetched automatically; the user can press Play in the media controls.
- A new selection cancels the previous resolver with
AbortController. A version check also ignores late results when a resolver ignores cancellation. Each media element's callbacks carry its selection version, so old events cannot advance the current queue. - Unmount and KeepAlive deactivation cancel outstanding work and pause playback. Reactivation reloads the selected item paused. The example does not persist playback position.
Copy the example
<script setup lang="ts">
import type { VideoMediaProps, VideoPlayerHandle } from 'videojs-vue'
import { Video, VideoPlayerRoot } from 'videojs-vue/video/core'
import { DefaultVideoSkin } from 'videojs-vue/video/skins/default'
import {
computed,
onActivated,
onDeactivated,
onMounted,
reactive,
useAttrs,
useTemplateRef,
} from 'vue'
import { usePlaylist } from './usePlaylist'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
settings?: Partial<VideoMediaProps>
labels?: {
previous: string
next: string
repeat: string
loading: string
retry: string
cancel: string
}
}>(),
{
settings: () => ({}),
labels: () => ({
previous: 'Previous',
next: 'Next',
repeat: 'Repeat playlist',
loading: 'Resolving source…',
retry: 'Retry item',
cancel: 'Cancel',
}),
},
)
const attrs = useAttrs()
const player = useTemplateRef<VideoPlayerHandle>('player')
const items = [
{
id: 'mux',
title: 'Video.js demo',
src: 'https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/highest.mp4',
poster:
'https://image.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/thumbnail.jpg',
},
{
id: 'oceans',
title: 'Oceans',
src: 'https://vjs.zencdn.net/v/oceans.mp4',
},
]
const playlist = reactive(
usePlaylist({
items,
getMedia: () => player.value?.media ?? null,
// Replace this with fetch(..., { signal }) for signed URLs.
resolveSource: (item, signal) => {
signal.throwIfAborted()
return item.src
},
}),
)
// A new media element per selection isolates late events from previous items.
const sessions = computed(() => (playlist.session ? [playlist.session] : []))
const mediaSettings = computed(() => {
const {
src: _src,
poster: _poster,
autoplay: _autoplay,
loop: _loop,
...settings
} = props.settings
return settings
})
let isActive = false
async function activate() {
if (!isActive) {
isActive = true
await playlist.select(Math.max(0, playlist.index), false)
}
}
onMounted(activate)
onActivated(activate)
onDeactivated(() => {
isActive = false
playlist.cancel()
})
</script>
<template>
<div class="playlist-example">
<VideoPlayerRoot
ref="player"
v-bind="attrs"
:title="playlist.session?.item.title"
:poster="playlist.session?.item.poster"
>
<DefaultVideoSkin>
<Video
v-for="session in sessions"
:key="session.version"
v-bind="mediaSettings"
:src="session.src"
:poster="session.item.poster"
:loop="false"
:autoplay="false"
@loadedmetadata="playlist.loadedMetadata(session.version)"
@ended="playlist.ended(session.version)"
@error="playlist.mediaError(session.version)"
@playing="playlist.playing(session.version)"
/>
</DefaultVideoSkin>
</VideoPlayerRoot>
<div class="playlist-panel">
<div class="playlist-actions">
<button
type="button"
:disabled="!playlist.canPrevious"
@click="playlist.previous()"
>
{{ labels.previous }}
</button>
<button
type="button"
:disabled="!playlist.canNext"
@click="playlist.next()"
>
{{ labels.next }}
</button>
<label
><input
v-model="playlist.repeat"
type="checkbox"
/>
{{ labels.repeat }}</label
>
</div>
<ol>
<li
v-for="(item, index) in items"
:key="item.id"
>
<button
type="button"
:aria-current="playlist.index === index ? 'true' : undefined"
@click="playlist.select(index)"
>
{{ index + 1 }}. {{ item.title }}
</button>
</li>
</ol>
<p
v-if="playlist.isLoading"
role="status"
>
{{ labels.loading }}
<button
type="button"
@click="playlist.cancel()"
>
{{ labels.cancel }}
</button>
</p>
<div
v-if="playlist.failure"
role="alert"
>
<p>{{ playlist.failure }}</p>
<button
type="button"
@click="playlist.select(playlist.index, true)"
>
{{ labels.retry }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.playlist-example {
color: white;
background: #18181b;
}
video-player {
display: block;
width: 100%;
min-height: 180px;
}
.playlist-panel {
padding: 1rem;
font-size: 0.875rem;
}
.playlist-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
ol {
margin: 0.75rem 0 0;
padding: 0;
list-style: none;
}
button {
border: 1px solid #71717a;
border-radius: 0.25rem;
padding: 0.375rem 0.75rem;
color: inherit;
background: transparent;
cursor: pointer;
font: inherit;
}
li + li {
margin-top: 0.5rem;
}
li button {
width: 100%;
text-align: left;
}
button[aria-current] {
background: #3f3f46;
border-color: white;
}
button:disabled {
opacity: 0.4;
cursor: default;
}
button:focus-visible,
input:focus-visible {
outline: 2px solid #a5b4fc;
outline-offset: 3px;
}
[role='alert'] {
margin-top: 0.75rem;
color: #fda4af;
}
</style>import type { PlayerMedia } from 'videojs-vue'
import { computed, onScopeDispose, shallowRef } from 'vue'
export interface PlaylistItem {
id: string
title: string
src: string
poster?: string
}
interface PlaylistOptions {
items: readonly PlaylistItem[]
getMedia: () => PlayerMedia | null
resolveSource?: (
item: PlaylistItem,
signal: AbortSignal,
) => string | Promise<string>
}
/**
* Application-level queue recipe. The player remains the playback state owner.
* @param {PlaylistOptions} options - Queue entries, active media and optional URL resolver.
* @returns {object} Queue state and selection/event handlers for keyed media elements.
*/
export function usePlaylist(options: PlaylistOptions) {
const index = shallowRef(-1)
const repeat = shallowRef(false)
const isLoading = shallowRef(false)
const failure = shallowRef('')
const session = shallowRef<{
version: number
item: PlaylistItem
src: string
} | null>(null)
const canPrevious = computed(() => index.value > 0)
const canNext = computed(
() =>
options.items.length > 0 &&
(repeat.value || index.value < options.items.length - 1),
)
let version = 0
let request: AbortController | undefined
let shouldResume = false
let hasMetadata = false
let isDisposed = false
function cancel() {
version++
request?.abort()
request = undefined
shouldResume = false
hasMetadata = false
isLoading.value = false
options.getMedia()?.pause()
session.value = null
}
async function select(nextIndex: number, play?: boolean) {
const item = options.items[nextIndex]
if (isDisposed || !Number.isInteger(nextIndex) || !item) {
return
}
const resume =
play ?? (shouldResume || options.getMedia()?.paused === false)
cancel()
const token = version
const controller = new AbortController()
request = controller
index.value = nextIndex
shouldResume = resume
failure.value = ''
isLoading.value = true
try {
const src = options.resolveSource
? await options.resolveSource(item, controller.signal)
: item.src
if (token !== version || controller.signal.aborted) {
return
}
if (!src.trim()) {
throw new Error('The playlist source is empty.')
}
session.value = { version: token, item, src }
} catch (error) {
if (token === version && !controller.signal.aborted) {
failure.value = error instanceof Error ? error.message : String(error)
shouldResume = false
}
} finally {
if (token === version) {
isLoading.value = false
request = undefined
}
}
}
async function loadedMetadata(token: number) {
const media = options.getMedia()
if (session.value?.version !== token || hasMetadata || !media) {
return
}
hasMetadata = true
if (!shouldResume) {
return
}
shouldResume = false
try {
await media.play()
if (session.value?.version !== token) {
media.pause()
}
} catch (error) {
if (session.value?.version === token) {
failure.value = error instanceof Error ? error.message : String(error)
}
}
}
function mediaError(token: number) {
if (session.value?.version !== token) {
return
}
shouldResume = false
hasMetadata = false
failure.value =
options.getMedia()?.error?.message || 'Media playback failed.'
}
function playing(token: number) {
if (session.value?.version === token) {
failure.value = ''
}
}
async function next(play?: boolean) {
if (canNext.value) {
await select((index.value + 1) % options.items.length, play)
}
}
function ended(token: number) {
if (session.value?.version !== token || !hasMetadata) {
return
}
if (!canNext.value) {
return
}
hasMetadata = false
return next(true)
}
onScopeDispose(() => {
isDisposed = true
cancel()
})
return {
index,
repeat,
isLoading,
failure,
session,
canPrevious,
canNext,
select,
next,
ended,
loadedMetadata,
mediaError,
playing,
cancel,
previous: () => (canPrevious.value ? select(index.value - 1) : undefined),
}
}Resolve signed URLs
Replace resolveSource with an application request. Pass the provided signal to fetch and validate the response before returning a URL:
resolveSource: async (item, signal) => {
const response = await fetch(`/api/media/${encodeURIComponent(item.id)}`, {
signal,
})
if (!response.ok) {
throw new Error(`Source request failed: ${response.status}`)
}
const result: unknown = await response.json()
if (
typeof result !== 'object' ||
result === null ||
!('src' in result) ||
typeof result.src !== 'string'
) {
throw new Error('Invalid source response')
}
return result.src
}For audio, compose AudioPlayerRoot, an audio skin, and keyed Audio elements using the same queue logic. For an all-HLS queue, replace Video with HlsVideo from videojs-vue/media/hls. A media playlist here means multiple queue items; an HLS master playlist with quality variants remains one item. List changes, shuffle, persistence, mixed adapters, and automatic error skipping are application policies outside this fixed-list example.