Video Player

API reference

Components, props, template-ref methods, and the full state-change event list.

Components

import { VideoPlayer, VideoStage, VideoCard, VideoPlaceholder, VideoPlayerPlugin, getPlatform } from '@munsonlabs/video-player'
import '@munsonlabs/video-player/style'
ExportDescription
VideoPlayerThe player itself - one video, full controls, always mounted immediately
VideoCardLazy-mountable single video (shows a poster/placeholder until it enters view or is clicked)
VideoStageSticky, full-width playlist stage that receives videos from VideoCard via window events
VideoPlaceholderPoster/loading placeholder used internally by VideoCard
VideoPlayerPluginVue plugin - app.use(VideoPlayerPlugin) registers all components globally
getPlatform(url)Returns 'youtube' | 'vimeo' | 'dailymotion' | 'jwplayer' | 'brightcove' | 'html5' for a built-in URL, or whatever key a registerPlatform() call registered
registerPlatform()Teaches the player a new platform without forking - see below
HideMarkerSlotless sentinel - tucks a pinned VideoStage away to a sliver while it's in the viewport, see below

A custom-element build is also available from @munsonlabs/video-player/element, exposing <ml-video-card> (and ml-video-player/ml-video-stage/ml-video-placeholder/ml-hide-marker, plus the headless controls below as ml-controls-play-button etc.) for non-Vue hosts — the ml- prefix namespaces the tags to avoid colliding with other libraries' custom elements, and ml-controls-* further separates the headless controls from the core player tags.

If you only need one half, two smaller bundles are also available: @munsonlabs/video-player/element/core (just the four player/stage/item/placeholder tags) and @munsonlabs/video-player/element/controls (just the thirteen ml-controls-* tags). Don't import more than one of /element, /element/core, /element/controls on the same page — they'd double-register any tag they share and throw.

/element and /element/core dynamically import() a platform's adapter code (YouTube/Vimeo/Dailymotion/JW Player/Brightcove) only once a URL for that platform actually mounts, from a sibling chunks/ directory - a page that only plays plain MP4/HLS/DASH downloads none of it. A directory-serving CDN (unpkg/jsdelivr/esm.sh) needs no special handling; self-hosting either bundle means deploying its chunks/ directory alongside it.

How a URL becomes a playing embed

What happens between setting src and an embed (YouTube/Vimeo/Dailymotion/a custom platform) actually appearing on screen, end to end:

  1. Platform detection. On mount, usePlayer calls resolvePlatform(src), which checks the URL against every registered matcher (the six built-ins plus anything added via registerPlatform) and returns a platform key plus whether it's embed or a plain source.
  2. Adapter construction. That result goes to mountAdapter, which either resolves a source URL and builds a native <video>/hls.js/dash.js adapter, or - for embeds - looks up the platform's registered factory (e.g. createYoutubeAdapter) and calls it.
  3. Hidden mount. The embed factory calls createEmbedMount, which inserts a wrapper <div> next to the <video> element and starts it at opacity: 0; pointer-events: none. The <video> (showing its poster) stays the visible thing for now - this is what masks the embed SDK's own iframe loading chrome (spinners, its own poster flash) until the SDK is actually ready. The adapter's el is this wrapper, not the <video> itself (unlike the native path, where el is the <video>).
  4. SDK boot + event translation. The factory loads the platform's SDK (or reuses it if another instance already has), builds the player inside the wrapper, and translates the SDK's own events into the common set every adapter emits (play, pause, timeupdate, durationchange, etc.).
  5. Wiring. Back in usePlayer, finalizeAdapter stores the adapter and calls attachPlayerEvents, which subscribes all the reactive player state (isPlaying, current, supportsCaptions, ...) to those translated events.
  6. Reveal. Once the adapter actually starts playing for the first time (hasStarted flips true), a watch swaps visibility: the <video> is hidden and the wrapper fades to opacity: 1 with pointer events restored - this is the moment the embed becomes the visible thing.
  7. Teardown. On unmount, the adapter's dispose() removes the wrapper, restores the <video> element's normal display, and detaches the platform's own event listeners.

