7. Session Persistence & Recovery
7. Session Persistence & Recovery
Section titled “7. Session Persistence & Recovery”The problem this solves
Section titled “The problem this solves”You start casting a film to the living room TV. Then you lock your phone and put it in your pocket. Or you swipe the app away out of habit. Or you get a call, and come back twenty minutes later.
What should happen is obvious: the TV keeps playing, and when you reopen the app the little cast bar is still there so you can pause it. The TV is a separate computer — nothing about the phone going away should matter.
Before this work, none of that happened. The bar was gone, the app had no idea a cast had ever existed, and — worse — the TV had stopped too.
There are really two separate problems tangled together here, and it’s worth keeping them apart:
| Problem | Whose fault | Where it’s fixed |
|---|---|---|
| The app forgets the session exists | The app | Parts 2–6 here |
| The TV stops playing | The doc-4 proxy | Part 7 — since fixed by removing segment proxying |
Part 1: why the app forgot
Section titled “Part 1: why the app forgot”Everything about a cast session lived in CastController’s fields — the
device, what’s playing, the position, the socket. CastController is created
in MyApp and provided app-wide, which is enough to survive navigation:
User taps Cast on the player screen → CastController.connect() → player screen pops → CastController is still alive (it lives in MyApp, not the screen) → mini bar appears ✓But “app-wide” only means “as wide as the app”. When the process dies, so does every field on it. There was no persistence of any kind — no SharedPreferences key, no Hive box, nothing.
And it was slightly worse than “forgets on restart”, because of this method:
// cast_controller.dart, beforevoid _handleSessionClosed({String? detail}) { _teardownLocalState(...); // wipes everything, silently}Any reason for the socket closing — a Wi-Fi blip, the TV sleeping for a moment, the phone waking up from a suspension — ran straight into a full teardown. The bar just vanished mid-film with no explanation and no way back.
Part 2: the snapshot
Section titled “Part 2: the snapshot”The fix starts with writing down enough to find the session again. Not the session itself — enough to find it.
┌─────────────────────────────────────────────┐│ CastSessionSnapshot (SharedPreferences) │├─────────────────────────────────────────────┤│ device: serviceName, name, host, port │ ← how to reach the TV│ content: id, routeUri │ ← how to re-fetch what's on│ display: title, poster │ ← so the bar can draw NOW│ position: 12m 34s │ ← where to pick up│ savedAt: <timestamp> │ ← how stale is this?└─────────────────────────────────────────────┘Two design decisions here are worth understanding, because they’re the kind of thing that looks like over-thinking until you hit the bug.
We do not persist the ContentEntity. Only its routeUri. On restore we
re-fetch it from the API. Why bother, when we could just serialize the whole
object?
- The stream URL may be signed and time-limited. A URL saved two hours ago can be dead.
- More importantly, entitlements move. A rental can expire between the app
closing and opening.
CastAwareNavigation.buildQueuealready gates onContentHelper.canAccessContentbefore anything reaches the TV — restoring blindly from disk would be a way to walk straight around the paywall.
We do persist the title and poster — but only to draw with. The restore takes a moment (an API call plus a socket handshake), and an empty screen followed by a bar appearing out of nowhere reads worse than a bar that’s already there saying “Resuming on Living Room TV…”. Those two fields exist purely so there’s something to render during that gap. They never touch playback.
When it’s written (throttled to ~5s, immediate on real transitions):
first MEDIA_STATUS after a load ──┐queue advanced to next episode ───┤play / pause ───┼──▶ store.write(snapshot)position tick (every 5s) ───┘When it’s cleared — and this is the important half:
user hits disconnect / stop ───┐watched to the end (FINISHED) ───┼──▶ store.clear()entitlement revoked on restore ───┘
socket dropped ───┐process killed ───┼──▶ KEEP IT. This is the whole point.handshake failed ───┘That split is why _teardownLocalState grew a clearSnapshot flag. Wiping the
record on a dropped socket would throw away the only route back from exactly
the situation it exists for.
Part 3: the restore
Section titled “Part 3: the restore”app starts (or returns to foreground) │ ▼ snapshot on disk? ──no──▶ done │ yes ▼ less than 2h old? ──no──▶ clear it, done │ yes (nobody wants last night's film back) ▼ ┌──────────────────────────────────────┐ │ show the bar NOW, from the snapshot │ status = restoring │ "Resuming on Living Room TV…" │ (title + poster from disk) └──────────────────────────────────────┘ │ ▼ GetContentPage.getContent(routeUri) ← fresh URL, fresh entitlement │ ├── failed (offline?) ──▶ hide bar, KEEP snapshot, try next resume │ ▼ canAccessContent(content)? ──no──▶ clear snapshot, hide bar │ yes ▼ _connect(device, startFrom: position, isRestore: true) │ ▼ LAUNCH → receiver ready → LOAD at 12m34sReconnecting is cheap, which is what makes this feel instant rather than like
a chore. CastDevice is a plain data class of host/port/name, and
CastSession.connect needs nothing more — no mDNS scan required. We
already know exactly where the TV is.
A few details worth knowing:
- It re-LOADs rather than adopting what’s playing. The receiver’s playlist URL points at a port on a process that no longer exists, so its next playlist refresh (or any seek) would fail. Re-loading at the saved position is indistinguishable from continuing, from where the user is sitting.
- It reuses
connect(). The only differences are cosmetic (restoringvsconnecting), a shorter timeout (8s, not 20 — a powered-off TV should fail fast and quietly), and silent failure. Auto-relaunching the receiver when the TV has gone back to its home screen falls out of the existing LAUNCH path with no extra code. - It fails silently. Nobody asked for this restore, so nobody should be told it didn’t work. No error card, no snackbar.
- The queue is rebuilt in the background. Restore loads a single item so
the TV starts fast; autoplay-next is refilled afterwards from
getAdjacentEpisode, re-filtered through the same paywall gate.
Part 4: zombie sockets
Section titled “Part 4: zombie sockets”This one is subtle and worth internalising, because it will bite you again somewhere else.
When iOS suspends a process, or Android freezes it, TCP sockets often survive in name only. Writes succeed. Nothing ever comes back. From Dart’s point of view everything is fine:
bool get isConnected => _session?.state == CastSessionState.connected;// ^^^^^^^^^^^^^^ what the session object last BELIEVEDThe controller polls the receiver every second for its status. Against a zombie socket that poll just runs forever into the void, and the remote sits there looking connected and doing nothing.
The fix is a liveness check, not a connection check:
_applyMediaStatus() ──▶ _lastStatusAt = now (proof of life)
_onTick() every 250ms: if canControl && now - _lastStatusAt > 8s → this socket is dead. reconnect.Note the canControl guard — that means “the receiver has given us a
mediaSessionId”, i.e. the session genuinely worked at some point. Without
that guard this was a real bug: before the first status, silence means
something entirely different (the receiver is rejecting the media), and that’s
the load watchdog’s job to report. The shorter silence timeout would
pre-empt it and turn every rejected stream into an endless reconnect loop.
Two different silences, two different meanings:
| When | Silence means | Handled by |
|---|---|---|
| After LOAD, before first status | The TV won’t play this | _loadWatchdog (15s) → error |
| After the session was working | The socket is dead | silence check (8s) → reconnect |
Part 5: reconnecting instead of vanishing
Section titled “Part 5: reconnecting instead of vanishing”_beginReconnect() is deliberately not a teardown. The content, the device,
the queue and the position are all still perfectly correct — only the socket
is gone.
socket drops │ ▼keep: castingContent, activeDevice, queue, positiondrop: session, status, subscriptionsset: isReconnecting = true ──▶ bar stays up, says "Reconnecting…" │ ▼retry after 1s → 2s → 4s → 8s │ ├── connected ──▶ re-LOAD at last position, bar returns to normal │ └── all four failed ──▶ tear down, but KEEP the snapshot (coming back to the app later gets another go)The proxy is deliberately left running throughout — _ensureStarted is
idempotent, so not stopping it avoids a pointless port change in the middle of
a recovery.
Part 6: keeping the process alive (Android)
Section titled “Part 6: keeping the process alive (Android)”Everything above handles the app coming back. This part is about it not having to.
Android’s cached-app freezer will suspend a backgrounded process. Because our
Dart isolate is the TV’s origin server (doc 4), a frozen process means a
stalled TV. CastForegroundService prevents that:
cast connects ──▶ CastForegroundService.start() └─ ongoing notification: "Casting to Living Room TV" type: mediaPlaybackcast ends ──▶ CastForegroundService.stop()Why mediaPlayback and not something more literal?
connectedDevicereads more accurately, but Android 14 gates it behind Bluetooth/NFC/USB permissions this app doesn’t hold. We’re on Wi-Fi.dataSync(what the downloader uses) is capped at 6 hours a day on Android 15 — which a film can exceed.
Note where it’s driven from: CastRemoteOverlay, not CastController. The
notification is user-facing text, so it needs localisation, which lives in the
widget tree. The service takes finished strings and never sees a key — the
same split DownloadProgressNotifier uses.
There is no iOS equivalent. iOS suspends the process roughly thirty seconds after backgrounding, full stop. That’s precisely why the restore-on-resume path above exists.
That suspension is survivable here only because the TV is a separate
computer. AirPlay has no such luxury — the Apple TV is fed by this app’s
own AVPlayer, so a suspended process is a stopped film. AirPlay therefore
reuses this doc’s whole shape (a snapshot, a 2h window, re-fetch and re-check
on restore, keep-on-accident/clear-on-purpose) but ends in starting playback
again rather than reconnecting to something that never stopped. Doc 6’s
“Coming back” section has the details, including the two places the two
designs genuinely diverge.
Part 7: where the TV stopping came from
Section titled “Part 7: where the TV stopping came from”Everything above is about the app forgetting. The TV stopping was a separate problem, and it’s worth knowing where it came from because it was self-inflicted.
Casting used to survive being killed. The pre-proxy version sent the CDN URL straight to the TV:
// the old version'contentId': url, // https://cdn.../master.m3u8The TV fetched everything itself. The phone was a remote control, exactly as the README’s one-paragraph summary says. Swipe the app away and the film kept playing, because nothing about the phone was load-bearing.
Then the proxy (doc 4) started routing segments through the phone as well as the key — and the phone quietly became the TV’s origin server:
BEFORE WITH SEGMENTS PROXIED ┌───────┐ ┌───────┐ │ Phone │ ──"play this URL"──▶ │ Phone │ ──"play this URL"──▶ └───────┘ ┌────┐ └───┬───┘ ┌────┐ │ TV │ │ ◀── playlists ───── │ TV │ CDN ──────── video ───────▶└────┘ │ ◀── key ─────────── └────┘ │ ◀── EVERY SEGMENT ──┘ phone can die freely CDN ─┘ phone IS the origin serverSegment proxying existed only to correct a Content-Type the CDN gets wrong,
against a receiver behaviour that was never actually observed. The key — the
real reason the proxy exists — is 16 bytes fetched once per VOD. Segments are
the whole film.
That route is now gone. /s/ and _serveSegment have been removed, and
rewriteMedia no longer takes a segment-proxy callback; it just absolutises
segment URIs against the real CDN. A minimal VOD load makes three requests to
the phone — master playlist, media playlist, key:
expect(proxy.recentPaths, hasLength(3));…but “three requests and then nothing” was wrong
Section titled “…but “three requests and then nothing” was wrong”This section used to end by saying the receiver “needs nothing from this process again”. A device log disproved it:
proxyPaths: [GET /m/<master> -> 200, GET /v/<720p/media.m3u8> -> 200, GET /v/<1080p/media.m3u8> -> 200, ← a second rendition, mid-film GET /k/<kms key> -> 200]The receiver does ABR. It switched from 720p to 1080p partway through, and
every switch is a fresh media-playlist fetch — because a variant playlist is
exactly where the #EXT-X-KEY URI gets rewritten to point back here. So the
phone stays load-bearing at unpredictable moments for the whole film, not just
at load time.
That matters most on iOS, which suspends a backgrounded app about thirty
seconds after the screen locks and tears its listening socket down. The next
rendition switch reaches nothing and the receiver gives up with
idleReason: ERROR. Worse, the HttpServer object and its cached base URL
survive the suspension, so _ensureStarted went on handing out an address
nothing was bound to — every retry failed identically, which is why Retry
looked inert.
CastLanProxyServer.revalidate now probes the socket (and re-checks the local
address, for a Wi-Fi reconnect), rebinds when either has gone stale, and
reports whether the address changed so the load can be re-dispatched.
CastController calls it on resume and before a retry. Note it rebinds on a
new port, which is why the load must go out again — and why it re-dispatches
only when the address actually changed: come back quickly enough and the TV is
still playing happily.
None of that keeps the TV playing while the phone is locked. Nothing client-side can, while the key needs the phone — which is what the two server-side changes below would finally fix.
So the recovery machinery in Parts 2–5 is no longer papering over a stalled TV. It handles the app’s own memory: reconnecting the control socket, spotting a dead one, and putting the bar back.
Two things are still worth doing server-side, and they would let the proxy be deleted outright rather than merely slimmed:
- Let the key endpoint accept
vpsid/referer/platformas query parameters as well as headers. This is not a security change —vpsidisHMAC-SHA1("android|<referrer>", secret), a static value with no per-request, per-user or time component, already sent in cleartext by every client. It just needs to be reachable by a client that can’t set headers. - Fix the CDN’s
Content-Typefor.ts(video/mp2t), so nobody is ever tempted to reintroduce a proxy for it.
With (1), contentId goes back to being the CDN URL and
CastLanProxyServerImpl can be deleted entirely.
If the TV ever does reject a CDN segment, the symptom will be playback
failing immediately on the receiver while recentPaths shows all three
requests served successfully — the phone did its job and the TV still refused.
That would mean the Content-Type concern was real after all, and fix (2) is
the answer, not a new proxy route.
Map to the code
Section titled “Map to the code”| Concern | Files |
|---|---|
| Snapshot model & storage | lib/data/services/cast/cast_session_snapshot.dart, cast_session_store_impl.dart, lib/domain/services/cast_session_store.dart |
| The AirPlay equivalent | lib/data/services/cast/airplay_session_snapshot.dart, airplay_session_store_impl.dart, lib/domain/services/airplay_session_store.dart, lib/presentation/widgets/player/airplay/airplay_controller.dart (restoreSession, _saveSnapshot, _onRouteLost) |
| Restore / reconnect / lifecycle | lib/presentation/widgets/player/cast_controller.dart (restoreSession, _beginReconnect, _onAppResumed, _onTick) |
| Foreground service | lib/data/services/cast/cast_foreground_service.dart, android/.../CastForegroundService.kt, MainActivity.kt |
| UI states | cast/cast_remote_overlay.dart (hasActiveSession), cast/cast_mini_player.dart |