DEV Community

Mason K
Mason K

Posted on

Add casting to a custom HTML5 video player without breaking it

TL;DR

We're adding a cast button to a custom <video> player using the Remote Playback API, with an AirPlay fallback for Safari. Then we fix the four bugs that make casting work on your laptop and fail on an actual TV: token expiry, CORS on the receiver, unreachable hosts, and treating prompt() as if it were a connection.

The thing to understand before writing any code: the TV does not run your player. It fetches your stream URL and plays it with its own stack. Your overlays, your quality caps, your analytics, your token refresh logic, none of it makes the trip.

So this tutorial is half API wiring and half making your stream survive without you.

1. The baseline player 🎥

Start with a plain element and our own controls. Nothing exotic:

<!-- index.html -->
<video id="player" playsinline controls></video>

<div class="controls">
  <button id="castBtn" hidden>Cast</button>
  <span id="castState"></span>
</div>

<script type="module" src="./src/player.js"></script>
Enter fullscreen mode Exit fullscreen mode
// src/player.js
import Hls from 'hls.js'; // hls.js 1.5.x

const video = document.getElementById('player');
const MANIFEST = 'https://example.com/path/playback.m3u8';

if (video.canPlayType('application/vnd.apple.mpegurl')) {
  // Safari plays HLS natively, and this path is also what AirPlay uses.
  video.src = MANIFEST;
} else if (Hls.isSupported()) {
  const hls = new Hls();
  hls.loadSource(MANIFEST);
  hls.attachMedia(video);
}
Enter fullscreen mode Exit fullscreen mode

⚠️ Note: that branch matters later. On Safari the element has a real src, which AirPlay can fling. With hls.js, MSE is feeding the element and video.src is a blob URL that means nothing to a TV. This is the single biggest source of confusion in casting code.

2. Feature detection, three ways

There's no one API. We check for each path and pick:

// src/cast-support.js
export function detectCastSupport(video) {
  return {
    // Standards-based path. Chromium-leaning; MDN lists it as limited availability.
    remotePlayback: 'remote' in video && typeof video.remote?.watchAvailability === 'function',

    // Safari's own AirPlay interface, predates the spec above.
    airplay: typeof window.WebKitPlaybackTargetAvailabilityEvent !== 'undefined',

    // Cast SDK, only if you've loaded the sender script and written a receiver app.
    castSdk: typeof window.cast !== 'undefined' && typeof window.chrome?.cast !== 'undefined',
  };
}
Enter fullscreen mode Exit fullscreen mode
// quick check in the console
detectCastSupport(document.getElementById('player'))
// Chrome:  { remotePlayback: true,  airplay: false, castSdk: false }
// Safari:  { remotePlayback: false, airplay: true,  castSdk: false }
// Firefox: { remotePlayback: false, airplay: false, castSdk: false }
Enter fullscreen mode Exit fullscreen mode

3. Wiring the Remote Playback API

Two calls do most of the work. watchAvailability tells us whether to show the button at all, and prompt opens the browser's own device picker.

// src/remote-playback.js
export function initRemotePlayback(video, btn, onState) {
  if (!('remote' in video)) return;

  // Show the button only when a device is actually reachable.
  video.remote
    .watchAvailability((available) => {
      btn.hidden = !available;
    })
    .catch(() => {
      // Some browsers can't watch continuously. Show the button and let
      // prompt() fail loudly instead of hiding the feature entirely.
      btn.hidden = false;
    });

  btn.addEventListener('click', async () => {
    try {
      await video.remote.prompt();
    } catch (err) {
      // AbortError = user dismissed the picker. Not an error worth surfacing.
      if (err.name !== 'AbortError') {
        console.error('cast failed', err);
        onState('error', err);
      }
    }
  });

  // These are the source of truth, NOT the click handler.
  video.remote.addEventListener('connecting', () => onState('connecting'));
  video.remote.addEventListener('connect', () => onState('connected'));
  video.remote.addEventListener('disconnect', () => onState('disconnected'));
}
Enter fullscreen mode Exit fullscreen mode

The comment on those last three lines is the whole lesson of this tutorial, so let's make it explicit.

