Video Player Implementation (Video.js/Plyr)
Embedded <video> tag — minimal working solution that breaks on the first non-standard requirement: custom design, subtitles, quality, view analytics, ads, DRM. For production, you need a player with an ecosystem.
Video.js vs Plyr: When to Use What
Plyr — lightweight (~28 KB gzip), beautiful out of the box, supports HTML5 video/audio, YouTube, Vimeo. Sufficient for 80% of tasks. API is simple and straightforward.
Video.js — heavy (~150 KB core + plugins), industry standard. Supports HLS, DASH, DRM (Widevine, PlayReady, FairPlay via plugins), ads (IMA SDK), analytics. Used on large media platforms.
Choice: simple beautiful player for mp4/YouTube — Plyr. Adaptive streams (HLS/DASH), live broadcasts, or monetization — Video.js.
Plyr: Basic Integration
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css">
<video id="player" playsinline controls>
<source src="/video/movie.mp4" type="video/mp4">
<source src="/video/movie.webm" type="video/webm">
<track kind="captions" label="English" srclang="en" src="/captions/en.vtt" default>
</video>
<script src="https://cdn.plyr.io/3.7.8/plyr.js"></script>
<script>
const player = new Plyr('#player', {
controls: ['play-large', 'play', 'progress', 'current-time', 'mute',
'volume', 'captions', 'settings', 'pip', 'airplay', 'fullscreen'],
settings: ['captions', 'quality', 'speed', 'loop'],
speed: { selected: 1, options: [0.5, 0.75, 1, 1.25, 1.5, 2] },
youtube: { noCookie: true, rel: 0, showinfo: 0 },
ratio: '16:9',
keyboard: { focused: true, global: false },
tooltips: { controls: true, seek: true },
captions: { active: true, language: 'en', update: true },
})
// View analytics
player.on('timeupdate', () => {
const pct = Math.floor((player.currentTime / player.duration) * 100)
if ([25, 50, 75, 90].includes(pct)) {
analytics.track('video_progress', { percent: pct, src: player.source })
}
})
</script>
Plyr with React
import Plyr from 'plyr'
import 'plyr/dist/plyr.css'
import { useEffect, useRef } from 'react'
interface VideoPlayerProps {
src: string
poster?: string
captions?: { src: string; label: string; language: string }[]
}
export function VideoPlayer({ src, poster, captions = [] }: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null)
const playerRef = useRef<Plyr>()
useEffect(() => {
if (!videoRef.current) return
playerRef.current = new Plyr(videoRef.current, {
ratio: '16:9',
controls: ['play-large', 'play', 'progress', 'current-time',
'mute', 'volume', 'settings', 'fullscreen'],
})
return () => {
playerRef.current?.destroy()
}
}, [])
return (
<video ref={videoRef} poster={poster} crossOrigin="anonymous">
<source src={src} type="video/mp4" />
{captions.map(cap => (
<track key={cap.language} kind="captions"
label={cap.label} srcLang={cap.language} src={cap.src} />
))}
</video>
)
}
Video.js: HLS Streaming
<link href="https://vjs.zencdn.net/8.10.0/video-js.css" rel="stylesheet">
<video id="my-video" class="video-js vjs-big-play-centered" controls preload="auto"
poster="/poster.jpg" data-setup='{}' style="width:100%;height:auto">
</video>
<script src="https://vjs.zencdn.net/8.10.0/video.min.js"></script>
<script>
const player = videojs('my-video', {
fluid: true,
responsive: true,
sources: [{
src: 'https://stream.example.com/hls/video.m3u8',
type: 'application/x-mpegURL',
}],
html5: {
vhs: {
overrideNative: true, // use VHS instead of native HLS
enableLowInitialPlaylist: true,
smoothQualityChange: true,
limitRenditionByPlayerDimensions: true,
},
},
controlBar: {
children: [
'playToggle',
'volumePanel',
'currentTimeDisplay',
'timeDivider',
'durationDisplay',
'progressControl',
'liveDisplay',
'remainingTimeDisplay',
'customControlSpacer',
'playbackRateMenuButton',
'chaptersButton',
'descriptionsButton',
'subsCapsButton',
'qualitySelector', // requires videojs-contrib-quality-levels plugin
'fullscreenToggle',
],
},
})
// Error logging
player.on('error', () => {
const error = player.error()
console.error('Video error:', error.code, error.message)
Sentry.captureException(new Error(`Video error ${error.code}: ${error.message}`))
})
</script>
Quality Switching in Video.js
npm install @videojs/http-streaming videojs-contrib-quality-levels videojs-hls-quality-selector
import videojs from 'video.js'
import 'videojs-contrib-quality-levels'
import 'videojs-hls-quality-selector'
const player = videojs('player')
player.hlsQualitySelector({ displayCurrentQuality: true })
// Programmatic switching
const qualityLevels = player.qualityLevels()
qualityLevels.on('addqualitylevel', (event) => {
const level = event.qualityLevel
console.log(`Quality: ${level.height}p, bitrate: ${level.bitrate}`)
})
Lazy Player Loading
Initializing Video.js on 10 players simultaneously — kills page performance. Use IntersectionObserver:
const observers = new Map<Element, IntersectionObserver>()
function lazyInitPlayer(container: HTMLElement) {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const video = entry.target.querySelector('video')!
videojs(video, { fluid: true })
observer.disconnect()
observers.delete(container)
}
})
}, { threshold: 0.1, rootMargin: '200px' })
observer.observe(container)
observers.set(container, observer)
}
document.querySelectorAll('.video-lazy').forEach(el => lazyInitPlayer(el as HTMLElement))
Custom Poster with Hover Preview
// Generate preview sprite (storyboard)
// On server: ffmpeg -i input.mp4 -vf "fps=1/10,scale=160:-1,tile=10x10" sprites.jpg
// Client code:
function setupThumbnailPreview(player: any) {
const progressBar = player.controlBar.progressControl.seekBar.el()
const thumb = document.createElement('div')
thumb.className = 'vjs-thumbnail'
progressBar.appendChild(thumb)
progressBar.addEventListener('mousemove', (e: MouseEvent) => {
const rect = progressBar.getBoundingClientRect()
const pct = (e.clientX - rect.left) / rect.width
const time = pct * player.duration()
const frame = Math.floor(time / 10) // 1 frame every 10 sec
const col = frame % 10
const row = Math.floor(frame / 10)
thumb.style.backgroundImage = 'url(/sprites.jpg)'
thumb.style.backgroundPosition = `-${col * 160}px -${row * 90}px`
thumb.style.left = `${e.clientX - rect.left - 80}px`
thumb.style.display = 'block'
})
}
Embedding YouTube via Plyr
const player = new Plyr('#youtube-player', {
debug: false,
})
// HTML: <div id="youtube-player" data-plyr-provider="youtube" data-plyr-embed-id="dQw4w9WgXcQ"></div>
// Get current time from YouTube iframe
player.on('timeupdate', ({ detail: { plyr } }) => {
console.log(plyr.currentTime)
})
Performance and Core Web Vitals
Video player — one of the main culprits of poor LCP/CLS. Recipes:
<!-- Reserve space for player via aspect-ratio, prevent CLS -->
<div style="aspect-ratio: 16/9; background: #000;">
<video ...></video>
</div>
<!-- Poster image for fast LCP -->
<video poster="/poster-small.webp" preload="none">
// Don't load player JS until interaction
const loadPlayer = async () => {
const { default: Plyr } = await import('plyr')
await import('plyr/dist/plyr.css')
return new Plyr('#player')
}
document.getElementById('play-btn')?.addEventListener('click', async () => {
const player = await loadPlayer()
player.play()
}, { once: true })
Timeline
Plyr with basic config and brand styling — 1 day. Video.js with HLS, quality switching, subtitles, and analytics — 3–4 days. DRM integration (Widevine + license server) — separate project for 1–2 weeks.







