Skip to content

6. AirPlay: Reusing the LAN Proxy for Apple TV

6. AirPlay: Reusing the LAN Proxy for Apple TV

Section titled “6. AirPlay: Reusing the LAN Proxy for Apple TV”

Note: this shipped. It began as a design proposal, and the central argument survived contact with the code: the LAN proxy from doc 4 is the right foundation, and AirPlay now reuses it unchanged. Two details in the original proposal were wrong, though, and both are corrected below — see “Two corrections to the original proposal”.

Doc 4 ended with the workaround that makes encrypted content playable on a Chromecast: the phone turns itself into a tiny local web server (the “LAN proxy”) that sits between the TV and the CDN, injecting the authentication the key server demands and fixing a content-type quirk. The proxy is at lib/data/services/cast/cast_lan_proxy_server_impl.dart.

A natural follow-up question is: the iPhone next to me has an Apple TV on the same Wi-Fi — can I AirPlay this same content to it? The concern usually stated is “AirPlay can’t play encrypted content.” This doc answers that question, and refines the premise, because the premise is slightly off in a way that matters.

The question, and a correction to the premise

Section titled “The question, and a correction to the premise”

“AirPlay can’t play encrypted content — can we reuse the cast proxy for it?”

Two claims are tangled together here. Let’s separate them.

Claim A — “AirPlay can’t play encrypted content.” This is not quite right, and the distinction is load-bearing. Apple invented HLS, and Apple TV / AVPlayer natively support standard HLS AES‑128 — the exact #EXT-X-KEY:METHOD=AES-128 scheme this app uses (see doc 4). What Apple TV genuinely cannot handle is FairPlay / SAMPLE‑AES, a different, stronger scheme this app does not use. So the encryption itself is not the blocker.

Claim B — “the receiver can’t get the key.” This is right, and it’s the real problem — but it’s the same problem the Chromecast has. The key server (kms.chorki.net) returns 403 unless the request carries the HMAC‑signed headers referer / vpsid / platform from EncryptionHelper.playerHeader() (lib/core/helper/encryption_helper.dart:35). No receiver — Chromecast or Apple TV — can attach those headers. That, not the encryption, is what needs a bridge.

So the answer is: yes, the proxy can be reused, and the reason it works is that it solves the authenticated key (Claim B), not the encryption (Claim A). To see clearly why it’s the right tool — and not some other iOS mechanism — we first need to look at how the phone plays its own encrypted video today.

Both were found while building it, and both matter.

1. There is no /s/ segment route. The proposal described the proxy as re-serving video segments with a corrected video/mp2t MIME type. That route existed once and was deleted long before this work: routing every byte of the film through the phone meant casting died the moment the OS froze the app. Doc 4’s “Why segments are not proxied” has the full story. The proxy has three routes — /m/, /v/, /k/ — and segments go straight from the CDN to the receiver.

2. Online iOS playback does not use VidLoader. The proposal claimed streaming went through kvideo’s custom-scheme key URI and an AVAssetResourceLoaderDelegate. It does not. VidLoader is instantiated only for downloads (kVideo/ios/kvideo/Sources/kvideo/DownloadManager.swift:17). Streaming builds a plain AVURLAsset with header injection (kVideo/ios/kvideo/Sources/kvideo/PlayerController.swift:117):

var asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": media.headers ?? [:]])

AVFoundation replays those headers on the manifest, the segments and the #EXT-X-KEY request. That is the whole AES-128 story for streaming on iOS.

This correction strengthens the conclusion rather than weakening it. The blocker was never a resource loader we would have to defeat — it is simply that custom headers do not cross the AirPlay boundary. Apple’s own advice for that is to move authentication into the URL, which is exactly what pointing the receiver at the proxy does.

This is worth restating because it’s the most common misconception. The LAN proxy never holds plaintext video. It does one thing that matters:

/k/ — fetches the AES-128 key from kms.chorki.net with the signed headers the receiver can’t send (cast_lan_proxy_server_impl.dart, _serveKey, calling playerHeader()), and re-serves the raw 16 bytes as application/octet-stream. /m/ and /v/ exist only because the key’s URI has to be rewritten somewhere, and that somewhere is the playlist.

The segments stay encrypted the whole way, and never touch the phone. The receiver decrypts them — Shaka on a Chromecast, AVPlayer on an Apple TV.

AirPlay is the part that surprises people coming from the Chromecast work, because it’s structured nothing like it.

