@glitchlab/vue-video-player
v1.6.1
Published
Vue 3 / Nuxt 3 video player with HLS support, device-mode toggle, hover-to-play, and zero global CSS side-effects.
Maintainers
Readme
@glitchlab/vue-video-player
A lightweight, HLS-capable Vue 3 video player with a polished overlay UI, device-mode toggle, hover-to-play, and zero global CSS side-effects. Ships a Nuxt 3 module for zero-config integration.
Live demo → https://video-player-playgraound.vercel.app/ — drop in a video file or paste any URL (HLS .m3u8 supported). The deployed playground uses the React build, but the same UX, props, and styles ship with this Vue package.
Why this player
- HLS streaming via
hls.jswith automatic native fallback (Safari) - YouTube support — pass a YouTube URL and it embeds automatically, no extra config
- Custom control bar (default) — a clean YouTube-style layout: full-width seek bar, playback controls on the left, utilities on the right. Or use
"native"controls, or none. - Settings menu — one gear button with a two-level menu for speed, quality, subtitles and audio track
- Thumbnail seek preview — hover the seek bar to see a frame preview from a WebVTT storyboard sprite
- Chapters — named timeline segments with tick marks on the seek bar, from a WebVTT file or an inline array
- Playlist — play a list of videos in sequence with auto-advance and prev/next buttons
- Quality selector —
Auto+ per-resolution switching for multi-rendition HLS streams - Audio-track switcher — pick between multiple audio tracks (e.g. languages) when the stream provides them
- Captions —
<track>subtitles/captions, with optional cue styling (font size, colors, background) - Live stream UX — a LIVE badge for live HLS, with click-to-jump-to-edge
- Mini-player — floats to a viewport corner when scrolled out of view while playing
- Ambient mode — an optional soft, blurred color glow behind the player
- Loading & error states — a buffering spinner and a styled error overlay with a retry button
- Event emits —
play,pause,ended,time-update,seeked,volume-change,milestone,error - Persisted preferences — opt-in volume, speed and resume-position memory via
localStorage - Theming — rebrand with a
themeprop (accent color, radius) or--gvp-*CSS variables - Keyboard shortcuts + double-click to toggle fullscreen
- Accessible — focus rings, ARIA on every control, screen-reader announcements,
prefers-reduced-motion - Nuxt 3 module —
modules: ["@glitchlab/vue-video-player/nuxt"]and you're done - Scoped CSS, no preflight — all styles live under
.gvp-root. No*resets, no theme tokens leaked into your design system - Device-mode toggle — flip between desktop (16:9) and mobile (9:16) presets
- Hover-to-play with safe play/pause race handling
- Lightweight: ~3.5 KB CSS + ~19 KB JS gzipped (hls.js is a peer dependency, loaded once)
- Fully typed; SSR-safe
Installation
npm install @glitchlab/vue-video-player hls.js
# or
pnpm add @glitchlab/vue-video-player hls.js
# or
yarn add @glitchlab/vue-video-player hls.jsPeer dependencies:
vue >= 3,hls.js >= 1
Import the stylesheet once at your app entry:
import "@glitchlab/vue-video-player/style.css";Quick start
Vue 3
<script setup lang="ts">
import { VueVideoPlayer } from "@glitchlab/vue-video-player";
import "@glitchlab/vue-video-player/style.css";
</script>
<template>
<VueVideoPlayer
src="https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8"
poster="/images/poster.jpg"
/>
</template>Nuxt 3
Add the module to nuxt.config.ts:
export default defineNuxtConfig({
modules: ["@glitchlab/vue-video-player/nuxt"],
css: ["@glitchlab/vue-video-player/style.css"]
});Then use the component anywhere — no manual import required:
<template>
<VueVideoPlayer
src="https://example.com/video/playlist.m3u8"
poster="/images/poster.jpg"
/>
</template>Props
| Prop | Type | Default | Description |
|---------------------|---------------------------------------------------|-----------------------------------------|------------------------------------------------------------------------------------------|
| src | string | — | Video URL. .m3u8 is routed through HLS automatically. Optional when playlist is set. |
| poster | string | — | Poster image shown before playback starts. |
| playlist | PlaylistItem[] | — | A list of videos to play in sequence. Auto-advances on end; prev/next buttons appear. Takes precedence over src. See Playlist. |
| defaultIndex | number | 0 | Index of the playlist item to start on. |
| autoAdvance | boolean | true | Auto-advance to the next playlist item when one ends. |
| thumbnails | string | — | URL of a WebVTT storyboard for seek-bar thumbnail previews. See Thumbnails. |
| chapters | string \| Chapter[] | — | WebVTT chapters URL, or an inline array of { start, title }. See Chapters. |
| showDeviceToggle | boolean | true | Show the desktop/mobile toggle pill in the top-left. |
| defaultDevice | "desktop" \| "mobile" | "desktop" | Initial device mode. |
| hoverPlay | boolean | false | Start playback on mouse-enter, pause on mouse-leave. |
| tooltipText | string | — | Tooltip text shown above the play button on hover. |
| closable | boolean | false | Show a close button in the top-right; emits close when clicked. |
| class | string | "" | Extra class added to the outer container (alongside .gvp-root). |
| muted | boolean | true | Mute the video. Required for autoplay in most browsers. |
| loop | boolean | false | Loop playback. |
| controls | boolean \| "custom" \| "native" | true | true (default) / "custom" — branded control bar (play/seek/time/speed/captions/volume/PiP/fullscreen) that auto-hides during playback. "native" — native browser controls. false — no controls (just the play overlay). See Custom controls. |
| autoPlay | boolean | false | Start playback as soon as the source is ready. Works for HLS (after MANIFEST_PARSED), native MP4/WebM (after loadedmetadata), and YouTube embeds. Browsers block sound-on autoplay, so this only fires when muted is also true (the default). |
| frameMaxWidth | { desktop?: string; mobile?: string } | { desktop: "960px", mobile: "420px" } | Max width of the player in each device mode. |
| aspectRatio | { desktop?: AspectRatio; mobile?: AspectRatio } | { desktop: "16/9", mobile: "9/16" } | Aspect ratio per device mode. AspectRatio is `${number}/${number}`. |
| hlsConfig | Hls.HlsConfig | — | Optional hls.js config. Use a stable reference (e.g. shallowRef) to avoid HLS rebuilds.|
| isHls | boolean | false | Force HLS routing even when the URL doesn't end in .m3u8. |
| theme | PlayerTheme | — | { accent?, radius? } — rebrand via CSS variables. See Theming. |
| captionStyle | CaptionStyle | — | { fontSize?, textColor?, backgroundColor?, backgroundOpacity? } — style caption cues. |
| miniPlayer | boolean | false | Float to a viewport corner when scrolled out of view while playing. See Mini-player. |
| miniPlayerPosition| "bottom-right" \| "bottom-left" \| "top-right" \| "top-left" | "bottom-right" | Corner the mini-player docks to. |
| ambientMode | boolean | false | Show a soft, blurred color glow behind the player. See Ambient mode. |
| persistKey | string | — | When set, persists volume, speed and resume position to localStorage under this key. |
Events
| Event | Payload | Description |
|-------------------|-------------------------------|----------------------------------------------------------------------|
| close | — | Emitted when the close button is clicked. Requires closable=true. |
| play | — | Emitted when playback starts or resumes. |
| pause | — | Emitted when playback pauses. |
| ended | — | Emitted when the current video reaches its end. |
| time-update | (currentTime, duration) | Emitted on every timeupdate (~4×/sec); times in seconds. |
| seeked | (currentTime) | Emitted after a seek completes, with the new time in seconds. |
| volume-change | (volume, muted) | Emitted when volume or mute state changes. |
| milestone | (percent) | Emitted once when watch progress crosses 25%, 50%, 75% and 100%. |
| error | — | Emitted when the underlying media element reports an error. |
| playlist-change | (index, item) | Emitted when the active playlist item changes. |
Slots
| Slot | Description |
|-----------|------------------------------------------------------------------------------------------|
| default | Rendered inside the underlying <video>. Use for <track> elements (captions/subs). |
Custom controls
The branded control bar is on by default — it looks and behaves the same across every browser and OS, no inconsistent native chrome:
<!-- Custom control bar — this is the default, :controls="true" -->
<VueVideoPlayer src="/videos/movie.m3u8" />
<!-- Same thing, explicit -->
<VueVideoPlayer src="/videos/movie.m3u8" controls="custom" />
<!-- Native browser controls instead -->
<VueVideoPlayer src="/videos/movie.m3u8" controls="native" />
<!-- No controls at all -->
<VueVideoPlayer src="/videos/movie.m3u8" :controls="false" />The bar uses a YouTube-style layout — a full-width seek bar on top, then a button row:
- Left group — play/pause, prev/next (when a playlist is active), volume (mute toggle + slider that expands on hover), and the time / LIVE badge
- Right group — the settings gear, Picture-in-Picture, and fullscreen
The settings gear opens a two-level menu grouping playback speed, quality (resolution), subtitles and audio track. Rows for unavailable settings (e.g. no extra audio tracks) are hidden, so the menu never shows a dead option.
It auto-hides 3 seconds after the last interaction during playback and reappears on mouse move; it stays visible while paused. Single-click the video toggles play/pause; double-click toggles fullscreen.
Keyboard shortcuts
When the player has focus (controls="custom"):
| Key | Action |
|----------------|-------------------|
| Space / K | Play / pause |
| ← / → | Seek ∓ 5s |
| ↑ / ↓ | Volume ± 10% |
| M | Mute toggle |
| F | Fullscreen toggle |
| P | Picture-in-Picture |
Double-clicking the video surface also toggles fullscreen.
Platform notes
The bar degrades gracefully where a platform can't support a control:
- iPhone Safari — the volume slider is hidden (
HTMLVideoElement.volumeis read-only on iOS); the mute toggle remains. Fullscreen uses Apple's native player. - Firefox — the Picture-in-Picture button is hidden (no JS API; use Firefox's own PiP affordance).
- YouTube URLs — the custom bar does not render; YouTube's iframe can't be driven by it, so YouTube's own controls are shown instead.
Styling
Every part has a .gvp-* class hook — override what you need:
.gvp-controls { /* the bar */ }
.gvp-ctrl-btn { /* generic control button */ }
.gvp-seek-progress { background: deeppink; }
.gvp-volume-fill { /* volume slider fill */ }Thumbnail seek preview
Hover the seek bar to see a frame preview. Pass thumbnails a URL to a WebVTT storyboard file — the format YouTube and Vimeo use. Each cue maps a time range to a region of a sprite image:
WEBVTT
00:00:00.000 --> 00:00:05.000
storyboard.jpg#xywh=0,0,160,90
00:00:05.000 --> 00:00:10.000
storyboard.jpg#xywh=160,0,160,90<VueVideoPlayer src="/videos/movie.m3u8" thumbnails="/videos/storyboard.vtt" />The #xywh=x,y,w,h fragment crops a tile out of the sprite; relative image paths resolve against the VTT's own URL. A cue with no #xywh uses the whole image. You can generate a storyboard from a video with ffmpeg:
ffmpeg -i video.mp4 -vf "fps=1/5,scale=160:90,tile=5x100" storyboard.jpgOnly used with the custom control bar.
Chapters
Segment the timeline into named chapters. They add tick marks to the seek bar and show the chapter title in the hover preview. Pass either a WebVTT chapters URL or an inline array:
<!-- Inline -->
<VueVideoPlayer
src="/videos/tutorial.m3u8"
:chapters="[
{ start: 0, title: 'Introduction' },
{ start: 150, title: 'Getting started' },
{ start: 600, title: 'Advanced topics' },
]"
/>
<!-- From a WebVTT chapters file -->
<VueVideoPlayer src="/videos/tutorial.m3u8" chapters="/videos/chapters.vtt" />Inline items may omit end — it's filled from the next chapter's start (or the video duration). Only used with the custom control bar.
Playlist
Pass playlist an array of items to play videos in sequence. The player auto-advances when one ends and shows prev/next buttons in the control bar. Each item can carry its own poster, thumbnails and chapters:
<VueVideoPlayer
:playlist="[
{ src: '/videos/ep1.m3u8', title: 'Episode 1', thumbnails: '/videos/ep1.vtt' },
{ src: '/videos/ep2.m3u8', title: 'Episode 2' },
{ src: '/videos/ep3.m3u8', title: 'Episode 3' },
]"
:default-index="0"
auto-advance
@playlist-change="(index, item) => console.log('Now playing', item.title)"
/>playlist takes precedence over src. Set :auto-advance="false" to require a manual next click. PlaylistItem is exported for typing.
Event emits
Wire the player's playback events into your own analytics or UI without a separate timer:
<VueVideoPlayer
src="/videos/movie.m3u8"
@play="track('video_play')"
@pause="track('video_pause')"
@ended="track('video_complete')"
@time-update="(time, duration) => (progress = time / duration)"
@seeked="(time) => track('video_seek', { time })"
@volume-change="(volume, muted) => console.log(volume, muted)"
@milestone="(percent) => track(`watched_${percent}`)"
@error="showRetry()"
/>milestone fires exactly once each as watch progress crosses 25%, 50%, 75% and 100% — handy for completion analytics. Milestone tracking resets per source (including playlist advances).
Theming
Rebrand the player without overriding component classes. Pass a theme prop — it maps to CSS custom properties on the player root:
<VueVideoPlayer
src="/videos/movie.m3u8"
:theme="{ accent: '#e11d48', radius: '0.5rem' }"
/>| Field | Maps to | Description |
|----------|--------------------|------------------------------------------------------|
| accent | --gvp-accent | Seek progress, play button, active states. Any CSS color. |
| radius | --gvp-radius | Corner radius of the player frame. |
For deeper control, set the --gvp-* variables (or override .gvp-* classes) directly in your own CSS:
.gvp-root { --gvp-accent-rgb: 225 29 72; --gvp-radius: 0.5rem; }Mini-player
Set miniPlayer and the player floats to a corner of the viewport when it scrolls out of view while playing — so viewers keep watching as they read the rest of the page. It returns when scrolled back; a close button dismisses it.
<VueVideoPlayer
src="/videos/movie.m3u8"
miniPlayer
miniPlayerPosition="bottom-right"
/>Ambient mode
ambientMode adds a soft, blurred color glow behind the player that samples the current video frame (YouTube's "ambient mode"). Give the player some surrounding padding so the glow is visible.
<VueVideoPlayer src="/videos/movie.m3u8" ambientMode />Live streams
Live HLS streams are detected automatically (via hls.js's live flag). The control bar shows a LIVE badge instead of a duration — red and pulsing at the live edge, dimmed when behind. Clicking the badge jumps back to the live edge. No prop needed.
Persisted preferences
Pass a persistKey and the player remembers volume, mute, playback speed and the resume position across reloads and revisits, via localStorage:
<VueVideoPlayer src="/videos/course-1.m3u8" persistKey="my-course" />Resume position is tracked per source URL, so one key can resume many videos independently. Omit persistKey to disable persistence entirely.
Caption styling
Style subtitle/caption cue text with captionStyle — applied via the ::cue pseudo-element:
<VueVideoPlayer
src="/videos/talk.m3u8"
:caption-style="{
fontSize: '1.4em',
textColor: '#fde047',
backgroundColor: '#000000',
backgroundOpacity: 0.75,
}"
>
<track kind="subtitles" src="/subs/en.vtt" srclang="en" label="English" default />
</VueVideoPlayer>YouTube URLs
Pass any common YouTube URL as src and the player swaps the <video> element for a privacy-enhanced (youtube-nocookie.com) embed inside the same styled frame — no extra prop needed:
<VueVideoPlayer src="https://www.youtube.com/watch?v=dQw4w9WgXcQ" />
<VueVideoPlayer src="https://youtu.be/dQw4w9WgXcQ?t=90" :auto-play="true" />
<VueVideoPlayer src="https://www.youtube.com/shorts/dQw4w9WgXcQ" />Recognised forms: youtube.com/watch?v=ID, youtu.be/ID, youtube.com/embed/ID, youtube.com/shorts/ID, youtube.com/live/ID, music.youtube.com/watch?v=ID, and bare 11-character IDs. A ?t= / ?start= timestamp in the URL is honored.
Which props work over YouTube
| Prop | YouTube behavior |
|---------------------------------------|---------------------------------------------------------------------------------------------------|
| muted | ✅ Mutes the embed (mute=1). |
| loop | ✅ Loops the single video (loop=1 + playlist=<id>, YouTube's required workaround). |
| controls | ✅ Shows/hides YouTube's controls (controls=1 / controls=0). |
| autoPlay | ✅ Autoplays (autoplay=1). YouTube + browsers force muted autoplay, so mute=1 is set too — even if :muted="false". |
| showDeviceToggle / defaultDevice | ✅ The desktop/mobile aspect-ratio toggle still works. |
| closable / @close | ✅ The close button still renders and emits. |
| class / frameMaxWidth / aspectRatio | ✅ Frame styling, sizing, and aspect ratio all apply. |
| hoverPlay | ❌ No effect. Hover-to-play needs programmatic pause, which requires the YouTube IFrame Player API (not loaded). YouTube's own controls handle starting playback. |
| tooltipText | ❌ No effect. The tooltip is attached to the centered play-button overlay, which isn't rendered for YouTube. |
| poster | ❌ No effect. YouTube shows its own video thumbnail; a custom poster would require an overlay layer. |
| default slot (<track> captions) | ❌ No effect. There's no <video> element to attach <track> to — use YouTube's own caption settings. |
| hlsConfig / isHls | ❌ No effect. Not an HLS stream. |
If you need
hoverPlay, a custom poster, or a play-button overlay over a YouTube video, you'd need the YouTube IFrame Player API integrated — that's not in this build (it'd add a ~30 KB external script). Open an issue if it matters for your use case.
Examples
Looping background video, no UI chrome
<VueVideoPlayer
src="/videos/hero.m3u8"
:muted="true"
:loop="true"
:auto-play="true"
:show-device-toggle="false"
/>Autoplay an HLS stream
<VueVideoPlayer
src="https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8"
:auto-play="true"
/>
autoPlaywaits for the manifest to parse (HLS) or forloadedmetadata(native), then calls.play()for you. Browsers reject sound-on autoplay without a prior user gesture —muted(the default) is the reliable path.
Hover-to-play with a tooltip
<VueVideoPlayer
src="/videos/demo.mp4"
poster="/images/thumb.jpg"
:hover-play="true"
tooltip-text="Watch the demo"
/>Dismissible player in a modal
<script setup lang="ts">
import { ref } from "vue";
import { VueVideoPlayer } from "@glitchlab/vue-video-player";
const open = ref(true);
</script>
<template>
<VueVideoPlayer
v-if="open"
src="/videos/walkthrough.m3u8"
:closable="true"
:show-device-toggle="false"
@close="open = false"
/>
</template>Custom aspect ratio and frame width
<VueVideoPlayer
src="/videos/portrait.mp4"
default-device="mobile"
:aspect-ratio="{ desktop: '4/3', mobile: '9/16' }"
:frame-max-width="{ desktop: '720px', mobile: '360px' }"
/>Captions and subtitles
<VueVideoPlayer src="/videos/talk.m3u8" :controls="true">
<track kind="captions" src="/captions/talk.en.vtt" srclang="en" label="English" default />
<track kind="subtitles" src="/captions/talk.es.vtt" srclang="es" label="Spanish" />
</VueVideoPlayer>Custom hls.js configuration
<script setup lang="ts">
import { shallowRef } from "vue";
import { VueVideoPlayer } from "@glitchlab/vue-video-player";
const hlsConfig = shallowRef({
enableWorker: true,
lowLatencyMode: true,
maxBufferLength: 30
});
</script>
<template>
<VueVideoPlayer src="/videos/live.m3u8" :hls-config="hlsConfig" />
</template>Use
shallowRef(or any stable reference). A new object identity each render tears down and rebuilds the entire HLS instance.
TypeScript
Full type definitions ship with the package. Re-exported types:
import type {
VideoPlayerProps,
HLSPlayerProps,
DeviceMode,
AspectRatio
} from "@glitchlab/vue-video-player";Styling and customization
All styles are scoped under .gvp-root. The CSS file (~3 KB minified) contains no global resets and no design-token bleed. Override what you need:
.gvp-root {
border-radius: 8px;
}
.gvp-play {
background-color: rebeccapurple;
}
.gvp-toggle-btn.is-active {
color: deeppink;
}Available class hooks:
| Class | Element |
|-----------------------|-------------------------------------------------|
| .gvp-root | Outer container |
| .gvp-video | Underlying <video> element |
| .gvp-vignette | Top vignette overlay |
| .gvp-bottom-fade | Bottom gradient |
| .gvp-toggle | Device-toggle wrapper |
| .gvp-toggle-pill | The pill background |
| .gvp-toggle-btn | Individual toggle button (.is-active modifier)|
| .gvp-toggle-divider | Vertical divider between toggle buttons |
| .gvp-close | Top-right close button |
| .gvp-play-wrap | Center play-button container |
| .gvp-play | Play button |
| .gvp-tooltip | Tooltip above play button |
| .gvp-icon | All inline SVG icons |
SSR
The component is SSR-safe. Server output renders the static frame; HLS attaches client-side once the video element mounts. Works with Nuxt 3, Vite-SSR, and any vanilla SSR setup.
Browser support
- Chromium ≥ 88, Firefox ≥ 78, Safari ≥ 14, Edge ≥ 88
- Native HLS playback on Safari (no
hls.jscost) - Worker-based HLS on browsers with MSE
Contributing
git clone https://github.com/im-fahad/vue-video-player.git
cd vue-video-player
pnpm install
pnpm test
pnpm buildIssues and PRs welcome at https://github.com/im-fahad/vue-video-player/issues.
License
MIT © im-fahad
