feat(mobile): style every Google Cast SDK sheet in the app palette - #1990
Open
Ur-imazing wants to merge 6 commits into
Open
feat(mobile): style every Google Cast SDK sheet in the app palette#1990Ur-imazing wants to merge 6 commits into
Ur-imazing wants to merge 6 commits into
Conversation
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-castexposes no styling API andapps/mobile/ios+androidare 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.jsinjects aGCKUIStyleblock intoAppDelegate.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.jswritesmediaRouteThemeand thecast*Styleitems ontoAppTheme, because every cast dialog resolves its theme from the Activity, not from the cast button'sContextThemeWrapper.Android's cast control is now the SDK's own button (
src/lib/cast/NativeCastButton.tsx), in the sameFrostedbackplate the AirPlay picker already uses.showCastDialog()can only click a nativeMediaRouteButtonthat 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 fromGCKCastContextdirectly, so it keeps the app-drawn glyph, itscast-connectedvariant 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-runsinitDefaultFonts, 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 fromgetCastState(). That call was measured answeringnoDevicesAvailablefor minutes while both Chromecasts already sat in the app's ownMediaRouterroute list; it readnotConnectedseconds after the chooser was opened. The gate therefore never opened. Android now renders the SDK button unconditionally; iOS keeps its gate, becausepresentCastDialogneeds no attached button and its state is trustworthy.This was a pre-existing bug, not a regression from this PR — verified at
4337a352dthat 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
mediarouter1.8.0-beta01MediaRouteButtonhas no visibility logic at all andsetAlwaysVisible(boolean)is a no-op stub (0: returnin its bytecode), so the SDK's old auto-hide behaviour is gone.2. The chooser dialog's ground stayed stock
#303030. The theme setandroid:colorBackground, but an AppCompat dialog paintsandroid:windowBackground—ThemeOverlay.AppCompat.Darksets both to#303030, so our text colours landed while the ground did not. OverridingwindowBackgroundcosts no dialog inset or corner radius, because the stock value is a flat colour rather thanabc_dialog_material_background.Three facts worth knowing before editing this
expo prebuildREUSES an existingios/rather than recreating it, so a name-only sentinel made an edited block look already-applied and kept building the previous palette.apply(), not the header'sapplyStyle— Swift renames the selector. Only a real compile catches it; the tests pinned the header spelling and stayed green while the build failed.deviceChooserdoes not own the chooser's own title or Cancel button._styleAttributesForNavigationis captured once inviewDidLoadfromconnectionController, so both sheets' nav bars come fromdeviceControl.connectionController.navigation.Verification
Both platforms were verified by sampling pixel values out of screenshots rather than judging by eye — the stock cast red
#D0021Band our#CB333Bpass a glance and fail the design system.iOS (simulator, against a real Chromecast):
#1c1917#f5f5f4#a8a29e#f5f5f4#0a84ffanywhere#d0021banywhereThe 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):
#1c1917#f5f5f4#303030/#424242/#d0021b<CastButton>mountscontent-desc="Cast",clickable=trueuiautomator dumpThe 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; iOSBUILD SUCCEEDED; AndroidassembleDebuggreen.Reviewed
Tier-2
/ce-code-reviewran 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
88d3796b9and four dropped as false or preference-only. Findings it caught that were shipping green: guttingforgeApplyCastBasepassed every test while every cast surface reverted to Google's palette;volumeSliderMaximumTrackTintColorhad no test at all; neither plugin's exported entry point ran under jest; and Android theme item values were largely unasserted (aapt2checks that a@colorreference 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 thegetCastState()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
Theme.MediaRouterpointsmediaRouteBodyTextAppearance/mediaRouteHeaderTextAppearanceatTextAppearance.MediaRouter.Dynamic.*, which hardcodeandroid:textColorto#FFFFFFand#BDC1C6— a text appearance's owntextColorbeats the theme-leveltextColorPrimarythis 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.values-night/carries no cast resources, so the explicit hex should win in either mode.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
apps/mobileCI is ubuntu-only and itsbuildscript is a no-op echo, so jest string-pinning is the only automated gate on generated native code — which is exactly how theapplyStyleerror reached a build, and how the wrong background attribute reached a device. A macOS job runningexpo prebuild --cleanplus a Debugxcodebuildwould close the iOS half; nothing currently would have caught the Android half. That is recurring runner cost and a deliberate omission.getCastState()cause is still open. The discriminating experiment is a button attached but never tapped.apps/tvhas 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