There is no AirPlay protocol to implement. No mDNS scan of your own, no TLS socket, no CASTV2 binary framing, no LOAD/MEDIA_STATUS messages, no CastSession/CastController. iOS owns all of that. The app doesn’t discover the Apple TV or talk to it at all. Instead:

  • You present the system AirPlay route picker (AVRoutePickerView), or the user reaches for Control Center.
  • You hand AVPlayer a media URL and play as normal.
  • When the user picks an Apple TV, AVPlayer switches to external playback: the Apple TV takes over and fetches the media URL itself, directly from the network. Your app becomes a remote, exactly like with Chromecast.

That last sentence is the important one. AirPlay runs on the same “the receiver fetches the URL itself” model as Chromecast. Whatever URL you gave AVPlayer is the URL the Apple TV will fetch — and whatever your app attaches to its own requests, the Apple TV will not.

Two consequences follow, and only the first blocks streaming:

  1. Custom headers don’t cross. AVURLAssetHTTPHeaderFieldsKey is an app-process thing. The Apple TV reaches kms.chorki.net bare and gets a 403. This is the streaming blocker, and the proxy is the fix.
  2. Resource-loader delegates don’t run. Apple is explicit: “Video AirPlay is not supported when using a custom resource loader.” Streaming doesn’t use one — but downloads do, which is why downloaded titles cannot AirPlay at all (see below).

Why the LAN proxy is exactly the right fix

Section titled “Why the LAN proxy is exactly the right fix”

Look at what the proxy turns the stream into: plain http:// URLs, every one of which the Apple TV can fetch, carrying no app-only context.

  • The playlists are rewritten so the key URI points at http://<phone>:<port>/k/<token> — a normal http URL, not a custom scheme — so external playback follows it fine.
  • /k/ injects the signed headers the Apple TV can’t send.
  • Segments are absolutised to the CDN, so the film never touches the phone.
  • The Apple TV’s AVPlayer decrypts the AES-128 natively with the key it fetched through the proxy — exactly as Shaka does on a Chromecast.

The three cases side by side make the point: Chromecast and AirPlay are structurally identical from the proxy’s perspective.

Case 1 — In-app iOS streaming:
AVPlayer fetches manifest + key + segments itself,
attaching referer/vpsid/platform to every request
Case 2 — Chromecast:
TV/Shaka ──► phone LAN proxy (/m /v /k) ──► CDN + kms.chorki.net (+ headers)
segments: CDN ──► TV, phone not involved
Case 3 — AirPlay → Apple TV:
Apple TV ──► SAME phone LAN proxy (/m /v /k) ──► CDN + kms.chorki.net (+ headers)
(AVPlayer) segments: CDN ──► TV, phone not involved

Cases 2 and 3 are the same picture. The proxy contains zero Chromecast-specific code — no CAST namespace, no receiver knowledge — already sets permissive CORS, and already serves the key as application/octet-stream, which AVPlayer requires (there’s a comment saying so at local_server.dart:26: “iOS AVPlayer refuses to use a key returned with a media MIME type”). It was, almost accidentally, already tuned for the very player that powers AirPlay.

The whole feature lives in this repo. kvideo and ottstack_player — the local path deps under /Users/fahad/myLab/FlutterProjects/Player/ — were not touched. Three things that looked like they’d force a plugin change turned out not to:

Need Why the app already had it
allowsExternalPlayback = true kvideo never sets it, and AVPlayer defaults it to true. External playback already engaged; there was simply no button.
Swap the URL mid-playback OSPlayerController.playNewMedia() → _loadMediaEntry → kController.play() → player.replaceCurrentItem() on the same AVPlayer, so external playback survives the swap. OSMediaModel.prepare is an existing async hook that rewrites the entry just before it reaches native — and it runs on episode auto-advance too.
Detect the AirPlay route AVAudioSession.routeChangeNotification (active route + portName) and AVRouteDetector (availability) are app-side APIs; neither needs an AVPlayer reference.

If the swap waited for the route to go active, the Apple TV would already hold the CDN URL, 403 on the key, and show black until the swap landed. Apple’s route picker reports when it opens — the last moment before the user commits to a device — so the swap happens there. Playback continues locally through the proxy in the meantime (the phone can reach its own LAN address perfectly well), and the TV’s very first fetch is a URL that works. If the user cancels, it swaps back after a short grace window, because the route-change notification can trail the picker’s dismissal by a beat.

The handover: AirPlay leaves the player screen, like casting

Section titled “The handover: AirPlay leaves the player screen, like casting”

