Skip to content

8. Telling failures apart

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.

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, before
final 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 reload

Every 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”.

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 stored

Now 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.

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, traced

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.

_beginReconnect nulled _session without closing it. That matters more than it looks, because of what close() actually is:

cast_session_handle.dart
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 started

applicationId == 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.

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, Android
listener.onPlaybackError(error.toString())
// → "androidx.media3.exoplayer.ExoPlaybackException: Source error"
// kvideo, iOS
self.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, raw

Order 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, before
void sendMessage(String namespace, Map<String, dynamic> payload) {
_socket.sendMessage(namespace, sessionId, _transportId ?? 'receiver-0', payload);
} // ^ namespace ignored

Doc 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.

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.

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 slider
controlType: fixed ──▶ no slider
controlType: attenuation ─▶ slider
controlType 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 it

AirPlayController.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.

Very little, as it turned out, because the pieces already fitted:

  • ShortsEntity wraps a ContentEntity (the bytes API deserialises straight into ContentModel), so playContent takes a byte verbatim — no mapper, no second entity, no conversion.
  • CastController.playContent gates on nothing but isConnected.
  • 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.

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 here

Debounced 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.

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