The native (non-embed) path skips steps 3 and 6 entirely - the <video> element is the adapter's el from the start, so there's nothing separate to reveal.

Adding a custom platform

registerPlatform teaches the player about a platform beyond the built-in six (YouTube, Vimeo, Dailymotion, Brightcove, JW Player, plain HTML5/HLS/DASH), without forking the package:

import { registerPlatform } from '@munsonlabs/video-player'

registerPlatform({
  key: 'acme',
  test: (url) => url.includes('acme.tv'),
  embed: true, // true: the platform has its own SDK/iframe; false: it resolves to a plain playable file
  createAdapter: (videoEl, options) => createAcmeAdapter(videoEl, options),
})

Call it once, before mounting any player that might see the new URL - resolution reads the registry fresh every time (no caching), so ordering is the only thing that matters.

  • embed: true - the platform owns its own player (YouTube, Twitch, Vimeo). createAdapter is a factory (videoEl, options) => PlaybackAdapter returning every method the player needs (play/pause/currentTime/captions/quality/PiP/fullscreen/on/off/dispose), translating your SDK's own events into the ones the player listens for (play, pause, ended, timeupdate, durationchange, etc.).
  • embed: false - the platform just hosts a file/manifest behind an opaque URL or ID (JW Player, Brightcove). resolveSource (optional - skip it if the URL is already a direct, playable link) returns { src, type?, poster?, adTagUrl? }; the native <video>/hls.js/dash.js path handles playback for you.

registerPlatform is registerMatcher + registerEmbedAdapter/registerSourceResolver in one call - each is exported separately too, for the rare case you only need one.

From a web component

registerPlatform is exported from /element and /element/core too:

<script type="module">
  import { registerPlatform } from 'https://esm.sh/@munsonlabs/video-player/element/core'
  registerPlatform({ key: 'acme', test: (url) => url.includes('acme.tv'), embed: true, createAdapter: createAcmeAdapter })

  const player = document.createElement('ml-video-player')
  player.setAttribute('src', 'https://acme.tv/watch/123')
  document.body.append(player)
</script>

One thing that doesn't come up in the Vue case - timing: importing /element//element/core calls customElements.define(), which synchronously upgrades any matching tag already in the DOM as part of that call, and a <script type="module"> defers until after the whole document has been parsed - so a statically-declared <ml-video-player> tag is already sitting in the DOM by the time your script runs, and gets upgraded (running usePlayer's adapter resolution) as soon as the import above evaluates, before your own registerPlatform call on the next line has a chance to run. Creating the tag from script, after registering, as above sidesteps this entirely.