The first build got the streaming right and the UX wrong: video played on the Apple TV, but the remote never appeared and the only controls were the player screen’s own.

The cause is worth remembering, because it is not obvious. CastRemoteOverlay refuses to draw over routes that own the whole screen — VideoPlayerRoute is in its _hiddenRoutes set. Casting gets away with this because connecting pops the player screen (video_player_screen.dart, _onCastStateChanged), which is what puts the user on an ordinary route where the mini bar can appear. AirPlay left the user sitting on the very route the overlay is forbidden to draw on.

So AirPlay now performs the same handover — with one deliberate difference:

Chromecast connects AirPlay goes active
pause local player do NOT pause ← the AVPlayer feeds the TV
pop VideoPlayerRoute pop VideoPlayerRoute
mini bar appears mini bar appears

A Chromecast fetches the stream itself and stops needing the phone, so pausing is free. An Apple TV is being fed by this very AVPlayer — pausing it pauses the TV.

The pop then runs dispose(), which calls stopPlaybackIfNotPip() — and that would kill the TV. The screen now skips it while a route is live, which is the same exemption that function already makes for Picture-in-Picture, just kept on our side so the player package stays untouched.

One consequence shaped the design more than anything else: after the pop there is no State left to call back into. AirPlaySession is therefore plain data — transport, content, queue — and AirPlayController builds its own entries with buildPlayerMedia, exactly as CastController builds its own LOAD payloads. An earlier version passed reload and playEpisode closures over the screen’s State; the first press of Next after a handover would have called into a disposed widget.

The handover has to happen before the player screen mounts

Section titled “The handover has to happen before the player screen mounts”

The first version of the handover reacted to the route going active, which was one notifier tick too late. CastAwareNavigation.play — the single “play this content” funnel — gated on CastController.isConnected, so it knew nothing about AirPlay and pushed VideoPlayerRoute anyway. The screen then forced landscape from initState, the pop landed a beat later, and dispose put it back to portrait:

push VideoPlayerRoute
initState → setFullscreen(true) → landscape ROTATES
~1 tick later: route goes active → maybePop()
dispose → setFullscreen(false) → portrait ROTATES BACK

The user sees the whole UI spin and snap back for no reason. The only fix that produces zero movement is to never mount the screen, so the funnel now asks RemotePlaybackCoordinator instead — which covers AirPlay and also the cast sessions that are restoring or reconnecting, where isConnected is false while the remote is plainly on screen. RemotePlaybackController.playContent is the one call that replaces what is on the TV, whichever transport is live.

Two paths still push the route without going through the funnel (restoring from PiP, and the in-player episode list), so the player screen also seeds its “already casting” flags from the live controllers rather than false, and skips the landscape lock entirely when a session is up.

There is no separate AirPlay button. The cast icon is the single “send this to a screen” affordance, and the sheet lists an AirPlay row above the discovered Chromecasts on iOS.

That row cannot be an ordinary ListTile with an onTap. iOS exposes no API to present the route picker — AVRoutePickerView is the only thing that can open it — so the real control is stretched across the row and drawn with a clear tint, with our own icon and label underneath. Clear tint rather than alpha: 0 on purpose: UIKit skips hit-testing below about 0.01 alpha, so a fully transparent picker would swallow the tap and open nothing. StretchedRoutePickerView also resizes the picker’s internal UIControl to fill the bounds, so the whole row is tappable rather than just the invisible glyph.

That row shipped inert everywhere except the player screen, and the reason is worth keeping because it is the same class of mistake as the audio-session policy above: a guard doing its job in a place nobody had given it the inputs it needs.

AirPlayController only ever engages with a session:

_engaged = engage && _routeActive && _session != null;

and attach() was called from exactly one place in the app — VideoPlayerScreen. So on a content page the row drew itself, Apple’s picker took the tap, the user chose an Apple TV, and then:

route goes active
→ _updateEngagement(engage: true)
→ _session == null → _engaged stays FALSE
├─ no proxy retained, no wakelock, no cast torn down
├─ nothing was ever loaded, so the Apple TV showed nothing
├─ coordinator.active == null → no remote bar
└─ the sheet stayed open (it only popped on cast.isConnected)

The _session != null requirement is not the bug — it is the guard from “One thing that can’t be distinguished”, and removing it would put a selected HomePod back in charge of every Play button. The bug was that the sheet, which knows exactly what the cast icon was tapped for, never told the controller.

