Skip to content

4. Cast and AirPlay

Doc 2 relied on one property: whatever headers you attach to a media item, the native player replays on every request it makes — including the key request. Doc 3 removed the network but kept that property, by moving the key request to a server inside the app.

This doc removes the property itself.

When you cast, the receiver fetches the video, not the phone. You hand a TV a URL, and the TV goes to the CDN on its own. Nothing app-side crosses that boundary — not the headers, not a resource-loader delegate, nothing.

The TV can fetch the manifest. It can fetch every segment. It reaches kms.chorki.net with no referer, no vpsid, no platform — and gets a 403. The result is a receiver that loads the video perfectly and then fails to decrypt it.

This applies identically to Chromecast and to Apple TV, for different underlying reasons and with the same outcome. Apple TV decrypts AES-128 natively and happily; the encryption was never the problem. The authenticated key request is.

The fix: put the phone back in the path, for one request

Section titled “The fix: put the phone back in the path, for one request”
WITHOUT the proxy WITH the proxy
───────────────── ──────────────
Phone ──"play this CDN URL"──► TV Phone ──"play this PHONE URL"──► TV
│ │
┌──────────────┘ ┌────────────────┘
▼ ▼
CDN: manifest ✓ Phone proxy: manifest ✓
CDN: segments ✓ (rewritten so the key
KMS: key ✗ 403 points back at the phone)
│ │
▼ ┌───────┴────────┐
black screen ▼ ▼
CDN: segments ✓ Phone: key
│
▼
phone signs the
request, forwards
16 bytes to the TV

The phone runs a small LAN-visible HTTP server with three routes:

Route Serves Rewritten how
GET /m/<token> the master playlist child playlists → back through /v/, session keys → /k/
GET /v/<token> a media playlist the #EXT-X-KEY URI → /k/, segments → absolute CDN URLs
GET /k/<token> the key fetched upstream with playerHeader(), cached, returned as ContentType.binary

<token> is the upstream URL, base64url-encoded so it survives as a single path segment. Decoding rejects anything that is not a plausible http(s) URL, so the proxy cannot be turned into an open relay to arbitrary hosts.

The tempting design is to proxy everything — it is simpler and it fixes a Content-Type the CDN gets wrong for .ts files. That is what this code used to do, and it was a mistake:

The key is 16 bytes fetched once per title. The segments are gigabytes. Only the key is worth the phone being in the path.

Routing the whole film through the phone meant playback died the moment the app was killed, backgrounded hard, or frozen by the OS. Now, for VOD, the receiver makes about three requests to the phone — master, media playlist, key — and after that holds no dependency on the app process at all. The phone can die and the film keeps playing.

The trap that cost a day: EXT-X-SESSION-KEY

Section titled “The trap that cost a day: EXT-X-SESSION-KEY”

A master playlist may carry #EXT-X-SESSION-KEY, a pre-fetch hint pointing at the same key the media playlists will need. Shaka (the player inside the Chromecast receiver) acts on it while parsing the master.

Left unrewritten, that one line sends the receiver straight to the KMS — and it 403s before a single segment is ever requested. AVPlayer ignores the hint, so AirPlay worked fine while Chromecast died on identical content. The rewriter handles it explicitly, and the regex that matches #EXT-X-KEY is anchored so it can never accidentally match #EXT-X-SESSION-KEY instead.

A close cousin: #EXT-X-I-FRAME-STREAM-INF, the trick-play playlists. Also parsed eagerly by Shaka, also ignored by AVPlayer until the user scrubs. Same class of bug, same fix.

AirPlay has exactly the same problem, so it got exactly the same fix — the class that drives it does almost nothing except swap the URL at the right moments.

The interesting part is when. If the swap waited until the route went active, the Apple TV would already be holding the CDN URL, 403 on the key, and show black until the swap landed. Apple’s route picker announces when it opens — the last moment before the user commits to a device — so the swap happens then. The phone keeps playing locally through its own LAN proxy (it can reach its own address perfectly well), and the TV’s very first fetch is a URL that already works. If the user cancels the picker, the swap is reverted.

That is why buildPlayerMedia() takes url and headers overrides, and why the AirPlay path passes headers: const {}:

normal: url = content.url headers = playerHeader()
AirPlay: url = http://<lan-ip>/m/… headers = {}
└─ signing is now the proxy's job,
not the player's
  • lib/data/services/cast/cast_lan_proxy_server_impl.dart — the server. Route documentation at L18-45; _serveKey at L540-580, with the key cache and the explicit catch for “settings not loaded yet” (without headers the fetch is guaranteed to 403, so it fails loudly rather than caching an error body as a key).
  • lib/data/services/cast/cast_playlist_rewriter.dart — pure string transformation, no I/O, so every rule is unit-testable against real captured manifests. keyUriPattern (L41-43) is shared with the offline download writer; the session-key rule is L105-109.
  • lib/presentation/widgets/player/airplay/airplay_controller.dart:145-170 — the doc comment explaining why AirPlay needs the cast proxy at all.
  • lib/presentation/pages/content_pages/video_player_screen.dart:383-391 — _prepareForAirPlay, the hook ottstack_player awaits before handing a media entry to the native player.
  • android/app/src/main/kotlin/com/prothomalo/CastForegroundService.kt — keeps the Dart isolate (and therefore the proxy) alive while casting.

This doc is the summary. The casting series covers the same ground in depth, with wire-level detail:

Casting encrypted content is hard for exactly one reason: a receiver cannot send a custom HTTP header, and the key request needs three of them. Everything else — the LAN server, the base64url tokens, the playlist rewriting, the session-key special case — exists to get a TV to a key it is not allowed to ask for directly. And the reason it is affordable is the asymmetry doc 2 pointed at: one 16-byte request through the phone, and gigabytes straight from the CDN.