## TL;DR We're adding a cast button to a custom 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. The baseline player 🎥
⚠️ 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. Feature detection, three ways
There's no one API. We check for each path and pick: 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.
The comment on those last three lines is the whole lesson of this tutorial, so let's make it explicit. 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.
💡 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. AirPlay, Safari's way 🍎
Safari doesn't implement the API above. It has its own event and its own picker:
You can also let Safari handle it entirely with an attribute, which is what most sites do: Now fix the four bugs that only happen on real hardware
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.
⚠️ 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.
The receiver is not your page. It's a different origin fetching your manifests and segments:
If access-control-allow-origin is missing or pinned to your domain only, the TV gets blocked while your browser is fine.
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:
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.
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. When to just turn it off
