feat(tv): focus-driven hero swaps to the selected experience's video#803
Merged
Ur-imazing merged 21 commits intomainfrom Apr 20, 2026
Merged
feat(tv): focus-driven hero swaps to the selected experience's video#803Ur-imazing merged 21 commits intomainfrom
Ur-imazing merged 21 commits intomainfrom
Conversation
…n hero Extend the home-screen listing query to carry each experience's first ComponentSectionsVideoHero block alongside its lightweight fields so the rail-driven hero can swap without a second round-trip per focus change. Non-VideoHero blocks are returned with __typename only. Plan: docs/plans/2026-04-17-001-feat-tv-focus-driven-hero-plan.md (Unit 1)
Surface per-item focus events to rail consumers so the home screen can drive a focus-driven hero. Preserves existing focusMemory write behavior; the new prop is optional and additive. Plan: docs/plans/2026-04-17-001-feat-tv-focus-driven-hero-plan.md (Unit 2)
Rewrite HomeHero to accept a single hero prop and cross-dissolve between previous and current media via two stacked layers. The Explore CTA lives in a stable text overlay (not a crossfading layer) so its first-mount focus claim and identity survive hero swaps. Honors AccessibilityInfo reduce-motion by snapping between states. In HomeScreen, replace the dual LIST_EXPERIENCES + GET_WATCH_EXPERIENCE queries with the extended LIST_EXPERIENCES (now includes each experience's first VideoHero block). A 300ms trailing-only debounce timer holds a committed-experience id; rail focus resets the timer, commit fires on timeout. Initial render seeds the committed id to isHomepage. Explore CTA targets whichever experience the hero currently reflects, never a transiently-focused card. Accessibility: after commit, dispatch AccessibilityInfo.announceForAccessibility with the new hero's title + subtitle, guarded against duplicate announcements when focus returns to the already-committed card. Plan: docs/plans/2026-04-17-001-feat-tv-focus-driven-hero-plan.md (Units 3-6)
The wrapper View's onFocus does not reliably fire on react-native-tvos when a nested Pressable inside FocusableCard gains focus, so the focus-driven hero swap never triggered on real hardware. Pass an explicit focus hook into renderItem and plumb it straight into FocusableCard's onFocus prop, where it fires deterministically. Keep the wrapper View's onFocus as a fallback so existing callers that don't consume the hook still get focusMemory updates.
When the focus-driven hero swaps to a new experience's streaming URL, the native VideoView surface renders black while HLS loads the manifest, estimates bandwidth, and decodes the first frame — often 200–800ms on TV hardware, and unmaskable on Android TV where the VideoView punches through the RN view hierarchy. Render the poster image as a base layer that always paints first, and only mount the VideoView once the player reports readyToPlay. When the video is ready, fade it in over the poster in 200ms. The user never sees a black hero during a swap.
Replace the prev/current slot pattern with a keyed layer stack so each MediaLayer stays mounted across a hero commit. The outgoing layer now stays where it was — its VideoView keeps painting the video's last frame during the fade instead of re-mounting against the outgoing experience's poster image (which was the jarring mid-transition still the user was seeing). Pause the outgoing player on deactivate so the painted frame freezes instead of continuing to animate during the fade. On the incoming layer, after the player reports readyToPlay hold the poster visible for 1s, then crossfade to the video over 500ms. This gives the eye a single stable still between the outgoing and incoming videos rather than a rapid-fire video→still→still→video sequence. Reduce Motion skips the hold and snaps.
…n detail Home: propagate pointerEvents="none" to every wrapper around the VideoView inside MediaLayer so the TV focus engine doesn't stop on the native video surface when the user D-pads up out of the rail. Focus now consistently lands on the Explore CTA whether the hero's video is playing or still loading as a poster. Experience detail: the hero was non-focusable, which prevented the user from scrolling back to the top of the screen once they moved down into the blocks. Add a full-bleed Pressable behind the text overlay as a silent focus target — invisible focus state, but acceptable as a D-pad UP landing spot so ScrollView can scroll the hero back into view.
The previous guide only wrapped the text container at the bottom of the hero. When D-padding UP from the rail, focus tried to land somewhere in the video region above the guide — the native VideoView caught it and the Explore button was never reached while the video was mounted. Wrap the entire hero container in TVFocusGuideView so any upward focus attempt into the hero area is redirected to the Explore Pressable, regardless of whether the video is mounted or not.
trapFocusDown=false on the hero TVFocusGuideView so DOWN from Explore exits the hero region in a single press instead of bouncing off the guide's bounds once.
…dant Explore Pressable was inside the TVFocusGuideView, so DOWN from Explore bounced off the guide's bounds once and required a second press to reach the rail. Move the guide to wrap only the media layers and gradient so Explore sits as a sibling in the view hierarchy. The guide still catches upward focus attempts into the media region and redirects them to Explore, but no longer traps Explore's own outbound DOWN movement.
trapFocusDown on the hero's TVFocusGuideView didn't prevent Explore's outbound DOWN from bouncing off the guide's bounds, so the rail required two presses. Plumb the rail's TVFocusGuideView handle up to HomeScreen, pass it into HomeHero as `nextFocusDownHandle`, and bind it to Explore's `nextFocusDown` prop. Focus now crosses directly from Explore into the rail on a single D-pad press without any guide bounce. Verified end-to-end via keystroke-driven screenshots on the tvOS simulator: DOWN → rail focus, UP → Explore focus, DOWN again → rail focus on first press. Also switch Explore's ref to a callback ref + state-backed node handle so the hero's focus-guide destinations always resolve on first render (React commits refs after render, so a render-time read of `exploreRef.current` was null initially and left the guide without a destination). Route Explore's node handle through a callback-ref so the hero focus guide has a valid destination from first render.
Brainstorm requirements and implementation plan that drove the focus-driven hero feature shipped in the preceding tv commits.
Pre-fix: ContentRail's TVFocusGuideView autoFocus claimed initial focus for the first rail card, overriding the Explore Pressable's hasTVPreferredFocus. Pressing UP or DOWN from Explore could leave focus stranded in the hero's non-focusable video region. Changes: - Drop autoFocus from ContentRail's TVFocusGuideView so Explore's hasTVPreferredFocus wins on first mount. - Wrap the hero's text overlay in a TVFocusGuideView with trapFocusUp + autoFocus so UP from Explore keeps Explore focused instead of leaking focus into the video area. - Plumb the Explore Pressable's native handle up through onExploreHandleChange so each rail card gets nextFocusUp routed directly to Explore. - Keep nextFocusDown on Explore so DOWN moves into the rail. - Make Explore's focused state visible: add a white border + glow on top of the 1.08x scale, since tvOS Pressable shadows in the button's own primary color were nearly invisible against the hero gradient. - Disable ScrollView scroll on the home screen (content fits and scroll attempts were blurring Explore on UP).
Move the `trapFocusUp` TVFocusGuideView from the text container up to the hero container. Wrapping only the text container made the guide's frame tight enough that the first DOWN press got absorbed by the guide instead of routing through Explore's `nextFocusDown` to the rail. Wrapping the entire hero keeps UP trapped inside the hero (no focusable above Explore in the hero — so UP becomes a no-op), while DOWN from Explore exits the hero boundary in a single press and lands on the rail via the `nextFocusDown` handle. Verified on tvOS simulator via keystroke-driven screenshots: - Initial: Explore visibly focused - UP from Explore: stays on Explore - DOWN from Explore: first press focuses a rail card - UP from rail: returns to Explore
When the hero's native VideoView is actively painting, the tvOS
focus engine treats it as a focus candidate even with
focusable={false} on VideoView itself. This caused UP/DOWN from
Explore to blur into limbo while the video played.
Layer the guards so tvOS reliably skips over the video surface:
- isTVSelectable={false} on every View/Animated.View wrapping the
media layers inside the hero (outer and inner).
- Self-referencing nextFocusUp on the Explore Pressable so UP from
Explore resolves back to Explore instead of the video above.
- Retain the outer hero TVFocusGuideView trapFocusUp as a second
line of defense.
DOWN from Explore now reaches a rail card in a single press even
with the video playing. UP from rail into Explore and initial focus
on Explore are preserved.
Remove the Explore CTA and all the focus-routing plumbing that tried
to coordinate focus between it and the rail. The native VideoView
kept hijacking focus away from any interactive element placed above
the rail, which forced an ever-growing list of guards (nextFocusUp
self-references, TVFocusGuideView trapFocusUp wrappers,
isTVSelectable={false} on every wrapper, state-backed node handles,
etc.) and still wasn't robust once the video was painting.
New model:
- HomeHero is now purely presentational — title, subtitle, and the
crossfading video/poster. No Pressable, no focus concerns.
- ContentRail's TVFocusGuideView autoFocus claims initial focus on
the first card. The user navigates experiences by D-padding the
rail and pressing Select to open the focused experience.
- Drop the Explore-related props from HomeHero/ContentRail/FocusableCard
(nextFocusUp, nextFocusDown, onExploreHandleChange,
onFocusHandleChange, itemNextFocusUp).
- Re-enable ScrollView scroll on the home screen.
Net effect: something is always visibly focused (the rail auto-focuses
on mount), the video hero no longer competes for focus, and the
whole focus system is dramatically simpler.
- ContentRail: pass `item` directly through the renderItem hooks callback instead of looking it up via `data[index]` at callback time. FlatList fires focus callbacks asynchronously and an Apollo cache update can shrink `data` between render and callback, making `data[index]` undefined and crashing consumers that read `item.documentId` (review finding P1 #2). - ContentRail: remove the redundant wrapper `<View onFocus>`. Both the wrapper and `hooks.onFocus` resolved to the same `handleItemFocus(index)` call and fired per focus event on platforms where View focus bubbles, double-dispatching the debounce timer and any analytics (P2 #5). - ContentRail: drop the dead `focusMemory` module-level Map. It was written on every focus event but never read anywhere in the codebase (P3 #11). - HomeHero: move `HeroEntry` type to module scope — it was defined inside the component body for no reason (P3 #16). - HomeScreen: replace the local `COLORS` constant that duplicated `src/lib/colors.ts`. Every other component imports the canonical tokens; `index.tsx` was silently drifting (P3 #13). - HomeScreen: derive `effectiveCommittedId = committedId ?? homepageExperience?.documentId ?? null` so the hero renders on first paint rather than waiting for the `useEffect`-seeded `committedId`. Previously a blank hero surface flashed for ~50-100ms on cold mount while the effect fired (P2 #9). - HomeScreen: depend the accessibility-announce effect on `hero?.id`/title/subtitle rather than the `hero` object reference, so Apollo cache re-normalisations that produce a new object identity for the same experience don't trigger spurious re-runs (P3 #14). - queries.ts: remove orphaned comment referencing GET_WATCH_EXPERIENCE that trailed LIST_EXPERIENCES after the surrounding context was moved (P3 #12).
Home hero now renders the VideoHero block of whichever Experience card is focused in the rail, so the background poster/video tracks D-pad selection instead of being hard-coded. Adds compile-time asserts that gql.tada's discriminated union for the blocks dynamic zone did not collapse to `never`, since that failure mode is silent under tsc. Documents the intentional divergence from the mobile LIST_EXPERIENCES shape so future sync passes don't clobber the per-experience hero data. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
🚅 Deployed to the forge-pr-803 environment in forge
|
…e cluster Adds a new best-practices learning documenting the patterns that came out of PR #803 (non-interactive hero, rail-owns-focus, poster-hold during HLS source swap, compile-time `never`-collapse assert for gql.tada dynamic zones), and refreshes four adjacent docs whose guidance was either superseded or incomplete in light of it: - ui-bugs/tv-videoview-steals-dpad-focus — Prevention superseded the "wrap the hero in TVFocusGuideView" recommendation for hero-above-rail layouts; now points at the new learning. - ui-bugs/tv-video-hero-blank-autoplay — adds a "Source swap on focus change" section covering the poster-hold technique. - best-practices/react-native-tvos-porting-pitfalls — adds Pitfall 6 (background VideoView + focusable siblings). - best-practices/expo-tv-platform-setup-sdui-monorepo — Section 3 notes the LIST_EXPERIENCES divergence; Section 6 adds the rail-owns-focus pattern. Also drops a duplicate last_updated key. Co-Authored-By: Claude Opus 4.7 (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.
Summary
The TV home hero now tracks rail focus: as the user moves between experience cards, the hero's poster, title, description and HLS video swap to the focused experience. The hero itself is non-interactive — the rail owns all focus — which fixes a cluster of focus bugs we were chasing around
VideoView,TVFocusGuideView, and back-navigation.What changes
LIST_EXPERIENCES— each experience now ships its ownComponentSectionsVideoHeroblock (apps/tv/src/lib/queries.ts), intentionally diverging from mobile's lighter shape. A header comment warns against re-syncing from mobile without reading.ContentRailgained anonItemFocusprop;HomeHerosubscribes and swaps source on focus change, with a poster-hold during HLS init so the background never flashes black.TVFocusGuideViewis gone fromHomeHero; focus enters/exits via the rail and theExplorebutton. Fixes the "focus gets stuck in the playing video" regression that Android TV'sVideoViewz-order was causing.apps/tv/app/index.tsxguard against silentVideoHeroBlock→nevercollapse if gql.tada's dynamic-zone union ever regresses.Why
On TV the hero used to be hard-coded to the first experience, so the 10-foot background never matched what the user was browsing. The fix looked small ("swap the video source") but ran into every Android TV focus pitfall listed in
apps/tv/CLAUDE.md:VideoViewrendering above RN, focus getting trapped on the player, UP from the rail landing inside the hero, DOWN from Explore needing two presses. The commit log reads as the iteration trail through those.Verification
pnpm turbo run typecheck lint --filter=@forge/tv— PASSpnpm turbo run test --filter=@forge/tv— 3 suites / 38 tests PASS/qapipeline on a temp branch with PR feat: cross-platform local QA pipeline with Playwright, Maestro, and TV YAML runner #795 merged: Layer 4a Android TV 38/38 flows passed; tvOS run in progress when aborted for unrelated reasons.Test plan
Explore, not inside the hero; DOWN fromExplorelands on the rail in one press.VideoViewnever captures focus while playing on Android TV.🤖 Generated with Claude Code