HLS on the Web - How WVCams Works
18/08/2025

Most DOT cameras publish HLS (HTTP Live Streaming): a small playlist that points at a rolling set of segments. On the web, we want three things: it plays everywhere, it starts fast, and it stays smooth. Here’s how we do that on WVCams—and what to watch out for.
The browser reality (Safari vs. everyone else)
- Safari (iOS/macOS): has native HLS in
<video>
. Just setsrc
to the.m3u8
. - Chrome/Firefox/Edge: use MSE (Media Source Extensions) via hls.js to parse HLS and feed media to the video element.
Minimal, robust player (Preact)
This mirrors what we ship: native first, hls.js fallback, autoplay-muted, playsInline
, and cleanup on unmount.
// components/HlsPlayer.jsx
import { useEffect, useRef } from 'preact/hooks';
import Hls from 'hls.js';
export default function HlsPlayer({ src, poster }) {
const ref = useRef(null);
useEffect(() => {
const video = ref.current;
if (!video || !src) return;
// Native HLS (Safari/iOS)
const canNative = video.canPlayType('application/vnd.apple.mpegurl');
let hls;
if (canNative) {
video.src = src;
} else if (Hls.isSupported()) {
hls = new Hls({
// Tweak if your origin is slow or segments are short
maxLiveSyncPlaybackRate: 1.5,
liveSyncDuration: 4, // seconds behind the live edge
});
hls.loadSource(src);
hls.attachMedia(video);
} else {
// Very old browsers—show a link fallback
video.outerHTML = `<p>Your browser can't play this stream. <a href="${src}">Open stream</a></p>`;
return;
}
// Autoplay policies: muted + playsInline
video.muted = true;
video.playsInline = true;
video.play().catch(() => { /* ignore blocked autoplay */ });
return () => {
if (hls) {
hls.destroy();
} else {
video.removeAttribute('src');
video.load();
}
};
}, [src]);
return (
<video
ref={ref}
controls
muted
playsInline
poster={poster}
preload="metadata"
style="width:100%; border-radius:8px"
title="Live traffic camera"
/>
);
}
Caching that actually helps (and doesn’t stall)
HLS has two file types with very different caching needs:
- Segments (
.ts
/.m4s
): immutable (unique filenames).
Cache-Control:public, max-age=31536000, immutable
- Playlists (
.m3u8
): mutable, tiny, frequent.
Cache-Control:public, max-age=3, stale-while-revalidate=15
This keeps the live edge fresh while letting CDNs cache the heavy media.
CORS & MIME types (fix 90% of random playback fails)
Make sure the stream origin (or your proxy) sends matching headers on both playlists and segments:
Access-Control-Allow-Origin: https://wvcams.com
Access-Control-Allow-Headers: Range, Origin, Content-Type, Accept
Content-Type (playlist): application/vnd.apple.mpegurl OR application/x-mpegURL
Content-Type (segments): video/MP2T OR video/mp4
If you proxy DOT streams, set Vary: Origin
and whitelist your domain. Avoid *
on sensitive origins.
Low latency vs. stability (LL-HLS or not?)
LL-HLS can cut delay to ~2–5s, but requires tighter CDN config and more edge requests.
- Start simple: 6s segments with 3–4 target durations in the rolling window → ~12–24s latency and stable playback.
- If you try LL-HLS:
- Use 2s segments (or partial segments/chunked CMAF).
- Ensure CDN supports chunked transfer and doesn’t coalesce small requests.
- hls.js supports LL-HLS; Safari’s native LL-HLS can behave differently—test both.
- Watch CDN cost: playlists refresh more often.
Player polish that users feel
- Preconnect to the stream host to speed TLS/DNS:
<link rel="preconnect" href="https://stream.example.gov" crossorigin>
- Poster image to reduce LCP while the player initializes.
- Error surface: show a small inline message if the stream stalls and offer “Open in new tab” as a fallback.
- Accessibility: give
<video>
atitle
/aria-label
with the cam name.
Common HLS quirks (and quick fixes)
- Stream “jumps back” a few seconds → encoder reset
EXT-X-MEDIA-SEQUENCE
. hls.js usually recovers; a reload fixes persistent loops. - Black frames on first play → playlist cached too long. Lower
max-age
on.m3u8
. - Cross-origin errors → wrong
Content-Type
or missingAccess-Control-Allow-Origin
on either playlist or segments. - Infinite loading, no errors → Service Worker or overly aggressive CDN cache. Bypass with a
?nocache=
param to confirm. - Autoplay blocked → ensure
muted playsinline
and callvideo.play()
after attaching the source.
CSP for media (if you lock things down)
If you use a Content Security Policy, include:
media-src https://wvcams.com https://*.your-cdn.com https://*.wvdot.example.gov blob:;
connect-src https://*.your-cdn.com https://*.wvdot.example.gov;
blob:
is needed for MSE when hls.js pushes segments.
Our checklist (what we deploy)
- Native Safari playback with
<video src="...m3u8">
- hls.js fallback everywhere else
- Autoplay muted +
playsInline
- CDN caching: segments long-lived; playlists ~3s with
stale-while-revalidate
- Correct MIME + CORS on both playlists and segments
- Poster + preconnect for faster start
- One-sentence description near each cam for SEO, deeper tech notes in posts like this