An offer is not a session. The sheet now hands its content over as an AirPlayOffer, which the controller holds without acting on, and promotes to a real session only when iOS reports our picker opening — the moment the user has committed to choosing a receiver for that title:

sheet opens → offerContent(content, queue, startFrom) no session
picker opens → _acceptOffer() session, nothing playing
receiver chosen → _onAirPlayStarted() → _swapToProxy() the film starts on the TV
picker cancelled → detach() after the grace window nothing left behind
sheet closes → withdrawOffer() offer only, never a session

Two details in that table are load-bearing:

  • Nothing loads at picker-open, which inverts the rule from “Swapping when the picker opens, not after”. That rule exists so an already playing stream is on a fetchable URL before the TV ever sees it. Here nothing is playing, so there is no 403 to pre-empt — the TV’s first fetch is whatever is loaded after it is chosen — and loading early would start the film audibly on the phone, over a detail page, under an open picker, and leave it running if the user backed out.
  • A cancelled picker detaches, behind the same 1200 ms grace the revert uses. Leaving the anticipatory session attached would let any later route change engage it, which is the phantom-route bug arriving through the side door.

A route going active with no offer standing — someone picking a receiver in Control Center while a content page happens to be open — still means nothing at all, for the reason the next-but-one section gives.

The cast remote overlay is reused via RemotePlaybackController, an interface both CastController and AirPlayController implement, with capability flags rather than a lowest common denominator. AirPlay opts out of three things, honestly:

  • Volume — AirPlay output volume is system-level, outside this process.
  • Quality — the Apple TV runs its own ABR across the multivariant playlist.
  • Disconnect — iOS exposes no API to end an AirPlay session. The remote shows the route picker where the Disconnect button would be; picking “iPhone” there is how a user stops.

Downloaded titles are served from 127.0.0.1:8080 (unreachable from an Apple TV) with a custom-scheme key URI resolved by VidLoader’s resource loader (bypassed by external playback outright). Both would have to change. The AirPlay button is hidden for offline playback, the same guard the cast button already applies.

One thing that can’t be distinguished, and why it’s safe

Section titled “One thing that can’t be distinguished, and why it’s safe”

AVAudioSession cannot tell video external playback from an audio-only AirPlay speaker (a HomePod, say). A false positive there just makes the phone play its own stream through its own LAN proxy — which works. No special handling needed.

Staying alive: the three things that used to stop the Apple TV

Section titled “Staying alive: the three things that used to stop the Apple TV”

The first build streamed correctly and handed over correctly, and then stopped the moment you looked away. Three separate causes, and they are worth keeping apart because only one of them is really about AirPlay.

This is the one that made everything else look broken, so it comes first.

AirPlayRouteMonitor decides “we are AirPlaying” from the audio session’s output port:

AVAudioSession.sharedInstance().currentRoute.outputs.first { $0.portType == .airPlay }

That works only if the app’s audio session actually participates in AirPlay routing — which, for video external playback, means the long-form video route-sharing policy. The app was running with the default policy, and worse, was setting its category in the wrong order:

AppDelegate.didFinishLaunching
setCategory(.playback) ← ours
GeneratedPluginRegistrant.register
└─ kvideo: setCategory(.playback) ← wipes any policy we set above

So active never flipped, _engaged never became true, and isAirPlaying stayed false forever — while the picker-open pre-swap still pointed the TV at the proxy, so video played on the Apple TV perfectly while the app behaved as though nothing had happened. That single false flag is what produced three different-looking bugs:

isAirPlaying == false
├─ VideoPlayerScreen never pops → the remote bar can never draw
│ (CastRemoteOverlay hides on VideoPlayerRoute)
├─ canAcceptContent == false → CastAwareNavigation pushes the player
│ screen instead of switching the TV
└─ dispose() calls stopPlaybackIfNotPip → leaving the screen kills the TV

The fix is two lines and one plist key: configure the session after the registrant, with mode: .moviePlayback, policy: .longFormVideo, and declare AVInitialRouteSharingPolicy = LongFormVideo. The policy earns its place twice over — it is also what makes iOS remember the chosen Apple TV across launches, which the restore below depends on.

OSPlayerController pauses itself whenever the app is hidden, exempting only PiP:

ottstack_player/lib/controller/os_player_controller.dart
if (kController.state.pipMode.value == k.PiPMode.active) return;
if (state == AppLifecycleState.hidden) kController.pause();