If your tags are statically declared in the markup (so you can't create them after registering), load /element/core with a ?defer query instead: it skips the automatic customElements.define() on import, so you register your platform first and then call the exported defineElements() yourself - the static tags upgrade at that point, with your platform already in the registry:

<script type="module">
  import { registerPlatform, defineElements } from 'https://esm.sh/@munsonlabs/video-player/element/core?defer'
  registerPlatform({ key: 'acme', test: (url) => url.includes('acme.tv'), embed: true, createAdapter: createAcmeAdapter })
  defineElements()
</script>

<ml-video-player src="https://acme.tv/watch/123"></ml-video-player>

?defer is read from the /element/core module's own URL, so import /element/core directly with the query (or point an import-map entry at a URL that includes it) - the combined /element bundle always registers immediately, since it imports the core module internally without the query. defineElements() is idempotent: it only defines whichever tags aren't already registered.

Props (VideoCard / VideoPlayer)

PropTypeDefaultDescription
srcstring-Required. The video URL or platform-specific URI - platform is auto-detected, see Getting started
titlestring''Video title
posterstring''Poster image URL
aspectRatiostring'16:9'e.g. '16:9', '9:16', '4:3'
autoplaybooleanfalseAutoplay on mount (starts muted - browsers block unmuted autoplay without a gesture)
mutedboolean-Force a starting mute state - if unset, follows the shared persisted audio preference
volumenumber-Force a starting volume (0-1) - if unset, follows the shared persisted audio preference
lazybooleantrue*Show placeholder until clicked
nativeUibooleanfalseUse the platform's native controls (YouTube, Vimeo, Dailymotion only)
autoStagebooleanfalseImmediately send this video to the stage on mount
playbackRatenumber1Initial playback rate
adTagUrlstring''VAST or VMAP ad tag URL
adMacroParamsobject-Fills {macro} tokens in whichever ad tag URL ends up in use
headerBiddingobject-Runs a Prebid.js auction before the ad plays: { adUnit, params?, timeoutMs? }
tracksarray-WebVTT caption/subtitle tracks: { src, kind?, srclang?, label?, default? }[]
payloadobject{}Arbitrary data attached to every state-change event
actionPlayerActionnullButton shown in the player HUD - a built-in ('mute' | 'loop' | 'autoplay') or { icon, label, onClick }
disableTapCapturebooleanfalseDisables the full-video tap-to-reveal-controls overlay
controlsbooleantrueSet false to render a bare <video> with no HUD at all - drive playback via the template-ref methods instead
playInViewbooleanfalseAuto-play once at least half the player is visible, and auto-pause once it isn't - for a scroll-snap feed. Implies muted unless muted is set explicitly
pinPinCorner-Pins the player to that screen corner ('bottom-right' | 'bottom-left' | 'top-right' | 'top-left') once it scrolls out of view while playing, instead of auto-pausing. Omit to disable

* Only VideoCard implements the lazy placeholder. VideoPlayer accepts the prop for type compatibility but always mounts immediately regardless of its value. playInView works with the default lazy placeholder too - VideoPlaceholder watches its own visibility and mounts the real player once it crosses the same threshold, so you don't need lazy="false" to use both together. If several playInView players are visible at once, a shared debounce lets only the last one to cross the threshold actually play.

Template-ref methods (VideoPlayer)

A template ref on VideoPlayer exposes the imperative API:

<script setup>
import { ref } from 'vue'
const player = ref(null)
</script>

<template>
  <VideoPlayer ref="player" src="..." />
  <button @click="player.retry()">Retry</button>
</template>
MethodDescription
togglePlay()Play/pause
seek(percent)Seek to a percentage (0-100) of the video's duration - for an absolute time, pass (seconds / total) * 100
toggleMute()Toggle mute
setVolume(level)Set volume, 0-1
toggleFullscreen()Enter/exit fullscreen
toggleLoop()Toggle loop - fires loopchange
setPlaybackRate(rate)Change playback speed - fires ratechange
setCaptionTrack(index)Select a caption track by index, or null to turn captions off - fires captionchange
setQuality(index)Select a quality level by index, or null for Auto - fires qualitychange
togglePip()Enter/exit Picture-in-Picture - fires pipchange
retry()Re-attempts loading the current source after an error

Template-ref methods (VideoCard and VideoStage)

VideoCard and VideoStage both forward the same controls/state as VideoPlayer above (everything except the internal fire/fmt helpers), so a template ref on either works the same way:

<script setup>
import { ref } from 'vue'
const item = ref(null)
</script>

<template>
  <VideoCard ref="item" src="..." lazy />
  <button @click="item.togglePlay()">Play</button>
</template>

The forwarding differs slightly by how each component owns (or doesn't own) the underlying player:

  • VideoCard may still be showing its lazy placeholder when the ref is used - calling any control method mounts the real player first if needed. When a VideoStage is present on the page, VideoCard hands playback off to it entirely instead: togglePlay() selects/toggles this video on the stage, and every other method is a no-op since the stage owns the actual player.
  • VideoStage additionally exposes playNext(), playPrevious(), hasNext, and hasPrevious when a playlist prop is set. Unlike VideoCard, it can't auto-mount a player without a video already selected (via a playlist entry or onVideoSelect) - calling a control method before that is a no-op.

Because this forwarding is wired up via defineExpose, the same methods/state are available identically whether you're holding a Vue template ref or a DOM reference to the ml-video-card/ml-video-stage/ml-video-player custom-element form:

document.querySelector('ml-video-card').togglePlay()

Hiding the pinned stage over content

When VideoStage is pinned to a corner (any pin other than 'full-width'), HideMarker lets you keep it from covering a specific section of the page - a footer, a signup form, a comments block. Place it just before that content: the stage slides mostly off-screen (leaving a small sliver) for as long as the marker is in the viewport, and slides back once it isn't.

<VideoStage pin="bottom-right" :playlist="videos" />

<!-- ...page content... -->

<HideMarker />
<Footer />

There's no prop linking HideMarker to a specific stage - there's only ever one VideoStage on a page, so it always applies to whichever one is mounted. It renders a single, unstyled, full-width <div> with min-height: 1px and no slot for content, so it triggers briefly as the page scrolls past that exact line rather than staying active for as long as some section below it remains visible - place it precisely where you want the stage to tuck away. The sliver width is a CSS custom property: --mlv-stage-tuck (default 32px).

useForwardedPlayer

Builds your own wrapper component the same way - a curated forward of a template-ref'd VideoPlayer's controls/state for your own defineExpose, so a consumer of your wrapper gets the exact same API as a direct VideoPlayer/VideoCard/VideoStage ref (or custom-element reference):

<script setup>
import { VideoPlayer, useForwardedPlayer } from '@munsonlabs/video-player'
const { playerRef, forwarded } = useForwardedPlayer()
defineExpose(forwarded)
</script>

<template>
  <VideoPlayer ref="playerRef" src="..." />
</template>

useForwardedPlayer owns the ref itself - bind playerRef on the wrapped VideoPlayer and spread (or pass) forwarded into your own defineExpose. Pass a guard callback to intercept a method call before it reaches the underlying player - return false to swallow it, or run a side effect (e.g. mounting a lazy placeholder first) before returning true.

exposePlayerOnElement

Copies a plain Vue <VideoPlayer>/<VideoCard>/<VideoStage>'s exposed API directly onto a real DOM element, so code with no access to your Vue app's internals - a third party's own script, a <ml-controls-*> custom element from a completely separate bundle - can read/call it the same way it would on a genuine custom element:

<script setup>
import { ref, onMounted } from 'vue'
import { VideoPlayer, exposePlayerOnElement } from '@munsonlabs/video-player'
const playerRef = ref(null)
const wrapperEl = ref(null)
onMounted(() => exposePlayerOnElement(wrapperEl.value, playerRef.value))
</script>

<template>
  <div id="my-player" ref="wrapperEl">
    <VideoPlayer ref="playerRef" src="..." />
  </div>
</template>
<!-- a completely separate script/bundle, e.g. a third party's own -->
<script type="module">
  import '@munsonlabs/video-player/element/controls'
</script>
<ml-controls-transcript for="my-player" cues='[{"time":0,"text":"..."}]'></ml-controls-transcript>
  • State fields are copied as live getters, not snapshotted values - el.current/el.isPlaying etc. keep reflecting the real player for as long as it's mounted, the same as reading them off a template ref would. Methods are copied directly (they already close over the real player internally, so there's nothing to keep "live" there).
  • el doesn't need to be the player's own root element - a wrapper <div> (as above), or any other element entirely, works exactly the same; exposePlayerOnElement only cares about the element you hand it.
  • This has nothing to do with the /element* bundles or custom elements on the player side - you're still rendering a plain Vue <VideoPlayer>. It's purely about making that player's state reachable from outside your Vue app, for whoever needs to reach it.

Headless controls

Unstyled control primitives for building a custom HUD - each is a single component, published both as a Vue component and as a ml- custom element from the exact same source (no duplicated logic), the same way VideoPlayer itself is.

As Vue components they're part of the main @munsonlabs/video-player import - nothing extra to install. As custom elements they're part of @munsonlabs/video-player/element (or the smaller @munsonlabs/video-player/element/controls, if you don't need the core player tags too) - see Custom-element usage below.

import {
  PlayButton, MuteButton, FullscreenButton, LoopButton, PipButton,
  CaptionsButton, QualityButton, PlaybackRateButton, Buffering, Scrubber,
  VolumeSlider, TimeDisplay, Transcript,
} from '@munsonlabs/video-player'
ComponentCustom elementDescription
PlayButton<ml-controls-play-button>Toggles play/pause
MuteButton<ml-controls-mute-button>Toggles mute
FullscreenButton<ml-controls-fullscreen-button>Toggles fullscreen
LoopButton<ml-controls-loop-button>Toggles loop
PipButton<ml-controls-pip-button>Toggles Picture-in-Picture - only renders when supportsPip
CaptionsButton<ml-controls-captions-button>Cycles Off → track 1 → ... → Off - only renders when supportsCaptions
QualityButton<ml-controls-quality-button>Cycles Auto → highest → ... → lowest → Auto - only renders when supportsQuality
PlaybackRateButton<ml-controls-playback-rate-button>Cycles through [0.5, 0.75, 1, 1.25, 1.5, 2]
Buffering<ml-controls-buffering>Renders its content only while isBuffering
Scrubber<ml-controls-scrubber>Drag-to-seek <input type="range"> - pauses on drag start, shows a live time preview, commits and resumes on release
VolumeSlider<ml-controls-volume-slider>Drag-to-set-volume <input type="range">, with a percentage readout
TimeDisplay<ml-controls-time-display>Shows current / total (formatted), or a "Live" badge when isLive. The default non-live render wraps the current time in .mlv-time-display__time and the total in .mlv-time-display__total for independent styling via :where() selectors
Transcript<ml-controls-transcript>Clickable transcript from a cues array ({ time, end?, text }[], or an inline JSON string attribute) - clicking a cue seeks to its timestamp (starting playback if paused), the cue at the playhead is highlighted (aria-current) and kept scrolled into view, and a scoped slot ({ cue, index, isActive, formatTime }) customises each cue's markup. Works on every platform, embeds included - it only needs the current time and seek

Every control takes a player prop pointing at the same object VideoPlayer/VideoCard/VideoStage expose via a template ref (or, for a raw custom element, the element itself - see below).

Vue usage

<script setup>
import { ref } from 'vue'
import { VideoPlayer, PlayButton, MuteButton, Scrubber } from '@munsonlabs/video-player'
const player = ref(null)
</script>

<template>
  <VideoPlayer ref="player" src="..." :controls="false" />
  <PlayButton :player="player" />
  <MuteButton :player="player" />
  <Scrubber :player="player" />
</template>

Or, <label for>-style, skip the ref and point at an element id instead - useful when the control isn't a sibling of the player, or you'd rather not thread a ref through:

<VideoPlayer id="my-player" src="..." :controls="false" />
<PlayButton for="my-player" />

player and for are both optional - if both are given, player wins. Internally these look up document.getElementById(...) (this package never uses shadow DOM, so a plain id lookup is always correct, not just a shortcut).

Custom-element (non-Vue) usage

<script type="module">
  import '@munsonlabs/video-player/style'
  import '@munsonlabs/video-player/element'
</script>

<ml-video-player id="player" src="..."></ml-video-player>

<ml-controls-play-button for="player"></ml-controls-play-button>
<ml-controls-mute-button for="player"></ml-controls-mute-button>
<ml-controls-scrubber for="player" style="flex: 1"></ml-controls-scrubber>

for="player" is the zero-JS path - no <script> needed for the wiring itself. The equivalent JS-property form (el.player = document.getElementById('player')) is only necessary when the two elements can't share a stable id (e.g. the player is created dynamically), since player is an object reference, not a string, so it can't be written as a plain HTML attribute the way for can.

Setting controls="false" on <ml-video-player> still has to go through a property too (playerEl.controls = false), for the same reason - it's a boolean prop, and HTML attributes are strings-only.

Styling

Every control's own CSS uses :where() (zero specificity), so a single class you add always wins over the built-in default, regardless of source order.

In Vue, a class fallthroughs onto the control's own root element like any single-root component:

<PlayButton :player="player" class="my-play-btn" />

As a raw custom element, this does not work the same way. A custom element is a real, persistent DOM node that Vue renders a child element into - class="..." set on <ml-play-button> stays on that outer tag; it never reaches the <button> Vue renders inside it. Target the inner element with a descendant selector instead:

ml-controls-play-button button { color: red; }
/* or, using the built-in class: */
ml-controls-play-button .mlv-play-button { color: red; }

CSS entry points

ImportPairs withCovers
@munsonlabs/video-player/style@munsonlabs/video-player/element (and plain Vue usage)Everything - core player + all thirteen headless controls
@munsonlabs/video-player/style/core@munsonlabs/video-player/element/coreJust VideoPlayer/VideoStage/VideoCard/VideoPlaceholder's own styles
@munsonlabs/video-player/style/controls@munsonlabs/video-player/element/controlsJust the thirteen headless controls' own styles

Only the combined /element bundle auto-injects /style for you - /element/core and /element/controls don't inject anything, so import the matching stylesheet yourself alongside whichever half you use. If you're building a fully custom HUD with the headless controls and never render the built-in one, /style/controls alone is enough - no need to ship the core player's CSS you're not using.

Themeable CSS variables

Override these from your own stylesheet to adjust layout without forking the player:

VariableDefaultUsed byDescription
--mlv-controls-widthmin(450px, calc(100% - 32px))Controls popupWidth of the expanded controls panel - widen it for a larger HUD, or narrow it for a tighter fit
--mlv-popup-aligncenterControls popupHorizontal alignment of the popup (center, left, right, or any CSS alignment value)
--mlv-stage-tuck32pxPinned stage / playerWidth of the sliver left visible when HideMarker tucks the pinned box off-screen
--mlv-radius12pxPinned stage / playerBorder-radius of the corner-pinned box
--mlv-accentControls "More" menuActive-state highlight color for the loop and autoplay toggle buttons

Example - widening the controls popup:

.mlv-controls { --mlv-controls-width: min(600px, calc(100% - 32px)); }

Scoped slots (Vue only)

Each button exposes its state via a scoped slot for full custom markup, e.g. PlayButton's #default="{ isPlaying }". This only works from a Vue template - a scoped slot is a render-function callback, which has no HTML/DOM equivalent. Static children placed inside a custom element tag (e.g. <ml-buffering><div class="my-spinner"></div></ml-buffering>) still work as a non-scoped default slot, but a raw custom-element consumer wanting markup that reactively changes with state has to do it themselves, listening for state-change and toggling their own elements/classes.

Events

state-change

Emitted by VideoCard, VideoPlayer, and VideoStage.

interface StateChangeEvent {
  type:
    | 'play' | 'pause' | 'ended' | 'seeked' | 'error'
    | 'adstart' | 'adend'
    | 'volumechange' | 'ratechange'
    | 'captionchange' | 'qualitychange' | 'pipchange' | 'loopchange'
    | 'firstQuartile' | 'midpoint' | 'thirdQuartile'
    | 'controlsopen' | 'controlsclose'
    | 'stageopen' | 'stageclose'
    | 'bufferstart' | 'bufferend'
    | 'timeupdate'
    | 'tap'
  currentTime: number
  duration: number
  src: string
  error?: { code: number; message: string } | null
  isMuted?: boolean // volumechange only
  playbackRate?: number // ratechange only
  captionIndex?: number | null // captionchange only; null means captions are off
  qualityIndex?: number | null // qualitychange only; null means Auto
  isPipActive?: boolean // pipchange only
  isLooping?: boolean // loopchange only
  element?: HTMLElement | null // controlsopen/controlsclose only
  payload?: Record<string, unknown>
}
TypeNotes
firstQuartile / midpoint / thirdQuartileFire once each as playback crosses 25%, 50%, 75% of duration. Useful for IAB-style progress analytics. Reset and can fire again on loop restart.
controlsopen / controlscloseFire when the expanded controls popup mounts/unmounts, with element set to its root DOM node. On controlsclose, element is a snapshot - it's detached from the DOM immediately after.
stageopen / stagecloseFire from VideoStage only, when the stage mounts its inner player (the first video arrives) and when it tears it down again. Arrive on the same state-change stream rather than as a separate event.
captionchange / qualitychangeFire on every setCaptionTrack()/setQuality() call and whenever the adapter's own ABR switches the active track/level, but never before playback has started.
pipchangeFires on entering/exiting Picture-in-Picture, whether triggered by the player's own button or the user closing the browser's floating PiP window directly.
loopchangeFires on every toggleLoop() call, never before playback has started.
bufferstart / bufferendbufferstart fires once the player has been stalled on waiting longer than the buffering-spinner delay; bufferend fires once playing/canplay resolves it (or on ended). Useful for tracking rebuffer count/ratio.
timeupdateFires at most every 250ms during playback, once it's started. A Vue consumer gets live current/total for free through the template-ref API's reactivity - this event exists for non-Vue consumers (e.g. a raw <ml-video-player> element with no surrounding Vue app) who need a signal to know position changed, e.g. to drive a custom seekbar.
tapFires from the tap-to-reveal-controls overlay, regardless of controls. The only signal a controls="false" consumer gets that the user tapped the video - useful for showing/hiding your own HUD in response.

On error, the player shows a built-in "Retry" button, or call retry() yourself via a template ref (see above).

Ads (IMA / VAST / VMAP)

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. Ads are not supported on YouTube, Vimeo, or Dailymotion. See the package README.md for the full step-by-step setup, adMacroParams macro-filling, and header-bidding config shape.

Ad overlay UI

While an ad is playing, a small overlay shows an "Ad" badge with a countdown to the ad's end, a dedicated pause/resume button, and a mute button that controls the ad creative's own independent audio (separate from the content video's volume/mute state: muting the player has no effect on ad audio, and vice versa).

Ad visibility behaviour

An ad pauses itself whenever the tab is hidden or the window loses focus (checked on visibilitychange/blur, and once up front when the ad starts, so a mid-roll beginning on an already-backgrounded tab is caught too): no manual setup needed, and it doesn't auto-resume, since the point is to stop burning an impression on nobody watching. If Picture-in-Picture is active when an ad starts, it's exited too (skipped on iOS, where the ad creative renders directly into the same <video> element PiP is already mirroring, so there's nothing to lose by staying in PiP there).

That same iOS behaviour - the ad creative playing through the content's own <video> element rather than a separate one - means the browser fires real timeupdate/durationchange/progress events for the ad's own timeline while it plays. The player ignores all three while an ad is active, so current/total/bufferedDisplay (and anything built on them, like a Scrubber or Transcript) stay on the content's position throughout the ad instead of briefly reflecting the ad's, and resume tracking automatically the moment it ends.

A second iOS-specific issue: after a post-roll ad finishes, the IMA SDK may not restore the original content source, leaving the <video> element pointing at the ad creative. Clicking "Replay" would then replay the ad instead of the content. The player tracks whether content had already ended when the ad started (postRollPending), and on CONTENT_RESUME_REQUESTED restores the saved original source and seeks to the end to clear the browser's internal "ended" flag, letting the normal ended/replay flow work correctly.

Copyright © 2026