Skip to content

feat(mobile): style every Google Cast SDK sheet in the app palette - #1990

Open
Ur-imazing wants to merge 6 commits into
mainfrom
feat/mobile-cast-sheet-theming
Open

feat(mobile): style every Google Cast SDK sheet in the app palette#1990
Ur-imazing wants to merge 6 commits into
mainfrom
feat/mobile-cast-sheet-theming

Conversation

@Ur-imazing

@Ur-imazing Ur-imazing commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

The Chromecast sheets were the last surface still wearing Google's colours: a neutral-grey ground, an iOS-blue Cancel, a blue volume slider and a blue "Stop casting". react-native-google-cast exposes no styling API and apps/mobile/ios + android are gitignored prebuild output, so the theming ships as two Expo config plugins.

A plugin change moves the fingerprint runtime version, so this needs a new native build — an OTA update cannot deliver it.

What changed

iOS — plugins/withCastUIStyle.js injects a GCKUIStyle block into AppDelegate.swift, covering every surface the SDK can present: chooser, connection controller (plus its nav bar and toolbar), empty state, connecting state, expanded controller, mini controller, track selector, instructions.

Android — plugins/withAndroidCastTheme.js writes mediaRouteTheme and the cast*Style items onto AppTheme, because every cast dialog resolves its theme from the Activity, not from the cast button's ContextThemeWrapper.

Android's cast control is now the SDK's own button (src/lib/cast/NativeCastButton.tsx), in the same Frosted backplate the AirPlay picker already uses. showCastDialog() can only click a native MediaRouteButton that is already attached, and the app mounted none — so Android had a cast glyph that did nothing at all. iOS is unchanged: it presents the dialog from GCKCastContext directly, so it keeps the app-drawn glyph, its cast-connected variant and its state-aware label.

Colours only. Row height, corner radius, the grabber and the type scale live in the SDK's nib and are not reachable. Fonts are deliberately left alone: contentSizeDidChange: re-runs initDefaultFonts, so a custom font is wiped the first time the reader changes text size.

Two Android defects the first pass missed, both found on hardware

The theming above was verified on iOS only. Testing it on a physical Galaxy Tab S8 (Android 16) against two real Chromecasts found two defects that no emulator could have shown — the emulator mangles multicast, so it never discovers a receiver at all.

1. The Android cast button could never appear. Both the old glyph and the new SDK button were gated on castUi.available, which comes from getCastState(). That call was measured answering noDevicesAvailable for minutes while both Chromecasts already sat in the app's own MediaRouter route list; it read notConnected seconds after the chooser was opened. The gate therefore never opened. Android now renders the SDK button unconditionally; iOS keeps its gate, because presentCastDialog needs no attached button and its state is trustworthy.

This was a pre-existing bug, not a regression from this PR — verified at 4337a352d that the old Android glyph was gated the same way. Neither design ever worked on Android 16.

Always-visible is correct rather than a leak: in mediarouter 1.8.0-beta01 MediaRouteButton has no visibility logic at all and setAlwaysVisible(boolean) is a no-op stub (0: return in its bytecode), so the SDK's old auto-hide behaviour is gone.

2. The chooser dialog's ground stayed stock #303030. The theme set android:colorBackground, but an AppCompat dialog paints android:windowBackgroundThemeOverlay.AppCompat.Dark sets both to #303030, so our text colours landed while the ground did not. Overriding windowBackground costs no dialog inset or corner radius, because the stock value is a flat colour rather than abc_dialog_material_background.

Three facts worth knowing before editing this

  • The iOS block's begin marker carries a content hash of the emitted Swift. expo prebuild REUSES an existing ios/ rather than recreating it, so a name-only sentinel made an edited block look already-applied and kept building the previous palette.
  • The trailing call is apply(), not the header's applyStyle — Swift renames the selector. Only a real compile catches it; the tests pinned the header spelling and stayed green while the build failed.
  • deviceChooser does not own the chooser's own title or Cancel button. _styleAttributesForNavigation is captured once in viewDidLoad from connectionController, so both sheets' nav bars come from deviceControl.connectionController.navigation.

