react-vast-player
v1.0.1
Published
Mode-agnostic React video engine with first-class VAST support. No plugins.
Maintainers
Readme
react-vast-player
A mode-agnostic React video engine with first-class VAST ad support. Build streaming players, playlists, and short-form feeds — all with preroll, midroll, and postroll advertising baked in.
Features
- Three player modes — Streaming, Playlist, and Feed (shorts-style)
- Full VAST 2–4 support — preroll, midroll, postroll, skippable ads, quartile tracking, discriminated error codes (301/303/401/402/403/900)
- VMAP schedules — single URL → preroll + midrolls + postroll, mutually exclusive with the individual
*VastUrlprops - HLS streams — auto-detected
.m3u8URLs play via native HLS on Safari and via optionalhls.json Chrome / Firefox / Edge onAdErrorcallback + graceful fallback — any VAST failure auto-resumes content within 500 ms; the consumer is notified viaonAdError({ reason, vastErrorCode })- Three levels of abstraction — drop-in components, headless hooks, or raw core primitives
- TypeScript-first — strict types throughout with full type exports
- React 19+ compatible
- Tree-shakeable — ESM + CJS dual output, zero side effects
Installation
npm install react-vast-player
# or
yarn add react-vast-player
# or
pnpm add react-vast-playerRequirements: React >= 19.0.0
Optional peer dependencies
# Only required if you serve HLS (.m3u8) content. MP4 works out of the box.
npm install hls.jsIf hls.js is missing when an .m3u8 URL is played, the player throws
react-vast-player: HLS playback requires hls.js. Install it: \npm i hls.js``.
Quick Start
Streaming Player
Single video with pre/mid/postroll ads.
import { StreamingPlayer } from 'react-vast-player'
export function MyPlayer() {
return (
<StreamingPlayer
src="https://example.com/video.mp4"
poster="https://example.com/poster.jpg"
prerollVastUrl="https://ads.example.com/vast.xml"
midrollVastUrls={[{ time: 30, url: 'https://ads.example.com/mid.xml' }]}
postrollVastUrl="https://ads.example.com/post.xml"
autoplay
muted
style={{ width: '100%', height: 600 }}
onPlay={(state, config) => console.log('playing', state)}
onAdError={({ reason, vastErrorCode }) =>
console.warn('ad failed:', vastErrorCode, reason)
}
/>
)
}With VMAP (single schedule URL)
<StreamingPlayer
src="https://cdn.example.com/episode.m3u8"
vmapUrl="https://ads.example.com/vmap.xml"
onAdError={({ vastErrorCode, reason }) => track('ad_error', { vastErrorCode, reason })}
/>When vmapUrl is set, the individual prerollVastUrl / midrollVastUrls /
postrollVastUrl props are ignored. VMAP fetch failure auto-resumes
content-only playback.
Playlist Player
Queue of videos, each with its own preroll ad.
import { PlaylistPlayer } from 'react-vast-player'
const queue = [
{ id: '1', src: 'ep1.mp4', title: 'Episode 1', prerollVastUrl: 'vast.xml' },
{ id: '2', src: 'ep2.mp4', title: 'Episode 2', prerollVastUrl: 'vast.xml' },
]
export function MyPlaylist() {
return (
<PlaylistPlayer
queue={queue}
autoAdvance
autoplay
muted
style={{ width: '100%', height: 600 }}
renderItem={(item, index, total) => (
<div style={{ position: 'absolute', top: 16, left: 16, color: '#fff' }}>
{item.title} — {index + 1} / {total}
</div>
)}
/>
)
}Feed Player
Vertical swipe feed (TikTok / Reels style) with interleaved VAST ads.
import { FeedPlayer } from 'react-vast-player'
const items = [
{ id: '1', src: 'clip1.mp4', title: 'Clip A' },
{ id: '2', src: 'clip2.mp4', title: 'Clip B' },
{ id: '3', src: 'clip3.mp4', title: 'Clip C' },
]
export function MyFeed() {
return (
<FeedPlayer
items={items}
vastUrls={['https://ads.example.com/vast.xml']}
adInterval={2}
autoplay
muted
style={{ width: '100%', height: '100vh' }}
renderItem={(slot) =>
slot.type === 'content' ? (
<div style={{ position: 'absolute', bottom: 60, left: 16, color: '#fff' }}>
{slot.item.title}
</div>
) : null
}
/>
)
}Controls & UX
All player modes with a control bar (StreamingPlayer, PlaylistPlayer) ship these UX features out of the box.
Keyboard shortcuts
Click (or tab to) the player to focus it, then:
| Key | Action |
|-----|--------|
| Space | Play / Pause |
| ← | Seek back 5 s |
| → | Seek forward 5 s |
| M | Toggle mute |
| F | Toggle fullscreen |
Shortcuts are disabled during ad playback and ignored when focus is on a form input.
Volume slider
On desktop (hover-capable devices), hovering over the mute icon expands an 80 px volume slider. Volume level persists across page reloads via localStorage (rvp:volume).
Playback rate
A rate selector (0.5× → 2×) appears in the control bar. The rate automatically resets to 1× when an ad starts and restores the user's choice when content resumes.
Midroll position markers
When midrollVastUrls is set, small amber dots appear on the seek bar at the scheduled timestamps.
Controls auto-hide
Controls fade out after 3 seconds of cursor inactivity during playback (matching YouTube's behaviour). Any mouse movement, touch, or keypress immediately reveals them. The cursor is also hidden when controls are hidden.
Picture-in-Picture
<StreamingPlayer pip src="…" />
<PlaylistPlayer pip queue={[…]} />Adds a PiP button to the control bar. The button reflects live PiP state (entered / exited) via the native browser enterpictureinpicture / leavepictureinpicture events.
Ad click-through
When a VAST ad includes a <ClickThrough> URL, the AD badge (top-left of the player) becomes clickable. Clicking it:
- Opens the advertiser's URL in a new tab (
noopener,noreferrer) - Fires all
<ClickTracking>beacons - Emits
ad:clickon the event bus - Keeps the ad playing in the background
API Reference
Abstraction Layers
The library is intentionally layered. Pick the level that matches how much control you need.
| Layer | Exports | When to use |
|-------|---------|-------------|
| Drop-in components | StreamingPlayer, PlaylistPlayer, FeedPlayer | Fully styled, works out of the box |
| Headless hooks | useStreamingPlayer, usePlaylist, useFeed | Custom UI, same logic |
| Core primitives | PlayerEngine, AdOrchestrator, EventBus, … | Advanced / non-React use cases |
Sub-entry points
import { PlayerEngine } from 'react-vast-player/core'
import { StreamingPlayer, useStreamingPlayer } from 'react-vast-player/streaming'
import { FeedPlayer, useFeed } from 'react-vast-player/feed'
import { PlaylistPlayer, usePlaylist } from 'react-vast-player/playlist'Drop-in Components
<StreamingPlayer>
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| src | string | — | Video URL (required) |
| poster | string | — | Poster image URL |
| prerollVastUrl | string | — | VAST URL to play before content |
| midrollVastUrls | { time: number; url: string }[] | — | VAST URLs at specific timestamps (seconds) |
| postrollVastUrl | string | — | VAST URL to play after content ends |
| vmapUrl | string | — | VMAP schedule URL. Overrides the three *VastUrl props above when set |
| autoplay | boolean | false | Autoplay on mount |
| muted | boolean | false | Start muted |
| loop | boolean | false | Loop content |
| className | string | — | Root element class |
| style | CSSProperties | — | Root element style |
| renderControls | (api) => ReactNode | — | Override the default control bar |
| onPlay | (state, config) => void | — | Fires when playback starts |
| onPause | (state, config) => void | — | Fires when playback pauses |
| onStop | (state, config) => void | — | Fires when playback stops |
| onSeek | (time, state, config) => void | — | Fires on seek |
| onStateChange | (state, config) => void | — | Fires on any state transition |
| onAdError | ({ reason, vastErrorCode }) => void | — | Fires on any VAST/VMAP failure. Content auto-resumes within 500 ms |
| pip | boolean | false | Enable Picture-in-Picture button in the control bar |
<PlaylistPlayer>
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| queue | QueueItem[] | — | Ordered list of videos (required) |
| autoAdvance | boolean | false | Move to next item when current ends |
| autoplay | boolean | false | Autoplay on mount |
| muted | boolean | false | Start muted |
| midrollVastUrls | { time: number; url: string }[] | — | Midrolls applied to every item |
| vmapUrl | string | — | VMAP schedule applied to every item. Overrides per-item prerollVastUrl and queue-level midrollVastUrls when set |
| pip | boolean | false | Enable Picture-in-Picture button in the control bar |
| className | string | — | |
| style | CSSProperties | — | |
| renderItem | (item, index, total) => ReactNode | — | Custom overlay per item |
| onPlay / onPause / onStateChange … | same as StreamingPlayer | | |
QueueItem
interface QueueItem {
id: string
src: string
poster?: string
title?: string
duration?: number
prerollVastUrl?: string
}<FeedPlayer>
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| items | ContentItem[] | — | Feed content (required) |
| vastUrls | string[] | — | Pool of VAST URLs to cycle through |
| adInterval | number | — | Insert an ad every N content items |
| autoplay | boolean | false | |
| muted | boolean | false | |
| className | string | — | |
| style | CSSProperties | — | |
| renderItem | (slot: FeedSlot) => ReactNode | — | Custom overlay. slot.type is 'content' or 'ad' |
| onPlay / onPause / onStateChange … | same as StreamingPlayer | | |
ContentItem
interface ContentItem {
id: string
src: string
poster?: string
title?: string
duration?: number
}Headless Hooks
Use these when you need full control over the UI while keeping all the VAST and playback logic.
useStreamingPlayer(config)
import { useStreamingPlayer, VideoSurface, ControlBar, AdOverlay } from 'react-vast-player'
function CustomPlayer() {
const api = useStreamingPlayer({
src: 'https://example.com/video.mp4',
prerollVastUrl: 'https://ads.example.com/vast.xml',
})
return (
<div ref={api.containerRef} style={{ position: 'relative', width: '100%', height: 600 }}>
<VideoSurface ref={api.videoRef} />
{api.adState.active && (
<AdOverlay adState={api.adState} onSkip={api.skipAd} />
)}
<ControlBar
state={api.controls.state}
onPlay={api.controls.play}
onPause={api.controls.pause}
onSeek={api.controls.seek}
onToggleMute={api.controls.toggleMute}
onToggleFullscreen={api.controls.toggleFullscreen}
disabled={api.adState.active}
/>
</div>
)
}Returns: UseStreamingPlayerResult
| Field | Type | Description |
|-------|------|-------------|
| videoRef | RefObject<HTMLVideoElement> | Attach to <VideoSurface> |
| engineRef | RefObject<PlayerEngine> | Access the raw engine |
| playerState | PlayerState | Current player state string |
| adState | AdState | Current ad state |
| controls | UseControlsResult | Play/pause/seek/volume/fullscreen |
| skipAd | () => void | Skip the active ad |
| containerRef | RefObject<HTMLDivElement> | Attach to wrapper for fullscreen |
usePlaylist(config)
const api = usePlaylist({ queue, autoAdvance: true, autoplay: true })
// Navigate
api.next()
api.prev()
api.goTo(2)
// Current item info
api.currentItem // QueueItem | undefined
api.currentIndex // number
api.totalItems // numberReturns: UsePlaylistResult — all fields from useStreamingPlayer, plus:
| Field | Type | Description |
|-------|------|-------------|
| currentItem | QueueItem \| undefined | Currently playing item |
| currentIndex | number | Zero-based index |
| totalItems | number | Queue length |
| next | () => void | Advance to next item |
| prev | () => void | Go to previous item |
| goTo | (index: number) => void | Jump to index |
useFeed(config)
const api = useFeed({ items, vastUrls: ['vast.xml'], adInterval: 3 })
// Swipe navigation
api.swipeNext()
api.swipePrev()
api.goTo(4)
// Current slot
api.currentSlot // FeedSlot | undefined — { type: 'content', item } | { type: 'ad', vastUrl }
api.currentIndex // position in the full schedule (content + ad slots)
api.totalSlots // total slots in the scheduleReturns: UseFeedResult — all fields from useStreamingPlayer, plus:
| Field | Type | Description |
|-------|------|-------------|
| currentSlot | FeedSlot \| undefined | Content or ad slot |
| currentIndex | number | Position in full schedule |
| totalSlots | number | Total slots |
| swipeNext | () => void | Go to next slot |
| swipePrev | () => void | Go to previous slot |
| goTo | (index: number) => void | Jump to slot index |
useControls(engineRef)
Low-level hook for playback controls, used internally by all higher-level hooks.
interface UseControlsResult {
state: ControlsState
containerRef: RefObject<HTMLDivElement>
play: () => void
pause: () => void
seek: (t: number) => void
setVolume: (v: number) => void
toggleMute: () => void
toggleFullscreen: () => void
}
interface ControlsState {
playing: boolean
currentTime: number
duration: number
buffering: boolean
volume: number
muted: boolean
fullscreen: boolean
playbackRate: number // current playback speed (1 = normal)
}Shared UI Components
These are the building blocks used inside drop-in components. Import and compose them in headless setups.
<VideoSurface>
A pre-configured <video> element.
<VideoSurface
ref={videoRef}
muted
autoPlay
playsInline
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/><ControlBar>
<ControlBar
state={controls.state}
onPlay={controls.play}
onPause={controls.pause}
onSeek={controls.seek}
onToggleMute={controls.toggleMute}
onToggleFullscreen={controls.toggleFullscreen}
disabled={adState.active}
/>Features: progress bar, play/pause button, timestamp, mute toggle, fullscreen toggle.
<AdOverlay>
Renders the "AD" badge and optional skip button over the video.
<AdOverlay
adState={adState}
onSkip={skipAd}
onClickAd={() => window.open(clickThroughUrl)}
/><BufferingSpinner>
<BufferingSpinner
visible={playerState === 'buffering'}
size={48}
color="#fff"
/>Types
PlayerState
type PlayerState =
| 'idle'
| 'loading'
| 'playing'
| 'paused'
| 'ad'
| 'buffering'
| 'error'
| 'ended'AdState
interface AdState {
active: boolean // Is an ad currently playing?
skippable: boolean // Can the user skip?
skipOffset: number // Seconds until skip is available
currentTime: number // Current position within the ad
podIndex: number // Index within the current ad pod
podTotal: number // Total ads in the current pod
}PlayerEventMap
Use engineRef.current?.bus.on(event, handler) to subscribe to low-level events.
engine.bus.on('play', () => {})
engine.bus.on('pause', () => {})
engine.bus.on('timeupdate', ({ currentTime, duration }) => {})
engine.bus.on('statechange', ({ state }) => {})
engine.bus.on('ad:start', ({ adId, duration, skippable, skipOffset, podIndex, podTotal }) => {})
engine.bus.on('ad:impression', () => {})
engine.bus.on('ad:quartile', ({ quartile }) => {}) // 'first' | 'midpoint' | 'third' | 'complete'
engine.bus.on('ad:skip', () => {})
engine.bus.on('ad:ended', () => {})
engine.bus.on('ad:pod:ended', () => {})
engine.bus.on('ad:click', ({ url }) => {})
engine.bus.on('ad:error', ({ reason, vastErrorCode }) => {})
engine.bus.on('error', ({ code, message, fatal }) => {})VAST error codes
ad:error.vastErrorCode follows the IAB VAST spec. The same code is
substituted into the [ERRORCODE] macro of any <Error> URL declared
in the VAST document.
| Code | Meaning | When it fires |
|------|---------|---------------|
| 301 | Wrapper timeout / fetch failure | A VAST wrapper inside the chain failed to load |
| 303 | Wrapper depth exceeded | Wrapper chain deeper than 5 levels |
| 401 | MediaFile URI not found | Parsed VAST has <Ad> elements but no usable <MediaFile> |
| 402 | MediaFile load failure | tech.swapSrc rejected (network / decode error) |
| 403 | Unsupported codec | No supported <MediaFile> candidate in the linear ad |
| 900 | Generic / undefined | Anything else — root-document fetch failure, empty VAST, non-VastError throw, VMAP parse failure |
Core Primitives
For advanced use cases — custom frameworks, non-React environments, or building your own abstraction.
import {
PlayerEngine,
EventBus,
Tech,
AdOrchestrator,
MidrollWatcher,
AdPodManager,
AdScheduler,
loadVast,
bestMediaFile,
parseVast,
} from 'react-vast-player/core'PlayerEngine
The central orchestrator. Wraps a <video> element and exposes IPlaybackControl and IAdControl.
const engine = new PlayerEngine(videoElement)
await engine.loadContent('https://example.com/video.mp4', adSchedule)
engine.play()
engine.seek(30)
engine.skipAd()
engine.bus.on('statechange', ({ state }) => console.log(state))loadVast(url) / parseVast(xml)
import { loadVast, parseVast, bestMediaFile } from 'react-vast-player'
const adPod = await loadVast('https://ads.example.com/vast.xml')
// or
const adPod = parseVast(xmlString)
const best = bestMediaFile(adPod[0].mediaFiles)
console.log(best.url)buildFeedSchedule(items, vastUrls, adInterval)
Build a mixed content+ad schedule for the feed:
import { buildFeedSchedule, isAdSlot, isContentSlot } from 'react-vast-player'
const schedule = buildFeedSchedule(items, vastUrls, 3)
schedule.forEach(slot => {
if (isContentSlot(slot)) console.log('content:', slot.item.src)
if (isAdSlot(slot)) console.log('ad:', slot.vastUrl)
})License
MIT