4. The state machine (this is the bug you'll ship) ⚠️

The naive version sets isCasting = true when the click handler resolves. That's wrong, because remote playback is asynchronous and can fail after it succeeds. The device drops off wifi. Someone's phone steals the TV. The receiver crashes.

Derive your UI from the connection state the browser reports:

// src/player.js (continued)
import { initRemotePlayback } from './remote-playback.js';

const btn = document.getElementById('castBtn');
const label = document.getElementById('castState');

let castState = 'disconnected';

function render() {
  label.textContent = {
    disconnected: '',
    connecting: 'Connecting to device...',
    connected: 'Playing on TV',
    error: 'Could not connect',
  }[castState];

  // Local controls are meaningless while the TV owns playback.
  document.querySelectorAll('.local-only').forEach((el) => {
    el.toggleAttribute('disabled', castState === 'connected');
  });
}

initRemotePlayback(video, btn, (state) => {
  castState = state;
  render();
});

// Belt and braces: video.remote.state is readable at any time.
setInterval(() => {
  if (video.remote?.state && video.remote.state !== castState) {
    castState = video.remote.state;
    render();
  }
}, 2000);
Enter fullscreen mode Exit fullscreen mode

💡 Tip: once connected, treat the remote as the source of truth for playback position and paused state. You now have two states that can disagree, and the one on the television is the one your user is looking at.

5. AirPlay, Safari's way 🍎

Safari doesn't implement the API above. It has its own event and its own picker:

// src/airplay.js
export function initAirPlay(video, btn) {
  if (typeof window.WebKitPlaybackTargetAvailabilityEvent === 'undefined') return;

  video.addEventListener('webkitplaybacktargetavailabilitychanged', (e) => {
    btn.hidden = e.availability !== 'available';
  });

  btn.addEventListener('click', () => {
    video.webkitShowPlaybackTargetPicker();
  });

  video.addEventListener('webkitcurrentplaybacktargetiswirelesschanged', () => {
    console.log('airplay active:', video.webkitCurrentPlaybackTargetIsWireless);
  });
}
Enter fullscreen mode Exit fullscreen mode

You can also let Safari handle it entirely with an attribute, which is what most sites do:

<video id="player" x-webkit-airplay="allow" playsinline controls></video>
Enter fullscreen mode Exit fullscreen mode

6. Now fix the four bugs that only happen on real hardware

Everything above works on your laptop. Here's what breaks on an actual TV.

Token expiry on the wrong clock

You mint a short-lived signed playback token because that's correct security practice. The TV picks the stream up thirty seconds later and holds it for ninety minutes, then 403s on a manifest refresh. Works in your tab, dies on the device.

// src/playback-url.js
// Cast sessions outlive browser sessions. Mint a token scoped to the session type.
export async function getPlaybackUrl({ forCast = false } = {}) {
  const res = await fetch('/api/playback-token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ ttlSeconds: forCast ? 60 * 60 * 4 : 60 * 15 }),
  });
  const { url } = await res.json();
  return url;
}
Enter fullscreen mode Exit fullscreen mode

Swap the element's source before prompting:

btn.addEventListener('click', async () => {
  video.src = await getPlaybackUrl({ forCast: true });
  await video.remote.prompt();
});
Enter fullscreen mode Exit fullscreen mode

⚠️ A longer TTL is a real security tradeoff, not a free fix. Scope the long-lived token to a single playback ID and a single device session, and keep the short TTL for normal browser playback.

CORS from an origin you forgot exists

The receiver is not your page. It's a different origin fetching your manifests and segments:

# verify from outside your app's origin
curl -I -H "Origin: https://not-your-site.example" \
  https://stream.example.com/abc123.m3u8
Enter fullscreen mode Exit fullscreen mode
HTTP/2 200
access-control-allow-origin: *
access-control-allow-headers: range
Enter fullscreen mode Exit fullscreen mode

If access-control-allow-origin is missing or pinned to your domain only, the TV gets blocked while your browser is fine.

Anything the TV can't resolve

Localhost, staging behind a VPN, an internal hostname. Your laptop resolves it, the TV on the guest network doesn't. Catch it in code instead of losing an afternoon:

// src/preflight.js
export function assertCastableUrl(url) {
  const u = new URL(url, location.href);
  const problems = [];
  if (u.protocol !== 'https:') problems.push('not https');
  if (['localhost', '127.0.0.1', '[::1]'].includes(u.hostname)) problems.push('localhost is not reachable from a TV');
  if (u.hostname.endsWith('.local') || u.hostname.endsWith('.internal')) problems.push('internal hostname');
  if (u.href.startsWith('blob:')) problems.push('blob URL from MSE, the TV cannot fetch this; use the real manifest URL');
  if (problems.length) console.warn(`[cast preflight] ${u.href}: ${problems.join(', ')}`);
  return problems.length === 0;
}
Enter fullscreen mode Exit fullscreen mode

That last check is the hls.js trap from section 1. If you're feeding the element through MSE, video.src is a blob and there is nothing for the TV to fetch. Keep the real manifest URL around and assign it before casting.

The receiver's ABR isn't yours

Your bitrate cap for metered connections lived in hls.js config. The TV has never heard of it and will pull your top rendition. If that matters for cost or for a plan tier, the only lever you have left is the manifest itself: serve a cast-specific variant playlist with the renditions you're willing to send.

7. When to just turn it off

There's a property for this, and using it is a legitimate decision:

// DRM the receiver can't honour, overlay-critical content, or
// ad insertion that the cast path would silently skip.
video.disableRemotePlayback = true;
Enter fullscreen mode Exit fullscreen mode

A disabled cast button with one line of copy explaining why beats a button that connects to a TV and then fails in a way the user blames on their wifi. "We support casting" is a claim with a testing burden attached.

Wrapping up

The test that predicts all of this: put your manifest URL in a bare <video> tag on a blank page, with none of your JavaScript anywhere, walk away for an hour, and see whether it's still playing. If it is, casting is nearly free, and so are smart TV apps and embedded webviews later. If it isn't, fix that first, because the cast button is only the first surface where it shows.

Two things worth reading next: MDN's Remote Playback API page has the current compatibility table, which is the part of this that changes; and if you need real control over what the TV renders, the Google Cast SDK's receiver application is the only path that gives it to you, at the cost of maintaining a second app.

What's your cast failure rate? If you're not measuring it separately from normal playback failures, it's probably higher than you think, because these bugs are invisible from the browser. #webdev #video

Top comments (0)