Verification

Both platforms were verified by sampling pixel values out of screenshots rather than judging by eye — the stock cast red #D0021B and our #CB333B pass a glance and fail the design system.

iOS (simulator, against a real Chromecast):

Element Measured
sheet ground #1c1917 exact
titles, row labels #f5f5f4 exact
row glyphs #a8a29e exact
transport glyph #f5f5f4 exact
iOS system blue #0a84ff anywhere 0 px gone
stock cast red #d0021b anywhere 0 px gone

The sheets also held dark at luminance 25/255 with the system appearance set to light, which closes the userInterfaceStyle: "automatic" hazard — an unstyled sheet follows the system, not the app.

Android (Galaxy Tab S8, Android 16, two real Chromecasts, shipped config with no probes):

Element Measured
dialog ground #1c1917 441,041 pixels matched
title, both route labels #f5f5f4 exact
stock #303030 / #424242 / #d0021b 0 px gone
SDK <CastButton> mounts content-desc="Cast", clickable=true uiautomator dump

The cast and AirPlay buttons were also confirmed present on a physical iPhone.

156 suites / 2334 tests, tsc, eslint and prettier clean. Clean prebuild both platforms; iOS BUILD SUCCEEDED; Android assembleDebug green.

Reviewed

Tier-2 /ce-code-review ran twice: once before the first push, and again scoped to the Android fix commit.

The first pass raised 13 findings after reconciliation, 11 through a validation gate; seven were applied in 88d3796b9 and four dropped as false or preference-only. Findings it caught that were shipping green: gutting forgeApplyCastBase passed every test while every cast surface reverted to Google's palette; volumeSliderMaximumTrackTintColor had no test at all; neither plugin's exported entry point ran under jest; and Android theme item values were largely unasserted (aapt2 checks that a @color reference resolves, never that it is the right one).

The second pass raised no code findings. Correctness traced every consumer of the availability signal and confirmed the fix is complete rather than half-applied; testing independently re-ran the falsification (reverting the gate fix turns exactly 1 test red, the theme fix exactly 2). It did find four overstated claims, corrected in af267a415 — most importantly that the getCastState() cause had been asserted without a counterfactual. The measurement stands; the mechanism does not, and the docs now say so.

Neither review pass carries cross-model corroboration. The independent cross-model reviewer failed to authenticate in its execution context both times, and on the second pass the adversarial lens was abandoned before completing — so this change has had no adversarial review at all. That is the largest gap in its verification.

Not verified

  • The DYNAMIC Android chooser. Only the classic chooser was measured. Theme.MediaRouter points mediaRouteBodyTextAppearance / mediaRouteHeaderTextAppearance at TextAppearance.MediaRouter.Dynamic.*, which hardcode android:textColor to #FFFFFF and #BDC1C6 — a text appearance's own textColor beats the theme-level textColorPrimary this plugin sets, so on that variant those two items do nothing. Which variant appears depends on whether the receiver advertises dynamic groups; two ordinary Chromecasts produce the classic one.
  • Android light mode. It rests on an argument, not a measurement: values-night/ carries no cast resources, so the explicit hex should win in either mode.
  • The iOS volume slider — the receiver reached reports no volume control, so that row never drew.
  • Whether the SDK button's state-aware label survives a live session.
  • Render performance of the now-unconditional Android button. The repo asks for page-load evidence on rendering changes; this adds one native view and has none.

There is no kill switch — a config-plugin change moves the fingerprint runtime version, so a wrong palette cannot be reverted by an EAS Update on either platform. Only a new store build.

Follow-up not in this PR

  • No CI step compiles the emitted Swift or builds the Android app. apps/mobile CI is ubuntu-only and its build script is a no-op echo, so jest string-pinning is the only automated gate on generated native code — which is exactly how the applyStyle error reached a build, and how the wrong background attribute reached a device. A macOS job running expo prebuild --clean plus a Debug xcodebuild would close the iOS half; nothing currently would have caught the Android half. That is recurring runner cost and a deliberate omission.
  • The getCastState() cause is still open. The discriminating experiment is a button attached but never tapped.
  • apps/tv has no Chromecast implementation, so neither fix needs porting today. When cast lands there, both defects will need re-deriving — TV and mobile lib predicates are known to drift silently.

