Loading temperature...
Tuesday, 21:45
← Home

HLS on the Web - How WVCams Works

18/08/2025

Photo of traffic cam on top of stoplights

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 set src 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> a title/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 missing Access-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 call video.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