Skip to content

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; ended advances 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 loop flag stays disabled so it cannot prevent ended.
  • 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 ​

vue
<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>
ts
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:

ts
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.