🤖 Generated with Claude Code

Ur-imazing and others added 4 commits August 21, 2026 10:56
The cast sheets were the last surface still wearing Google's colours: a
neutral-grey ground, an iOS-blue Cancel, a blue volume slider and a blue
"Stop casting". react-native-google-cast exposes no styling API, so both
halves ship as config plugins.

iOS — plugins/withCastUIStyle.js injects a GCKUIStyle block after the
vendor's setSharedInstanceWith call. Three constraints are load-bearing:
the block must follow that call (GCKUIStyle.sharedInstance()'s
dispatch_once reads GCKCastContext.sharedInstance(), which traps when
unset), the plugin is listed BEFORE react-native-google-cast because
AppDelegate mods run in reverse array order, and the trailing call is
apply() rather than the header's applyStyle — Swift renames the selector,
and only a compile catches it.

Android — plugins/withAndroidCastTheme.js writes mediaRouteTheme and the
cast*Style items onto AppTheme, since every cast dialog resolves its theme
from the Activity. Each style inherits its SDK parent; a bare parent drops
every SDK default silently.

src/lib/cast/CastRouteButton.tsx mounts an invisible native CastButton on
Android. That is a bug fix, not styling: showCastDialog() clicks the most
recently attached MediaRouteButton, the app mounted none, so the call
resolved false and Android had a cast glyph that did nothing.

Colours only. Row height, corner radius, the grabber and the type scale
live in the SDK's nib and are not reachable. Fonts are left alone because
contentSizeDidChange: re-runs initDefaultFonts.

Verified on the iOS simulator with a real Chromecast, sampling pixel
values rather than judging by eye: sheet ground #1c1917, titles #f5f5f4,
row glyphs #a8a29e, transport glyph #f5f5f4, and zero pixels of iOS blue
#0a84ff or stock cast red #d0021b. The sheets also held dark while the
system appearance was light, which closes the userInterfaceStyle
"automatic" hazard. aapt2 validated the Android styles; both falsified
once. A plugin change moves the fingerprint runtime version, so this
needs a new native build rather than an OTA update.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Applies the six actionable findings from the tier-2 review, plus two
judgement calls the review left open.

The substantive fix is the injection sentinel. `expo prebuild` REUSES an
existing ios/ rather than recreating it, so a name-only marker made an
edited block look already-applied: editing the emitted Swift and
re-running prebuild silently kept building the previous palette. The
marker now carries a sha1 of the emitted body, and a hash mismatch
excises the stale block before inserting the new one. Six tests cover it,
including the discriminating stale-block case; a missing end marker
throws rather than splicing a partial block.

Test coverage closes three gaps that each let a silent revert ship green.
The emitted Swift assignments were pinned only through their colour
constant's declaration, so gutting forgeApplyCastBase — or swapping which
attribute got which colour — passed every assertion while every cast
surface reverted to Google's palette; all fifteen assignment lines are
now pinned, with an anti-vacuous case. Neither plugin's exported entry
point ran under jest, so deleting the withAppDelegate wiring failed no
test; a new suite runs both entry points including the fail-silent
branch. Android theme item values were largely unasserted, and aapt2
validates that a @color reference resolves, never that it is the right
one; all nineteen are now table-driven.

Two judgement calls. The expanded controller was styled on Android but
its Activity was never declared, so those items were dead
configuration — `expandedController: true` declares it, which keeps the
"every SDK surface" scope intact and removes a latent
ActivityNotFoundException. And `forge_cast_accent_text` was declared and
never referenced, because Android's mediarouter hardcodes its text-button
colour; it is dropped, with a test that no declared cast colour is
unreferenced.

Documentation corrections matter as much here. The light-mode claim was
stamped verified without naming its platform: the measurement was iOS
only, and Android rests on a resource-fallback argument, so the prose now
says so. Also records that the pixel recipe is iOS-only tooling, that the
SDK-import guard governs this directory, and what the Android emulator
pass did and did not establish.