For local playback that is exactly right — nobody wants a film running in their pocket. But the controller is app-wide (main.dart), so it outlives the player screen, and while AirPlaying this AVPlayer is the session feeding the Apple TV. Locking the phone paused the TV.

AirPlay wants the same exemption PiP has. It lives in ChorkiPlayerController, an app-side subclass, rather than in the player package — the same split VideoPlayerScreen.dispose already makes for stopPlaybackIfNotPip, and for the same reason: ottstack_player is shared with the TV app and stays untouched.

Nothing else was needed. kvideo already sets audiovisualBackgroundPlaybackPolicy = .continuesIfPossible, and audio is already in UIBackgroundModes, so once that pause is gone AVFoundation keeps feeding the TV on its own.

This one only appears after the handover starts working, which is why it was invisible at first. AirPlay pops the player screen; the screen underneath is SingleContentScreen, whose post-frame callback autoplays the title’s trailer:

AirPlay engages
→ VideoPlayerScreen pops
→ SingleContentScreen rebuilds
→ startPlayingTrailer()
→ playNewMedia([trailer])
→ replaceCurrentItem on the SAME AVPlayer ← the Apple TV's session

and navigating away then called stopPlaybackIfNotPip(force: true), which disposes the media resources outright. Casting needs no guard here — a Chromecast fetches the stream itself, so the local player is free and the trailer should keep playing. AirPlay does, and it is keyed on ownsPlayer rather than isAirPlaying so it also holds through a route blip.

_onRouteState used to treat any loss of the AirPlay port as the session being over: disengage, reload back to the CDN URL, remote off screen. An Apple TV that sleeps for a moment, a Wi-Fi blip, an incoming call — all of them looked identical to the user walking away, mid-film.

iOS does say which it was, in AVAudioSessionRouteChangeReasonKey; the app was throwing it away. It now crosses the channel, and exactly one value means “the user is done”:

route goes inactive
│
├── reason == .override ──────▶ the user picked "iPhone" in the picker.
│ The ONLY deliberate ending iOS offers —
│ swap back to the CDN URL, clear the snapshot.
│
└── anything else ────────────▶ keep the session, the queue and the position.
(.oldDeviceUnavailable, Bar stays up saying "Reconnecting…".
a reconfiguration, an Route back inside 30s → carry on, no reload
interruption) (the stream never left the proxy URL).
Past 30s → stand down, but KEEP the snapshot.

This is the same bargain CastController._beginReconnect strikes, and it is here for the same reason: the content, the queue and the position are all still perfectly correct. Only the route is gone, and it usually comes back.

Coming back: what “parity with Chromecast” can and cannot mean

Section titled “Coming back: what “parity with Chromecast” can and cannot mean”

Doc 7 describes a cast session surviving the app being swiped away. AirPlay cannot do that, and it is worth being precise about why rather than pretending otherwise.

Chromecast AirPlay
────────── ───────
TV fetches the stream itself Apple TV is fed by THIS app's AVPlayer
phone dies → TV plays on process dies → the film genuinely stops
restore = reconnect the remote restore = start it again, where it left off

There is no way around that. External playback is a property of an AVPlayer inside a process, and iOS reclaims the process. What is achievable is everything the user actually sees, and that is what the restore delivers:

app relaunches
│
▼
snapshot on disk? ──no──▶ done
│ yes
▼
less than 2h old? ──no──▶ clear it, done
│ yes
▼
┌──────────────────────────────────────┐
│ show the bar NOW, from the snapshot │ status = restoring
│ "Resuming on Living Room…" │ (title + poster from disk)
└──────────────────────────────────────┘
│
▼
is an AirPlay route selected? ← iOS re-selects it on its own, thanks
│ (wait up to 6s) to the LongFormVideo policy above
├── no ──▶ hide the bar, KEEP the snapshot, try again next resume
▼
GetContentPage.getContent(routeUri) ← fresh URL, fresh entitlement
│
├── failed (offline?) ──▶ hide bar, KEEP snapshot
│
▼
canAccessContent(content)? ──no──▶ clear snapshot, hide bar
│ yes
▼
load through the proxy at 12m34s → the Apple TV picks it back up

Every decision here is deliberately the same one doc 7 argues for, and for the same reasons — the content is re-fetched rather than deserialised because a stream URL can be signed and a rental can lapse; title and poster are persisted purely so the bar has something to draw during the gap; the snapshot survives every accidental ending and is cleared only on the two deliberate ones (watched to the end, handed back to the phone).

