8. Telling failures apart
8. Telling failures apart
Section titled “8. Telling failures apart”The problem this solves
Section titled “The problem this solves”A cast session that was working would, every so often, do one of two things for no visible reason:
- flash “The TV could not play this video” in red across the remote, while the film carried on playing perfectly well on the TV; or
- silently rebuffer, and come back with the subtitle picker empty — for the rest of the session, on every episode.
Both came from the same eight lines. This doc is about those eight lines, plus three smaller lifecycle bugs the same investigation turned up, and one thing that was never a cast problem at all.
Part 1: one funnel, every error
Section titled “Part 1: one funnel, every error”The receiver talks back on the media namespace. _handleMessage looked at
each reply, asked CastError.tryParse “is this an error?”, and if so ran it
straight into _onLoadFailure:
// cast_controller.dart, beforefinal error = CastError.tryParse(payload);if (error != null) { if (payload['requestId'] == _pendingRateRequestId) { ...; return; } // guarded if (payload['requestId'] == _pendingTracksRequestId) { ...; return; } // guarded _onLoadFailure(error); // ← everything else return;}CastError.tryParse matches on the message type alone — LOAD_FAILED,
LOAD_CANCELLED, INVALID_REQUEST, INVALID_PLAYER_STATE, ERROR. It has no
idea what command drew the reply. And only two commands were guarded.
Everything else the app sends can draw one of those types:
the phone sends the receiver can answer ─────────────── ────────────────────── LOAD ─────────────────────▶ LOAD_FAILED / LOAD_CANCELLED ← a real refusal PLAY / PAUSE / SEEK ──────▶ INVALID_PLAYER_STATE ← "not right now" STOP ─────────────────────▶ INVALID_REQUEST QUEUE_UPDATE ─────────────▶ INVALID_REQUEST GET_STATUS (once a second) INVALID_MEDIA_SESSION_ID ← the poll raced a reloadEvery one of those hit _onLoadFailure, whose job is to decide the media is
bad:
_onLoadFailure │ ├── payload is still "full"? ──▶ degrade to minimal, re-LOAD at current position │ └─ visible rebuffer, and subtitles are gone │ for the rest of the session │ └── already minimal? ──▶ _error = … ──▶ remote goes red, "Playback failed" (the TV, meanwhile, is fine)So a seek the receiver briefly disliked, or a status poll that landed in the half-second between one LOAD and its replacement, would strip the user’s subtitles or paint a failure over a healthy film. That single funnel explains the phantom failures, the mid-session subtitle loss, and the assorted “different errors showing to UI”.
The LOAD_CANCELLED half
Section titled “The LOAD_CANCELLED half”There is a second, sharper version of the same bug. The app supersedes its own loads all the time:
| What the user did | What the app sends |
|---|---|
| Next episode | a new LOAD |
| Change quality | a new LOAD (the receiver has no rendition-pinning API) |
| Jump in the queue | a new LOAD, when the receiver hasn’t reported item ids |
| (nothing — automatic) | the minimal-payload retry, from _onLoadFailure itself |
The receiver answers the load it dropped with LOAD_CANCELLED. That is not a
failure. It is the receiver confirming it did what we asked. It went into the
funnel anyway — which is why a quick episode switch could flash red, and why
the automatic degrade could immediately trigger another degrade.
Part 2: the fix — know which load is in flight
Section titled “Part 2: the fix — know which load is in flight”The controller had no idea which LOAD it was waiting on. _dispatchLoad
captured a requestId for the trace line and threw it away:
final requestId = _nextRequestId; // local, never storedNow it is remembered:
final requestId = _nextRequestId;_pendingLoadRequestId = requestId;Overwriting on each dispatch is the whole trick. A superseded load’s id stops
matching the moment a newer one goes out, so its LOAD_CANCELLED can never be
mistaken for a live refusal.
The error branch then reads as four plain questions:
an error reply arrives │ ├─ is it the rate command we just sent? ──▶ swallow (unchanged) ├─ is it the subtitle command we just sent? ──▶ swallow (unchanged) │ ├─ requestId absent, or 0? ──▶ REAL FAILURE │ unsolicited: an `ERROR`, or a MEDIA_STATUS │ carrying `idleReason: ERROR`. Nothing else │ will report this one. │ ├─ requestId ≠ the load in flight? ──▶ ignore, and trace why │ a reply to PLAY/PAUSE/SEEK/STOP/the poll, │ or to a load we already superseded │ ├─ it IS the load in flight, and it's CANCELLED? ──▶ ignore │ nothing about the payload was refused, so │ degrading it would punish the wrong thing. │ If nothing replaces the load, the 15s load │ watchdog still reports the silence. │ └─ it IS the load in flight, and it FAILED ──▶ REAL FAILURE_pendingLoadRequestId is cleared in three places: when a MEDIA_STATUS arrives
carrying a mediaSessionId (the receiver accepted the media — the same moment
the load watchdog is cancelled), when the error above is consumed, and on
teardown.
Why requestId 0 still counts as real
Section titled “Why requestId 0 still counts as real”Cast uses requestId: 0 for broadcasts — messages the receiver sends because
something happened, not because we asked. That is exactly how playback dying
after it started arrives: a MEDIA_STATUS with idleReason: ERROR and no
request id at all. It has to keep reaching _onLoadFailure, or the Mi Box
signature in doc 3 (accept the load, issue a mediaSessionId, then abort over
the sideloaded WebVTT tracks) would stop being caught and the automatic
minimal-payload retry would never fire.
requestId │ what it means │ treatment───────────┼─────────────────────────┼────────────────────────── absent/0 │ the receiver volunteered│ real failure == load │ our load was refused │ real failure (unless CANCELLED) anything │ some other command │ ignored, tracedPart 3: three lifecycle bugs alongside it
Section titled “Part 3: three lifecycle bugs alongside it”The reconnect timer hijacked the user’s new device
Section titled “The reconnect timer hijacked the user’s new device”During a reconnect backoff, isConnected and isConnecting are both false, so
the device sheet drops back to the plain device list — and the user can pick a
different TV. _connect() did not cancel the pending backoff timer:
drop │ ▼ backoff scheduled (1s → 2s → 4s → 8s), _isReconnecting = true │ user taps "Bedroom" ──▶ connect(Bedroom) ──▶ session opens, playing ✓ │ ▼ the old timer fires ──▶ _isReconnecting still true ──▶ _connect(LivingRoom, …) └─ first line: `await disconnect()` ✗ the user's session is killed and the app reconnects to the old TV_cancelReconnect() now runs at the top of the public connect() — the
user’s device tap. Deliberately not inside _connect, because the backoff loop
calls that itself; cancelling there would stop it retrying after its first miss.
The reconnect path leaked sockets
Section titled “The reconnect path leaked sockets”_beginReconnect nulled _session without closing it. That matters more than
it looks, because of what close() actually is:
Future<void> close() => CastSessionManager().endSession(_session.sessionId);Closing the handle is the only thing that removes the session from
CastSessionManager.sessions. Skipping it leaked an entry per cycle — and in
the zombie-socket case (the 8s silence check in doc 7, where writes succeed and
nothing comes back) the old TLS socket stayed open too. A few Wi-Fi blips left
several parallel client connections open to one receiver.
The stale handle is now captured at the top of _beginReconnect and closed on
both exits — after _stateSub/_messageSub are cancelled, so the resulting
closed/onDone cannot re-enter _handleSessionClosed and start a second
reconnect.
The receiver app exiting went unnoticed for ~23 seconds
Section titled “The receiver app exiting went unnoticed for ~23 seconds”Stop the cast from the TV — Google Home, the TV remote, another sender taking
over — and the receiver sends a RECEIVER_STATUS with an empty applications
list. CastReceiverStatus parsed applicationId out of it and nothing ever
read the field. So the only signal was the socket going quiet:
user stops casting on the TV │ │ 8s silence check notices │ 15s four reconnect attempts against a receiver that has nothing to │ reconnect to ▼ ~23s of "Reconnecting…" over a session that finished before it startedapplicationId == null cannot answer this on its own: it means both “the app
exited” and “this particular status didn’t mention applications”, and only the
first is a reason to end anything. So CastReceiverStatus now carries a
tri-state hasApplication — true / false / null for “didn’t say” — and
_applyReceiverStatus acts only on a definite false, and only when
canControl is true so a status arriving mid-handshake cannot kill it.
The snapshot goes too. This is the user ending the cast, with the same finality as the in-app Stop button; offering to resume it on the next launch would be worse than useless.
And one cosmetic one
Section titled “And one cosmetic one”selectSubtitle rebuilt CastMediaStatus by hand to reflect the new track
before the receiver confirmed it — and the hand-rolled copy omitted
playbackRate, which defaults to null. So picking a subtitle at 1.5× snapped
the speed chip back to “Normal” and left the scrubber interpolating at 1× until
the next status arrived. copyWith now takes activeTrackIds, and the
hand-rolled constructor is gone.
Part 4: the local player’s errors (not a cast bug)
Section titled “Part 4: the local player’s errors (not a cast bug)”The cast remote has always done this right: a CastErrorReason enum, a
localized sentence per reason, and the raw payload available on long-press,
because release builds have no console.
The local player did the opposite. The native layers hand up a flat string:
// kvideo, Androidlistener.onPlaybackError(error.toString())// → "androidx.media3.exoplayer.ExoPlaybackException: Source error"// kvideo, iOSself.listener.onPlaybackError(error: item!.error.debugDescription)// → "Optional(Error Domain=AVFoundationErrorDomain Code=-11800 …)"That string crossed Pigeon, landed in a ValueNotifier, came out of
OSPlayerController.error, and PlayerControls.error() painted it verbatim in
an ErrorCard whose title and button were hardcoded English. Bengali viewers
got a Java exception under the words “Something went wrong”.
The fix is entirely app-side — ottstack_player and kvideo stay untouched,
the same line chorki_player_controller.dart already draws:
raw native string │ ▼PlayerError.classify(raw) lib/presentation/widgets/player/player_error.dart │ substring matching, most-specific first │ (only the flat string crosses Pigeon — the │ error *codes* are discarded natively) ▼PlayerErrorReason { offline, unavailable, unsupported, unknown } │ ├──▶ playerErrorMessage(reason, l10n) ──▶ ErrorCard (title, message, hint, Retry) │ └──▶ long-press ──▶ clipboard: content, url, platform, reason, rawOrder matters in the classifier: an InvalidResponseCodeException: 403 is
also an IO failure, and blaming the user’s Wi-Fi for it sends them to check a
connection that works fine. So “the server said no” is tested before “no
network”.
TrailerControls overrode neither error() nor statusIndicator(), so a
failed trailer painted the package default — a bare Text(rawString) — over
the content page. It now shows the same classified sentence, without the retry
and diagnostics affordances a trailer doesn’t warrant.
Part 5: the volume slider that was never wired to anything
Section titled “Part 5: the volume slider that was never wired to anything”Same family, one layer down. The remote’s volume slider did nothing to the TV, and never moved when the TV’s own remote changed the volume either.
// local_lib/flutter_cast/lib/session.dart, beforevoid sendMessage(String namespace, Map<String, dynamic> payload) { _socket.sendMessage(namespace, sessionId, _transportId ?? 'receiver-0', payload);} // ^ namespace ignoredDoc 2 now spells out the rule this breaks: the TV is two addressable endpoints
on one socket, and the namespace picks which. Volume lives on the receiver
namespace, so SET_VOLUME belongs to receiver-0 — but _transportId is
assigned the instant the first RECEIVER_STATUS arrives, which is the same
moment the session reports connected. So everything the app sent after the
handshake went to the media app:
LAUNCH ─────────────▶ receiver-0 ✓ (sent before the handshake, correct by luck) GET_STATUS ─────────▶ the media app ✗ SET_VOLUME ─────────▶ the media app ✗ LOAD / PLAY / SEEK ─▶ the media app ✓And, like every bug in this doc, it failed in silence — the media app has no handler for that namespace, so the message was dropped with no rejection.
Two consequences, not one. The obvious one is that SET_VOLUME never arrived.
The quieter one is that the explicit GET_STATUS in _onSessionReady, whose
comment promises to “know volume and app state either way”, never arrived
either — so _volumeLevel sat at its initial 1.0 forever. The slider showed
full volume because nothing had ever told it otherwise.
Why no test caught it
Section titled “Why no test caught it”The app’s seam is CastSessionHandle.sendMessage(namespace, payload) — there
is no destination parameter. The bug lived one layer below everything the
suite can see, and nothing in test/ ever built a real CastSession. The fix
therefore extracts the rule into a pure CastSession.destinationFor, which
cast_session_routing_test.dart asserts directly from the app’s own suite.
And a control that could never work
Section titled “And a control that could never work”Fixing the routing is not the whole answer, because some receivers do not allow
a sender to set their volume at all. Cast says so, in the same volume block
the app already parsed:
"volume": { "level": 0.4, "muted": false, "controlType": "attenuation" }attenuation and master are settable; fixed means the TV or AVR owns
its volume — typical for a dongle on an HDMI port, where the TV’s own remote is
changing the TV, not the cast receiver. The app had never read the field, so it
offered the slider unconditionally.
CastController.supportsVolume was a hardcoded true. It is now the
receiver’s answer, and false until the receiver gives one:
no RECEIVER_STATUS yet ──▶ no slidercontrolType: fixed ──▶ no slidercontrolType: attenuation ─▶ slidercontrolType absent, but a level is present ──▶ slider (older receivers omit it)RemotePlaybackController’s own doc already stated the principle — “a control
that silently does nothing is worse than an absent one” — as the reason AirPlay
returns supportsVolume => false. The cast side was the one breaking it.
Part 6: why bytes reached an Apple TV but never a Chromecast
Section titled “Part 6: why bytes reached an Apple TV but never a Chromecast”Not a bug at all, and worth writing down because the symptom is so misleading: a byte would play on an Apple TV and there was no way to send one to a Chromecast. The natural reading is “AirPlay works, Cast is broken”. The truth is that neither was implemented — and iOS hid it.
Bytes do not use the player the rest of the app uses. They run on plain
video_player (shorts_player_pool.dart, VideoPlayerController.networkUrl),
which on iOS is a bare AVPlayer. Nothing in this app or in
video_player_avfoundation sets allowsExternalPlayback, and AVPlayer
defaults it to true — a fact AirPlayRouteMonitor.swift already recorded for
kvideo’s benefit. The app-wide audio session is .longFormVideo. So the moment
a user picks an Apple TV in the system picker, iOS routes the bytes player to it
with no participation from this app whatsoever:
AirPlay Chromecast ─────── ────────── user picks a route in the OS picker user taps a cast button │ │ ▼ ▼ iOS moves the AVPlayer's output the app must build a LOAD (or mirrors the screen) and send it over the socket │ │ ▼ ▼ plays — app never knew nothing, if no code sends itAirPlayController.attach() is called from exactly one place, the main player
screen. It has never been called for a byte. What was reaching the Apple TV was
an OS behaviour nobody wrote, and it flattered the app into looking like it had
a feature it did not.
Google Cast has no transparent handoff and no app-level mirroring. Content reaches a Chromecast only when the app deliberately sends it. So the asymmetry was never “one transport is broken” — it was one transport doing the work for us and the other exposing that we had done none.
What it took to actually support it
Section titled “What it took to actually support it”Very little, as it turned out, because the pieces already fitted:
ShortsEntitywraps aContentEntity(the bytes API deserialises straight intoContentModel), soplayContenttakes a byte verbatim — no mapper, no second entity, no conversion.CastController.playContentgates on nothing butisConnected.- Bytes are HLS off the same CDN, so
_dispatchLoad’s LAN-proxy step handles them exactly as it handles a film.
What was genuinely missing was an affordance and a queue: a CastIconButton in
the byte’s action column, handed the loaded feed from that byte onward so the TV
keeps playing after a thirty-second clip instead of dropping to its idle screen.
Scrolling, and the wrong first answer
Section titled “Scrolling, and the wrong first answer”The first version froze the pager while casting, on the reasoning that the phone and the TV showing different bytes is confusing. That is true and it did not matter: a feed you cannot scroll is not a feed. It read as broken within seconds of real use.
The TV follows the scroll instead, and it is cheap because of something already built. The receiver is holding a queue — the byte plus the window sent with it — so moving within that window is one message, not a reload:
user settles on a byte │ ├─ in the queue the receiver already holds? │ └─▶ jumpToQueueItem → one QUEUE_JUMP, no rebuffer │ └─ scrolled past the window? └─▶ playContent → a fresh load, and a new window from hereDebounced by 400ms, because onPageChanged fires for every page a fling
crosses — acting on each would be a command storm at the receiver and a
slideshow of buffering on the TV. One fling settles into one move.
Nothing plays locally throughout: the byte on screen is a paused first frame
with the cast bar over it. _autoPlay is the single gate on starting a player,
so that is one early return, and the handover runs the same pause-everything
sweep _handleVisibility already used when the pager left the screen.
Two smaller things had to give way. ContentEntity.playableUrl now exists
because a sponsored byte keeps its asset under ad_campaign.url and
ContentModel.fromJson maps a missing url to "" — reading url on the cast
path would have handed the receiver an empty contentId. And the remote bar was
suppressed on the bytes tab and on a deep-linked single byte, on the reasonable
grounds that a bar over a full-bleed feed covers the content; that had to go,
since offering a way to start a cast with no way to stop it is worse. It also
fixed a case that predates any of this — casting a film and then wandering into
the bytes tab used to take every control of the TV away until you left again.
Map to the code
Section titled “Map to the code”| Concern | Files |
|---|---|
| Load/reply correlation | lib/presentation/widgets/player/cast_controller.dart (_pendingLoadRequestId, _handleMessage, _dispatchLoad, _applyMediaStatus) |
| Error model | lib/data/services/cast/cast_media_status.dart (CastError, CastErrorReason, CastReceiverStatus.hasApplication) |
| Reconnect lifecycle | cast_controller.dart (connect, _cancelReconnect, _beginReconnect), lib/data/services/cast/cast_session_handle.dart |
| Receiver-app exit | cast_controller.dart (_applyReceiverStatus) |
| Local player errors | lib/presentation/widgets/player/player_error.dart, player_controls.dart, trailer_controls.dart, error_card.dart, lib/l10n/intl_{en,bn}.arb |
| Message routing | local_lib/flutter_cast/lib/session.dart (destinationFor), local_lib/flutter_cast/LOCAL_MODIFICATIONS.md, docs/casting/02-protocol-and-sessions.md |
| Volume capability | lib/data/services/cast/cast_media_status.dart (volumeSettable), cast_controller.dart (supportsVolume, _applyReceiverStatus) |
| Casting a byte | lib/presentation/widgets/shorts_widget/shorts_video_widget.dart (CastIconButton), shorts_cast_queue.dart, lib/presentation/pages/shorts/shorts_feed_view.dart (the remote gate and _scheduleRemoteFollow), lib/domain/entities/content.dart (playableUrl), cast/cast_remote_overlay.dart (castBarShowsOverRoute) |
| Tests | test/presentation/widgets/player/cast_controller_test.dart, player_error_test.dart, player_controls_error_test.dart, test/data/services/cast/cast_session_routing_test.dart |