Video Player

Getting started

Install, mount a video, and read the state-change event stream.

Install

Add the package

npm install @munsonlabs/video-player

Mount a video

Import the CSS once alongside the component you use:

<script setup>
import { VideoCard } from '@munsonlabs/video-player'
import '@munsonlabs/video-player/style'
</script>

<template>
  <VideoCard
    title="Big Buck Bunny"
    src="https://cdn.jwplayer.com/videos/O5chtspP-4VHSaSK0.mp4"
    poster="https://storage.googleapis.com/gtv-videos-bucket/sample/images/BigBuckBunny.jpg"
  />
</template>

Listen for playback events

Every component emits state-change - the single event stream for everything the player does, including timeupdate (live position) and tap (the user tapped the video, even with controls="false"):

<script setup>
function onStateChange(e) {
  console.log(e.type, e.currentTime, e.src)
}
</script>

<template>
  <VideoCard src="..." @state-change="onStateChange" />
</template>

See the API reference for the full event list.

Live demo

This is @munsonlabs/video-player's actual published web component, loaded from npm at page load - not a mockup:

Loading player from npm…

The platform is auto-detected

There's no separate prop to set which platform a video is on - it's inferred from the shape of src:

PlatformURL format
YouTubehttps://www.youtube.com/watch?v=VIDEO_ID
Vimeohttps://vimeo.com/VIDEO_ID
Dailymotionhttps://www.dailymotion.com/video/VIDEO_ID
Brightcovehttps://players.brightcove.net/ACCOUNT_ID/PLAYER_ID_EMBED/index.html?videoId=VIDEO_ID
JW Playerhttps://cdn.jwplayer.com/videos/... · jwplayer://MEDIA_ID
Plain HTML5Any direct MP4 / HLS (.m3u8) / DASH (.mpd) URL

Components

ComponentUse it for
VideoCardThe common case - one video, lazy by default (shows a poster until clicked)
VideoPlayerSame player, but always mounts immediately - use when you don't want the lazy placeholder
VideoStageA sticky, full-width player that receives videos from any VideoCard on the page via window events, and minifies to a corner pip when scrolled out of view
VideoPlaceholderThe poster/loading placeholder VideoCard shows before mount - exported in case you want to reuse it
Register all four at once with app.use(VideoPlayerPlugin) instead of importing them individually.

Building a playlist stage

Pass playlist (an array of the same props each VideoCard takes) to let VideoStage track position within it and show next/previous controls:

<script setup>
import { VideoStage, VideoCard } from '@munsonlabs/video-player'
import '@munsonlabs/video-player/style'

const videos = [
  { VideoCard: 'https://...', title: 'One' },
  { VideoCard: 'https://...', title: 'Two' },
]
</script>

<template>
  <VideoStage :playlist="videos" />
  <VideoCard v-for="video in videos" :key="video.VideoCard" v-bind="video" />
</template>

Auto-advance to the next entry on ended is built in (persisted per-browser, toggled from the controls popup's "Auto" button) - there's no prop for it.

When VideoStage is pinned to a corner, it can end up covering content lower on the page - place a HideMarker just before that content and the stage tucks itself out of the way while the marker is in view. See the API reference for details.

Ads and header bidding

Pass ad-tag-url for a plain VAST/VMAP ad, or header-bidding to run a Prebid.js auction first and fall back to the plain ad tag on no-fill:

<VideoCard
  src="https://..."
  ad-tag-url="https://pubads.g.doubleclick.net/gampad/ads?..."
  :ad-tag-params="{ description_url: 'https://example.com' }"
/>

See the API reference for the full prop list, and the package README.md for the header-bidding config shape.

Auto-play in a scroll-snap feed

Set playInView to auto-play once a video is at least half visible, and auto-pause once it isn't - it works with VideoCard's default lazy placeholder too, so you don't need to disable lazy to use it. If several playInView videos are visible at once, only the last one to cross the threshold plays.

<VideoCard src="..." play-when-in-view />

Autoplay, mute & volume

Unless a video sets muted/volume explicitly, every player on the page shares one persisted audio preference (localStorage, updated whenever a viewer actually presses a mute button or drags a volume slider) - a newly-mounted or completely independent player starts at whatever level the viewer last chose anywhere else, instead of always resetting to full volume/unmuted.

Volume is never restricted - it always follows the shared preference. Mute has one hard exception layered on top: autoplay-ish playback with no user gesture behind it must start muted, full stop, regardless of the stored preference. This isn't a library choice, it's the browser enforcing its autoplay-with-sound policy - unmuted autoplay/playInView without a gesture is blocked or force-muted by the browser itself. This applies to:

  • autoplay="true" set directly on VideoCard/VideoPlayer.
  • A playInView video actually starting once scrolled into view - re-checked at that exact moment (not just once at mount, since a playInView player can sit mounted-but-paused for a long time before it actually plays), so if you unmute something else in the meantime, it plays unmuted once it becomes visible instead of forcing itself muted again.

That policy is scoped to the current page load, though, not to localStorage: once a real gesture has resulted in unmuted playback during this session - a click that happens to land unmuted (see below), or an explicit press of a mute button/volume slider - browsers generally allow further unmuted JS-triggered playback for the rest of it, so the forced-mute rule above stops applying from that point on (a fresh reload resets it - browsers don't know or care what a past session did).

A real user gesture is exempt from the policy entirely, and always follows the shared preference immediately, even before any gesture-less autoplay would've been allowed to (the gesture itself is what a browser needs, and it's happening right here) - and if that preference happens to be unmuted, this is itself one of the triggers above that lifts the forced-mute rule for the rest of the session, no explicit mute-button press required:

  • Clicking a lazy VideoCard's placeholder for the first time.
  • VideoStage's idle-click-to-resume and video-toggle window event.

VideoStage's playlist skip/auto-advance is the one exception to "follow the shared preference": playNext/playPrevious carry the outgoing video's own live mute/volume state forward to the next one, rather than the shared preference. This is the feed convention (TikTok/Reels-style), not the single-video-click convention: within one continuous playlist, audio should stick to whatever's already playing - skipping past a video you never unmuted shouldn't spontaneously turn sound on just because nothing's ever been saved globally, and skipping past one you did unmute shouldn't silently mute the next one either.

An explicit muted/volume set on a specific VideoCard/VideoPlayer (or a specific playlist VideoEntry) always wins over all of the above, in every case.

Building your own controls

Set controls="false" to render a bare player with no built-in HUD, then build your own from the headless control components (PlayButton, Scrubber, etc.) or drive it entirely via a template ref's imperative methods. useForwardedPlayer lets you forward that same API through your own wrapper component - see the API reference for both.

The built-in controls popup width defaults to min(450px, calc(100% - 32px)) and can be overridden via the --mlv-controls-width CSS variable - see the themeable CSS variables section for the full list.

Copyright © 2026