Android runtime evidence, new: the app builds, installs and runs with this
theming, and MediaRouter route restore fires on the watch screen —
the native MediaRouteButton does attach under Fabric interop, which was
the open risk. Not established: that showCastDialog() resolves true and
draws a themed dialog. Emulator multicast is mangled so no receiver is
discovered, and the dev-launcher overlay covers the cast glyph.

Four review findings were dropped by the validation gate: the plugin-order
invariant is already fail-closed (a misordered app.json removes the anchor
and the plugin throws), the config-plugins catch branch has no reachable
trigger, the STYLE_LINES representation was a preference whose cited
fragility did not exist, and the 3-line cap is the inline-comment rule
rather than a docblock rule.

156 suites / 2333 tests, tsc, eslint and prettier clean. Clean prebuild on
both platforms; iOS BUILD SUCCEEDED; Android assembleDebug and
processDebugResources green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit claimed MediaRouter route-restore logs showed the
native MediaRouteButton attaching under Fabric interop. That does not
hold: those lines repeat on a ~25s cadence (70 in one session), so they
are CastContext's own route loop rather than a one-shot
onAttachedToWindow, and `dumpsys activity top` found no MediaRouteButton
view. Whether the button mounts at all under RN 0.86's legacy
view-manager interop is still unknown, and it is the gate every other
Android claim sits behind.

Also records two levers confirmed inert by reading the mediarouter AAR:
Theme.MediaRouter points mediaRouteBodyTextAppearance and
mediaRouteHeaderTextAppearance at TextAppearance.MediaRouter.Dynamic.*,
which hardcode android:textColor to #FFFFFF (route rows) and #BDC1C6
(header). A text appearance's own textColor beats the theme-level
android:textColorPrimary and android:textColorSecondary this plugin sets,
so on the dynamic dialog those two items do nothing. The parent choice is
right, though: Theme.MediaRouter resolves to ThemeOverlay.AppCompat.Dark.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the hidden 1pt CastButton with the SDK button as the real,
visible control on Android. iOS is unchanged: it presents the dialog from
GCKCastContext directly, so it keeps the app-drawn glyph, its
cast-connected variant and its state-aware label.

The hidden button worked, but it left a gap between "a glyph is visible"
and "a native button is registered" — and that gap WAS the original
Android bug. showCastDialog() can only click a MediaRouteButton that is
already attached, the app mounted none, so the glyph appeared whenever a
receiver was discovered and did nothing. Feeding the vendor's static
registry from an invisible view fixed the symptom while keeping the two
facts separable. Rendering the native button as the control closes the
gap by construction: the thing you tap is the thing that opens the
dialog. It also removes the dependency on getCurrent()'s
last-attached-wins semantics, and the showCastDialog() ambiguity where
the promise resolves true because a button was found even if showDialog()
internally declined.

It also drops a hack I could not justify. The hidden view was 1pt at
opacity 0, and the comment claimed a zero-size view was "more likely to
be optimised away" — a guess written as reasoning. This uses the pattern
already in the same file: VideoAirPlayButton is a native picker inside
the shared Frosted backplate, and the cast button now sits beside it
identically at 44pt.

Verified on an emulator (2026-08-21) before committing to the approach:
the SDK button renders and does NOT self-hide with no receiver
discoverable, and its accessibility label reaches the native view
(content-desc="Cast"). Both platforms stay gated on castUi.available, so
no visible behaviour changed. On iOS the themed chooser still opens from
the app-drawn glyph, confirmed with two real receivers listed.

Costs, accepted: tintColor is the only styling lever on the SDK button,
its connected artwork is the SDK's rather than cast-connected, and
whether the state-aware label survives a live session is unverified.