Two things are genuinely different, and both fall out of the asymmetry above:

  • There is no device to reconnect to. CastSessionSnapshot is built around a host and port; AirPlay has neither, because iOS never tells the app which receiver the user chose. AirPlaySessionSnapshot therefore remembers only the receiver’s name, and only to draw with. Finding the TV again is iOS’s job, and the route policy is how it is asked to do it.
  • A restored session has no player screen behind it. The process that owned one is gone. So AirPlayController is given an ambient transport — the app-wide OSPlayerController — and drives it directly. This is the same reasoning that made AirPlaySession plain data in the first place: after the handover there is no State left to call back into.

Reused, unchanged:

  • lib/data/services/cast/cast_lan_proxy_server_impl.dart — the proxy. Only change: receiverHost became optional, because the system route picker never tells the app which device the user chose. selectLocalAddress already handled a null receiver by falling through to its home-LAN range ranking.
  • lib/data/services/cast/cast_playlist_rewriter.dart — the pure HLS rewriter.
  • lib/core/helper/encryption_helper.dart — playerHeader().

New:

  • ios/Runner/AirPlayRouteMonitor.swift — AVRouteDetector + AVAudioSession route events over a FlutterEventChannel, including the route-change reason.
  • ios/Runner/AirPlayButtonFactory.swift — AVRoutePickerView as a platform view.
  • ios/Runner/AppDelegate.swift — the audio session, configured after the plugin registrant with mode: .moviePlayback, policy: .longFormVideo; AVInitialRouteSharingPolicy in Info.plist alongside it.
  • lib/presentation/widgets/player/airplay/ — the Dart monitor, the button, and AirPlayController (which owns the swap, the queue, the reconnect window, the restore and the offer handshake).
  • lib/presentation/widgets/player/cast_device_sheet.dart — _AirPlayTile carries the sheet’s content into offerContent, and the sheet closes on either transport.
  • lib/presentation/widgets/player/chorki_player_controller.dart — the app-wide player, with the background pause exempted while a receiver is being fed.
  • lib/presentation/widgets/player/player_media_builder.dart — buildPlayerMedia, shared by the player screen and the controller so neither needs the other.
  • lib/presentation/widgets/player/remote_playback_controller.dart and remote_playback_coordinator.dart — the shared interface and the Cast/AirPlay arbiter.
  • lib/data/services/cast/airplay_session_snapshot.dart, airplay_session_store_impl.dart and lib/domain/services/airplay_session_store.dart — the restore record, a sibling of the cast one rather than a reuse of it.

Proxy ownership is now reference-counted (retain/release) because both transports share one server, and whichever tore down first used to pull the socket out from under the other.

  • App Transport Security. The proxy serves plain http://. Apple DTS says the sender app’s ATS exception governs the receiver’s fetch, and NSAllowsArbitraryLoads is already set — but this is the one claim in the whole design that only a real Apple TV can settle.
  • Apple TV behaviour end to end. The proxy’s own diagnostics answer this directly: recentPaths should show GET /m/… -> 200, GET /v/… -> 200, GET /k/… -> 200, and requestCount should then stop growing — segments must be flowing CDN → TV.
  • That .longFormVideo is enough to make the route observable. Everything in “Staying alive” rests on AVAudioSession.currentRoute reporting the AirPlay port once external playback engages. Log the monitor’s payload and confirm active: true with the receiver’s name. If it does not, the only remaining signal is AVPlayer.isExternalPlaybackActive, which lives inside kvideo — that would mean a KVO observer there pushed over a new event channel, and it is the one change this design has so far avoided needing.
  • That AVPlayer routes an item loaded after the route is already active. The offer flow deliberately loads nothing until a receiver is chosen, so the first item arrives on an AVPlayer whose audio session is already on the AirPlay port. allowsExternalPlayback defaults to true, so it should follow — but if the film comes out of the phone instead, the fallback is to load at picker-open muted and paused, then resume on engagement.
  • That the ambient transport can load with no player view mounted. A restored session has no OSPlayerView on screen. The AVPlayer is owned by kvideo’s PlayerController, not by the view, so it should be fine — but if it is not, the fix is for CastRemoteOverlay to host a 1×1 transparent player view while an AirPlay session is live.

The instinct — “reuse the cast proxy for AirPlay” — was correct, and for a cleaner reason than the original premise suggested. AirPlay isn’t blocked by encryption: Apple TV decrypts AES-128 natively. It’s blocked by the authenticated key, which is the identical problem the proxy already solves for Chromecast. The proxy was reusable as-is; the work was the playback surface around it.