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”.
Where we left off
Section titled “Where we left off”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.
Two corrections to the original proposal
Section titled “Two corrections to the original proposal”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.
Reminder: the proxy doesn’t decrypt
Section titled “Reminder: the proxy doesn’t decrypt”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.
What AirPlay actually is (and isn’t)
Section titled “What AirPlay actually is (and isn’t)”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
AVPlayera media URL and play as normal. - When the user picks an Apple TV,
AVPlayerswitches 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:
- Custom headers don’t cross.
AVURLAssetHTTPHeaderFieldsKeyis an app-process thing. The Apple TV reacheskms.chorki.netbare and gets a 403. This is the streaming blocker, and the proxy is the fix. - 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 normalhttpURL, 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
AVPlayerdecrypts 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 involvedCases 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.
How it was built (app-only)
Section titled “How it was built (app-only)”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. |
Swapping when the picker opens, not after
Section titled “Swapping when the picker opens, not after”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 appearsA 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 BACKThe 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.
One button, both technologies
Section titled “One button, both technologies”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.
The one button was only half wired
Section titled “The one button was only half wired”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 sessionTwo 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.
What the remote can and cannot do
Section titled “What the remote can and cannot do”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.
Why downloads are excluded
Section titled “Why downloads are excluded”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.
1. The app never knew it was AirPlaying
Section titled “1. The app never knew it was AirPlaying”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 aboveSo 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 TVThe 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.
2. The lifecycle pause
Section titled “2. The lifecycle pause”OSPlayerController pauses itself whenever the app is hidden, exempting only PiP:
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.
3. The trailer on the detail page
Section titled “3. The trailer on the detail page”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 sessionand 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.
A dropped route is not an ending
Section titled “A dropped route is not an ending”_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 offThere 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 upEvery 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.
CastSessionSnapshotis built around a host and port; AirPlay has neither, because iOS never tells the app which receiver the user chose.AirPlaySessionSnapshottherefore 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
AirPlayControlleris given an ambient transport — the app-wideOSPlayerController— and drives it directly. This is the same reasoning that madeAirPlaySessionplain data in the first place: after the handover there is noStateleft to call back into.
Where this lives in the code
Section titled “Where this lives in the code”Reused, unchanged:
lib/data/services/cast/cast_lan_proxy_server_impl.dart— the proxy. Only change:receiverHostbecame optional, because the system route picker never tells the app which device the user chose.selectLocalAddressalready 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+AVAudioSessionroute events over aFlutterEventChannel, including the route-change reason.ios/Runner/AirPlayButtonFactory.swift—AVRoutePickerViewas a platform view.ios/Runner/AppDelegate.swift— the audio session, configured after the plugin registrant withmode: .moviePlayback, policy: .longFormVideo;AVInitialRouteSharingPolicyinInfo.plistalongside it.lib/presentation/widgets/player/airplay/— the Dart monitor, the button, andAirPlayController(which owns the swap, the queue, the reconnect window, the restore and the offer handshake).lib/presentation/widgets/player/cast_device_sheet.dart—_AirPlayTilecarries the sheet’s content intoofferContent, 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.dartandremote_playback_coordinator.dart— the shared interface and the Cast/AirPlay arbiter.lib/data/services/cast/airplay_session_snapshot.dart,airplay_session_store_impl.dartandlib/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.
Still to confirm on a device
Section titled “Still to confirm on a device”- App Transport Security. The proxy serves plain
http://. Apple DTS says the sender app’s ATS exception governs the receiver’s fetch, andNSAllowsArbitraryLoadsis 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:
recentPathsshould showGET /m/… -> 200,GET /v/… -> 200,GET /k/… -> 200, andrequestCountshould then stop growing — segments must be flowing CDN → TV. - That
.longFormVideois enough to make the route observable. Everything in “Staying alive” rests onAVAudioSession.currentRoutereporting the AirPlay port once external playback engages. Log the monitor’s payload and confirmactive: truewith the receiver’s name. If it does not, the only remaining signal isAVPlayer.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
AVPlayerroutes 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 anAVPlayerwhose audio session is already on the AirPlay port.allowsExternalPlaybackdefaults 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
OSPlayerViewon screen. TheAVPlayeris owned by kvideo’sPlayerController, not by the view, so it should be fine — but if it is not, the fix is forCastRemoteOverlayto host a 1×1 transparent player view while an AirPlay session is live.
The takeaway
Section titled “The takeaway”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.