Rewrites the five tests the old design encoded rather than dropping them:
the platform split, the no-competing-JS-press-handler invariant, the
available gate on both platforms, and iOS keeping its connected glyph.
Adds NativeCastButton unit coverage for the three props that reach the
SDK. 156 suites / 2334 tests, tsc, eslint and prettier clean; iOS BUILD
SUCCEEDED; Android processDebugResources green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ur-imazing and others added 2 commits August 24, 2026 18:52
Two defects found on a physical Galaxy Tab S8 (Android 16) against two real
Chromecasts. Both were invisible to the emulator, which mangles multicast and
so never discovers a receiver.

**The Android cast button could never appear.** It was gated on
`castUi.available`, which comes from `getCastState()`. That call answers
`noDevicesAvailable` until a native `MediaRouteButton` has been attached AND
used: both Chromecasts sat in the app's own `MediaRouter` route list for
minutes at `noDevicesAvailable`, and only flipped to `notConnected` once the
dialog opened. So the signal that would reveal the button only becomes true
after the button already exists. Android now renders the SDK button
unconditionally; iOS keeps the gate, because `presentCastDialog` needs no
attached button and its state is trustworthy.

Always-visible is correct rather than a leak: in mediarouter 1.8.0-beta01
`MediaRouteButton` has no visibility logic at all and `setAlwaysVisible` is a
no-op stub, so the old auto-hide behaviour is gone.

**The dialog ground stayed stock.** The theme set `android:colorBackground`,
but an AppCompat dialog paints `android:windowBackground` —
`ThemeOverlay.AppCompat.Dark` sets both to `#303030`, so our text colours
landed while the ground did not. The stock value is a flat colour, not
`abc_dialog_material_background`, so overriding it costs no inset or corner
radius.

Verified on hardware by sampling pixels, not by eye:

| Element | Measured |
| --- | --- |
| dialog ground | `#1c1917`, 441,041 px in the panel |
| title, both route labels | `#f5f5f4` |
| stock `#303030` / `#424242` / `#d0021b` | 0 px |

The SDK button also mounts under RN 0.86 Fabric interop — `content-desc="Cast"`
and `clickable=true` in a `uiautomator` dump, which closes an open question from
the previous commit. The cast and AirPlay buttons were separately confirmed
present on a physical iPhone.

Both fixes were falsified before landing: reverting either turns three tests
red.

Also corrects three claims in `apps/mobile/CLAUDE.md` this measurement
disproved — that Android runtime behaviour was unverified, that the SDK button
does not self-hide for a reason that no longer exists, and that discovery needs
help. GMS registers its own callback with `flags=4` and the provider binds
unaided, so `MediaTransferReceiver` and a custom discovery module are both
unnecessary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Code review found four claims in the previous commit stated more strongly than
the evidence supports. The fixes themselves are unchanged and still verified.

**The `getCastState()` CAUSE was asserted without a counterfactual.** Both
`CLAUDE.md` and the code comment said the state flips only once a native button
"has been attached AND used". What was actually measured is narrower: the state
read `noDevicesAvailable` for minutes with both Chromecasts already in the app's
route list, and read `notConnected` seconds after the dialog opened. No
attached-but-never-tapped case was ever observed, so discovery latency,
foreground state and a GMS-side cache all still fit. The docs now separate the
measurement from the hypothesis and say the cause is unestablished. This changes
no behaviour: the fix only needs the signal to be untrustworthy, which the
measurement does establish.

**A stale caveat contradicted a new one.** The file still said the Android
pixel check "has not been exercised on these sheets yet" 40 lines after
claiming it had been.

**The VERIFIED claim did not name the dialog variant.** Only the CLASSIC
chooser was measured; the file's own text argues the DYNAMIC variant forces
stock white via `TextAppearance.MediaRouter.Dynamic.*` and is probably still
unthemed. The claim now says so up front instead of leaving the qualifier 37
lines away under an unrelated bullet.

**`PlayerControlsCastUi.available`'s JSDoc was false on Android** — it still
read "the button hides otherwise", which the gate removal made untrue. Two
reviewers flagged it independently.

Also anchors `#424242` to `background_floating_material_dark` and disambiguates
"441,041 px" as a matched-pixel count rather than a coordinate.

156 suites / 2335 tests, `tsc`, prettier clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant