feat: Android TV client as a tv flavor of :app:android - #3271
Draft
StageGuard wants to merge 46 commits into
Draft
feat: Android TV client as a tv flavor of :app:android#3271StageGuard wants to merge 46 commits into
StageGuard wants to merge 46 commits into
Conversation
Introduce the `tv` product flavor in :app:android (same `distribution` dimension, zero impact on phone task names / artifact paths) as the foundation of the Android TV client. Architecture doc: atv-architecture.md. - :app:shared:application: split media-cache/torrent bindings into getMediaCacheKoinModule() and add getTvCommonKoinModule() which wires a no-engine MediaCacheManager -- TV is online-only (no BT, no cache), the rest of the DI graph is shared with the phone as-is. - :app:android: move phone-only code from src/main to src/default; three-way manifest split (main = intersection, default = torrent services + oauth callback, tv = leanback entry, single process). - New TV UI library modules under app/android/ (ui-<feature>-tv naming): ui-main-tv (NavigationDrawer shell + NavHost), ui-foundation-tv (AniTvTheme via materialkolor -> tv-material mapping, TvFocusDefaults, TvScreenScaffold). - Exclude firebase/gms from tv classpath (leaks AD_ID permissions and measurement services via :utils:analytics otherwise); tv overrides app_name; DataStores bridge files moved from :app:shared to app-data. - CI: build assembleTvDebug and verifyTvManifestPurity (asserts merged tv manifest has no torrent declarations and only whitelisted permissions). Phone variant verified unchanged: merged default manifest semantically identical to pre-refactor baseline (78 elements). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add the three TV feature modules and wire the full watch flow, verified end-to-end on a real device (exploration -> details -> auto source selection -> web resolve -> ExoPlayer+libass playback -> danmaku). - ui-exploration-tv: trends hero (focus-driven backdrop crossfade) + paged recommendations, real data from Trends/RecommendationRepository. - ui-subject-tv: subject details (rating / summary / episode strip / resume button) from SubjectCollectionRepository. - ui-episode-tv: player MVP on the shared EpisodeFetchSelectPlayState orchestration with a TV extension subset (auto-select, auto-next, progress memory, playback speed; no Analytics/WatchTogether/BT-cache). DPad controls: confirm = play/pause, left/right = seek 5s, auto-hiding controls overlay with progress bar; danmaku via EpisodeDanmakuLoader. Three wiring points required for the pipeline (each fails silently without): fetchPlayState.onUIReady() starts the extension system; collecting mediaFetchSession.cumulativeResults keeps the cold fetch flow querying sources; mediaResolver.ComposeContent() attaches the WebView resolver. Captcha factory/recognizer must be registered on TV -- they serve the web source resolution chain, not comment posting. TV pins mediaSelectorSettings.preferKind = WEB at startup (own DataStore). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Complete the content surface of the TV client, verified on device: - ui-collection-tv: five-status TabRow (focus-to-select with counts, DOING first) + adaptive poster grid from subjectCollectionsPager, empty-state hint; replaces the shell placeholder. - ui-search-tv: text field (BasicTextField in a tv Surface shell, system IME with ImeAction.Search submit) + paged result grid from SubjectSearchRepository. - ui-schedule-tv: 15-day window from GetAnimeScheduleFlowUseCase, date chip row with focus-to-switch-day semantics (initial focus and scroll land on today), per-day grid with episode numbers; reachable via a new drawer entry through NavRoutes.Schedule. - ui-main-tv: shell reworked to Search/Exploration/Collection content states + schedule navigation; back always returns to exploration first. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… (M3) - ui-login-tv: two-step email OTP login (send code -> verify) on UserRepository, mirroring the phone EmailLoginViewModel semantics; drawer account entry shows nickname when logged in. Bangumi OAuth and profile editing stay cut per the online-only scope. - ui-settings-tv: settings subset (danmaku toggle; auto-play-next, auto skip OP/ED, auto switch source on error) over SettingsRepository, with a "configure on phone" placeholder for the rest and app version row. - TvTextField promoted to ui-foundation-tv; search page keeps its inline variant for now. - MainActivity provides LocalToaster backed by native Android Toast. - TvArchitectureTest (Konsist): forbids androidx.compose.material3 and phone-UI imports (whitelisted infra excepted) plus direct torrent / media-cache implementation references in all TV code. Konsist's root detection is confused by app/gradlew, so the test locates the repo root itself and uses scopeFromExternalDirectories. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Release job builds assembleTvRelease alongside assembleDefaultRelease (same job, shared cache and signing) and uploads per-arch TV APKs as workflow artifacts. - ci-helper: new uploadAndroidTvApk task; UploadAndroidApksTask gains a flavor input so TV assets are named ani-tv-<version>-<arch>.apk, avoiding collisions with phone assets. - Regenerated build.yml / release.yml from src.main.kts. In-app update for TV stays disabled (needs server-side android-tv platform support); users get TV builds from GitHub Releases for now. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Captured the reference TV app (me.him188.ani.tv) on device and matched its layout on the three core screens. Home (exploration): - Backdrop moved to a full-height right half with a horizontal fade into the surface, instead of a 16:9 block in the corner. - Hero gains the meta row (rating / airing progress / episode count / season) by loading full SubjectCollectionInfo for the focused card; buttons are now stacked vertically with leading icons. - Added the carousel indicator and the bottom key hint; poster rows are image-only, and the hero stays pinned while the card area scrolls. - Summary line count adapts to available height so short landscape screens keep the action buttons visible. Details: - Title plus original (Japanese) name. - Bottom info band in three columns like the reference: play button / season, airing progress and collection stats / tag wall / rating histogram with score and vote count. - Episode strip switched to 16:9 stills with the number and title overlaid (subject art as placeholder until TMDB episode stills land). Player: - Overlay extracted to TvPlayerControls with the reference structure: two-line title, feature capsule row, progress row with both timestamps, and a bottom icon row showing the selected source, speed and aspect. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The home hero was dropping the summary on short screens and TMDB was never actually connected -- both fixed here. - Hero always renders the subject summary now; line count still adapts to available height, and on short screens (phone landscape) the action buttons switch from a vertical stack to a row to make room instead of hiding the text. - Ported the TMDB data layer from PR#3217 unchanged: TmdbImageService, TmdbEpisodeMatcher, BangumiSummaryService and StaleRefreshGate, plus the tmdbImageCache DataStore, the `ani.tmdb.api.token` build config field and the Koin registrations. - Exploration and details pages prefer the TMDB landscape backdrop and fall back to the portrait poster crop; episode cards use TMDB stills matched by matchToEpisodes (air-date first, episode number / special title as fallbacks). TMDB stays inert until `ani.tmdb.api.token` is set in local.properties: the service returns null without a token, so every call site degrades to the existing artwork. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Verifying TV UI without a physical remote meant hand-typing adb keyevent numbers. This draws a remote panel in the terminal instead: arrow keys / Enter / Backspace map to D-pad, confirm and back, and every button is also clickable with the mouse. Beyond the D-pad it covers long-press confirm (the favourite menu gesture), media keys, volume, wake, screenshot-and-preview, ASCII text input and app launch/relaunch. Device and package are auto-detected, preferring the local debug build over the reference app. Keys are sent on background threads so the panel stays responsive, and the panel only repaints on input or state changes rather than spinning. `--selftest` validates the layout (no overlap, unique hotkeys, labels fit their boxes, CJK counted as double-width) and prints an ASCII preview without touching a device. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round three of aligning with the reference build, this time studying the PR source (per review guidance) instead of eyeballing screenshots. The reference doesn't use tv-material at all -- it's material3 plus a hand-rolled focus system -- which explains why our previous attempts never looked right. Foundation (adapted from the PR into ui-foundation-tv, android-only): - TvKeys: key-repeat based long-press detection (count KeyDowns, fire at threshold without waiting for release, only track gestures started on this node, reset on focus loss) plus consumeHeldConfirmKey for popups. - TvImmersiveCards: portrait card with the "color ring + gap" focus visual, Prime-style hero buttons (grey fill, focused = solid primary), hero text colors, smootherstep-sampled backdrop fade stops (no mach bands), page/fullscreen backdrop layers, and the shared metric set. - TvNavigationSideRail: collapsible icon rail -- 48dp collapsed column, expands with a feathered panel on focus enter, left-key-only entry gating, avatar on top, focused glyph inverts on primary. Exploration page rebuilt to the PR's carousel model: trending drives a hero with instant title + async Bangumi info/summary fallback + TMDB backdrop (all cached per subject, 300ms debounce), left/right switches the carousel on hero buttons, 6s auto-advance, elongated-pill indicator, focus moving into cards collapses the buttons and swaps the hero to the focused card. Backdrop bottom fade interpolates between hero and card states. Two real bugs found on device: material3 clickables don't participate in keyboard focus while the phone is in touch mode (requestFocus returns false; real TVs never are), fixed by forcing InputMode.Keyboard in the shell; and spatial focus search wouldn't jump from the buttons across the indicator into the card row, fixed with explicit focusProperties links like the PR does. Theme now provides both material3 and tv-material color schemes (dark-fixed) while remaining tv-material usages migrate; activity goes edge-to-edge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Details page now matches the reference first screen: full-bleed backdrop (tri-state per the PR -- while TMDB is unresolved the layout waits in "has image" form instead of flashing the poster fallback), scroll-fade with DstOut bottom erase and a left readability scrim, white hero title with marquee plus original name, and the bottom-anchored info band (continue-watching button with episode number / date, airing progress and collection stats / three-line glass tag wall / rating histogram). Episode strip keeps TMDB stills but switches to the shared color-ring focus visual. Initial focus lands on the play button (polled); focus in the episode area layers the back key to return to the hero first. Not yet ported from the PR: capsule button row, episode grid dropdown, tag menu popup, snap-on-focus sections, characters/staff/comments. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The rule encoded the v3 assumption that TV UI is built on tv-material. The reference implementation (PR#3217) doesn't use tv-material at all -- it's material3 plus a hand-rolled focus system -- and our new foundation follows it, so the ban now flags the correct design. The phone-UI-tree ban stays, with the avatar widget added to the infra whitelist for the side rail. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The rail's normal entry is a left-press, which from deep inside a card row means many keypresses. The menu key now focuses the rail's default item in one step: the shell intercepts Key.Menu at the root and requests the rail's enter FocusRequester (newly exposed as a parameter), and the rail's enter gate additionally admits programmatic focus (FocusDirection.Enter) while still cancelling up/down/right spatial entry. Expansion follows focus as usual. Verified on device from both hero-button and deep-card focus states. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Neither Compose nor androidx.tv ships a focus *framework* -- only primitives (FocusRequester, focusProperties, focusRestorer) -- and the reference PR hand-writes a scheduler per page (TvDetailsFocusAnchors, TvExplorationFocusTarget). We had started accumulating the same boilerplate in three places, so this generalizes the PR's design into a reusable scope plus modifier extensions in ui-foundation-tv: - TvFocusKey: named focus positions; pages declare a private enum. - TvFocusScope: single per-page scheduler. Every programmatic move (initial focus, back layering, popup focus return, global hotkeys) goes through request(key); one Resolver effect polls requestFocus with arrival confirmation -- current-focus-state based, with an event latch, because requestFocus return values lie for detached nodes and re-requesting an already-focused anchor yanks focus back from the user (failure modes documented upstream). - Modifier.tvFocusAnchor: requester + automatic gain/loss reporting. - Modifier.tvFocusLink: explicit directional links where spatial search is unreliable across large non-focusable gaps. - Modifier.tvFocusEnterGate: direction-whitelisted container entry. - Modifier.tvFocusHotkey: root-level key-to-anchor jumps. - scope.InitialFocus(key): page-entry focus on the same resolve path. All three existing call sites migrated and re-verified on device: shell menu-key jump, exploration initial focus + button-to-card link, details initial focus + episode-area back layering. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Completes framework adoption across the TV UI. Every page now declares its focus positions as a private TvFocusKey enum and drives them through the shared scope instead of ad-hoc FocusRequester juggling: - schedule: replaces the hand-rolled delay(300) + requestFocus with InitialFocus on the "today" chip anchor (polling + arrival check). - collection: initial focus on the first tab; tabs declare an explicit down-link to the first grid card so spatial search doesn't get lost crossing the gap (empty state simply has no anchor, which is fine). - search / login: initial focus lands in the text field; on login the anchor follows the active step (email -> otp), and focusing the BasicTextField brings up the IME on its own. - settings: initial focus on the first toggle row. - side rail: internal enter gating now uses the framework's component-level tvFocusEnterGate(FocusRequester) overload (new), removing its bespoke focusProperties/onEnter block. Verified on device: rail-key navigation into schedule lands on today, collection lands on the first tab, login focuses the email field with the keyboard shown. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the poster-grid schedule with the phone ScheduleScreen's timeline layout, reusing the phone presentation layer wholesale (D3): ScheduleViewModel now drives the TV page directly, bringing the two-week day window, time-group deduplication (showTime), the current -time indicator row and placeholder skeletons for free. The whitelist in the Konsist guard admits the schedule state-layer package; UI composables remain hand-drawn on the TV side. Layout mirrors the phone: date tab row on top (today in primary), then per-day timeline -- time heading, 56dp rounded cover, title and episode line (including the "ep (sort)" special form). TV adaptations: tabs switch days on focus instead of click+pager (content crossfades), list items focus with the unified color-ring visual, the list is width-capped left-aligned for the 10-foot screen, initial focus lands on today, and errors render a retry hero button wired to the ViewModel's restarter. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The previous port copied the Compact (narrow-screen) variant -- a date tab row switching a single column -- but the phone/desktop wide-screen schedule is actually the Medium variant: fixed-width 360dp day columns side by side with DayOfWeekHeadline headers, no tab row. Rebuilt to match, reusing ScheduleScreenState (days window + lazy list state that starts on today's column) on top of the already-shared ScheduleViewModel. On scroll control: desktop wraps the row in HorizontalScrollControlScaffoldOnDesktop, which on Android is literally a pass-through Box (Platform.Desktop branch), and the core scaffold's input model doesn't exist on TV -- button visibility is driven by mouse hover events a D-pad never produces, and the overlay buttons would steal focus if forced visible. It exists to work around lazy rows not being mouse-draggable on desktop; TV needs no workaround because cross-column focus movement already scrolls the row via BringIntoView. Documented in the screen header; verified on device (column headers with today in primary, initial focus on today's first entry, focus crossing columns auto-scrolls). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e shell Issue 1: after the menu-key jump to the rail, moving with up/down would snap focus back to the entry item, taking several presses to escape. Root cause: the resolve loop polls up to ~1.2s and its arrival check only accepts the target anchor -- focus sitting on a *neighboring* rail item counts as "not arrived", so every poll tick yanked focus back. Fix is the upstream PR's "abandon on user input" semantics: the scope counts direction-key presses (tvFocusNavSignal, mounted at every page root; tvFocusHotkey doubles as the signal on the shell) and the resolver abandons as soon as the count moves. The hotkey also ignores system key auto-repeat so holding menu can't queue repeated requests. Issue 2: schedule is now a shell content tab like exploration and collection -- the rail item switches content in place instead of navigating to a separate destination (route removed), so the rail stays visible and back returns to exploration per the shell's semantics. Verified on device: menu then rapid down x3 lands on Settings with no snap-back; schedule renders inside the shell with initial focus on today's first entry; back returns to exploration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the TV-only thin ViewModel (selected-tab flow + pager + counts) with the phone's UserCollectionsViewModel/UserCollectionsState per the D3 reuse policy, consumed the same way the desktop variant does (state-held fork LazyPagingItems + collectWithLifecycle). This brings per-tab cached paging items and grid scroll states (switching tabs no longer rebuilds the pager and loses position), phone-consistent tab ordering, and cache-clearing refresh on login change. The lateinit navigator field is phone-assembly-only and stays untouched on TV. Konsist whitelist admits the collection state-layer package; the fork paging-compose lives under androidx.paging.compose and needs no entry. Verified on device with a logged-in account: tab count badges, focus- driven tab switching and the wish-list grid all render from real data. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The loader accumulated several rough edges: - StateFlow<SubjectDetailsUIState?> forced every consumer to handle "null or Placeholder" as two spellings of the same empty state. - load() returned a Job, faking a CompletableDeferred for the already-loaded case -- no call site ever used the return value. - reload() was clear()+load(), and SearchViewModel hand-rolled that same combination anyway, so three entry points expressed one thing. - clear() cancelled the task but left the previous subject's Ok state behind (stale details after clearing a search). - Error retry required callers to restate subjectId/placeholder that the Err state already carries. Now: the state flow is non-null (idle = Placeholder), load(force) is the single entry point (MonoTasker already cancels in-flight work, no pre-clear needed), retry() reads its target from the Err state, and clear() actually resets. Consumers dropped their null branches and explicit initial values; SubjectDetailsScreen's state parameter became non-null. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extends the D3 reuse policy to the remaining pages, deleting three TV-only ViewModels: - login: EmailLoginViewModel (ui-onboarding) provides email state, the resend cooldown (nextResendTime, rendered as a countdown button) and existing-account detection; step flow and error display stay TV-side. The shell reads the avatar's login state straight from UserRepository. - exploration: ExplorationPageViewModel/ExplorationPageState provide the trending pager (state-held fork LazyPagingItems) and recommendation pager; the TV-specific hero cache pipeline (info / TMDB backdrop / bgm.tv summary fallback, 300ms debounce) moves into the screen as the upstream PR does it. - details: SubjectDetailsViewModel + SubjectDetailsState drive the page (info, episode list ui state, SubjectProgressState.episodeIdToPlay for the continue-watching button -- phone-identical resume semantics, verified showing "第 06 话" from real progress). TMDB backdrop/stills stay page-side. Err state renders a retry button on the new loader.retry() path. Settings keeps its 5-line pass-through VM (the phone settings VM is a full multi-tab state machine, wrong tool for four toggles); the player keeps its TV VM until the PR's EpisodeScreenVariant seam is adopted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Second alignment pass on subject details, filling in what the first (hero-only) pass left out: - Episodes now form a full page like the PR: full title, the summary block (truncated, non-focusable per TV convention, with an expand button opening a scrollable dialog) with the portrait cover anchored top-end, then the episode still carousel. - Characters and staff rows (circular avatar cards with voice actor / position subtitles -- position via PersonPosition.nameCn, not the enum name), related subjects (portrait cards with the relation rendered in Chinese, navigating to the related subject), and comment cards (nickname, star rating, plain text extracted from the rich-text elements, 4-line clamp). All data comes from the reused SubjectDetailsState pagers; every card uses the shared color-ring focus visual. - Back layering is now three levels per the PR: below-sections return to the episode carousel (via the focus scope), episodes return to the hero play button, hero exits. Still not ported: capsule button row, episode grid dropdown, tag menu, snap-on-focus scrolling, per-episode runtime/overview from TMDB. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ect play Three user-reported issues on the home exploration page: 1. The first card row duplicated the hero (both were trending). It is now a "continue watching" row fed by the phone's followedSubjectsPager (in-watch subjects), and "for you" becomes a vertical grid (fixed columns of portrait cards) instead of a second horizontal row -- matching the phone layout. Per user direction the whole page is one LazyColumn: hero, the followed row and the grid scroll together. 2. Up from the first card row couldn't reach the hero buttons. Root cause: with focus in the cards, heroFocused=false removes the button block from composition entirely, so both spatial search and direct focus links target nothing. The followed row (a single horizontal row, so intercepting vertical keys is safe) now handles Up by re-expanding the hero first and then routing through the focus scope's polling resolver, which tolerates the re-attach delay. 3. "立即观看" now plays directly: it resolves the episode from the hero cache (progressInfo.nextEpisodeIdToPlay, else first episode) and navigates straight to the player, falling back to the details page only when episode info hasn't loaded yet. Verified on device: from episode 5 watched, the button opened the player on episode 6. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ro item Two follow-ups on the single-LazyColumn exploration page: 1. Up from the cards still couldn't reach the hero: once the page scrolls, the hero item gets recycled by the LazyColumn, so re-expanding the button block changed nothing -- the item itself wasn't composed. The up-key handler now scrolls the list back to item 0 first; the focus scope's polling resolver rides out the recomposition and lands on the play button. Verified after six rows of scrolling. 2. The backdrop stayed fixed at the root while the hero scrolled away under it. It now lives inside the hero item (fillParentMaxHeight for the 0.66 screen fraction) and scrolls with it. The wrapper needed an explicit fillMaxWidth -- without it the box shrinks to the image width, TopEnd alignment is meaningless and the image parks over the title. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Play and details buttons share one Row (dropping the vertical stack and with it the left/right carousel-switch key handling, which would have conflicted with in-row button navigation; auto-advance remains). - The carousel indicator sits at the hero bottom, centered within the info column width. - Buttons and indicator are always composed instead of hiding behind heroFocused -- the fixed-height info block had been pushing them out of the clipped hero box, which is why they vanished. - Summary takes weight(1f) between the meta row and the buttons. - Backdrop size restored: the outer hero box already caps height at 0.66 of the viewport, and the inner box was applying the same fraction again (0.44 effective). Inner box now fills its parent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per review: drop the play button (its direct-play duty lives on in the details page's continue-watching button), unwrap the Row, and since a single button has no in-row navigation to conflict with, restore left/right carousel switching on it (first item still lets Left fall through to the side rail). The Play focus anchor moves to the details button, keeping initial focus and the back-to-hero paths intact; the shell's direct-play navigation branch is removed with the callback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three review points on exploration: - Continue-watching and recommendation cards show the subject title under the cover (single line, ellipsized). - Focusing a card no longer swaps the hero: the hero is driven solely by the trending carousel (focusedCardSubject machinery removed); card focus only affects the backdrop fade state. - The "for you" grid adapts like the phone's GridCells.Adaptive: column count derives from available row width (BoxWithConstraints) and cards stretch to share the row equally via weight, so a fixed card width can no longer squeeze the rightmost item; trailing empty slots are held by weighted spacers to keep the last row's card size. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cus return - TvPosterCard: focused title switches to marquee scrolling (full title readable); width becomes nullable for weight-based adaptive grids - Exploration continue-watching/recommendation cards and details related cards now use the collection page's TvPosterCard style (title inside card); TvTitledCardColumn removed - Collection: anchor moves to the currently selected tab; Up from the grid and Back inside the grid both return focus to the current tab (new Modifier.tvFocusExit for direction-mapped exit redirects) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
At the grid's left/right edge, pressing left/right selects the adjacent collection tab and focuses the corresponding position in its list (same row, near-edge column; clamped to the last item). On the first tab the left edge falls through to the navigation rail (expands on focus). Implementation: grid-level onPreviewKeyEvent computes edge/row/column from LazyGridState.layoutInfo; a pendingFocusIndex drives a dynamic EdgeEntryCard anchor in the new grid (scroll into view + framework polling). Tab focus-select is frozen while the switch is in flight — disposing the focused card makes focus momentarily fall onto the first tab, which would otherwise steal the selection (found on device). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ration TvNavRailItem gains a selected flag marking the item for the currently shown page; the rail's enter-focus requester attaches to the selected item (falling back to the defaultFocus one). Menu key and left-edge entry share the same requester, so both now land on the current page's item. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Follow the Compose XXXDefaults convention (CardDefaults/NavigationRailDefaults): - ui-foundation-tv: TvBackdropDefaults/TvHeroDefaults/TvPageDefaults/ TvPortraitCardDefaults (TvImmersiveCards.kt), TvPosterCardDefaults, TvNavigationRailDefaults absorbs TV_RAIL_*; TvFocusDefaults exposes RingWidth/RingInset/RingCornerRadius as the single source for the focus ring spec, and hand-drawn rings (details/schedule cards) now read from it. TvHeroButton gains a shape parameter defaulting to TvHeroDefaults.ButtonShape; TvPosterCard width defaults to TvPosterCardDefaults.Width. - pages: private TvExplorationDefaults/TvScheduleDefaults/ TvSubjectDetailsDefaults/TvCollectionDefaults/TvSearchDefaults for page-local tuning; shared grid metrics (cell width/spacing/padding) deduplicated through TvPageDefaults. - drop dead exploration constants (TV_HERO_INFO_HEIGHT etc). No behavior change: all values are identical. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mirror the phone Compose organization (CollectionPageLayout / SubjectDetailsPageLayout / EmailLoginScreenLayout slot pattern, recommendationItems-style LazyScope sections): - exploration: TvExplorationPageLayout (immersive chrome + single LazyColumn) + tvContinueWatchingSection/tvRecommendationsSection; hero visuals move to TvExplorationHero.kt (backdrop/status row/ indicator), focus keys and carousel key handling stay in the screen and are injected via buttonModifier; hero media loading hoisted into TvHeroMediaState. - collection: TvCollectionPageLayout + TvCollectionTabRow + TvCollectionGrid + TvCollectionEmptyPlaceholder; edge tab-switch key logic stays in the screen (owns focusedCardIndex/pendingFocusIndex). - search: TvSearchPageLayout + TvSearchField + TvSearchResultsGrid. - login: TvLoginPageLayout + TvLoginStepSection (flat emission keeps the tree identical). - settings: TvSettingsPageLayout wraps the scroll column and title. - subject details: TvSubjectDetailsPageLayout (backdrop slot + scroll column + heroHeight) + TvDetailsHeroSection/TvDetailsEpisodesSection/ TvDetailsBelowSections; card composables move to TvSubjectDetailsCards.kt. Pure structural move: item keys, modifier chains, focus wiring and effect keying are byte-for-byte preserved; no behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Record the two conventions established in this round: XXXDefaults objects for tuning constants (with parameterization guidance) and the Screen -> PageLayout -> section skeleton pattern, both anchored to existing phone Compose precedents. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three fixes from an end-to-end focus-path walk of home and subject details on an Android TV emulator (Android 16, 4K): - Rail item click no longer clears focus. Clicking the item for the page already shown left focus dangling with no InitialFocus to take over, and with no focus owner Compose stops dispatching key events entirely -- the app went dead to the remote. Focus now moves right into the content instead (page switches still get adopted by the new page's InitialFocus). - Details: Play -> expand-summary -> episode carousel is now an explicit focus chain (new ExpandSummary anchor). The spans are a full screen with horizontal misalignment, where spatial search proved unreliable (long-summary subjects could not leave the Play button). - Details: Up from any episode card returns to expand-summary via a tvFocusExit redirect; previously non-first cards had no Up target at all (no horizontally overlapping candidate above). Verified on the TV emulator and the phone; regressions green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The shell's Rail key was only handed to the rail as a raw FocusRequester attached to the entry item; nothing reported anchor focus back to the scope, so request(Rail) from the menu hotkey could never confirm arrival and burned all 40 polling attempts (seconds on slow devices). Confirm presses are not direction keys, so clicking the current page's item moved focus into the content just for the next poll tick to yank it back to the rail -- two or three clicks were needed to escape. - The Rail anchor now sits on the rail container: requestFocus enters via the enter gate onto the selected item, and hasFocus reports arrival for the whole subtree, so the resolver converges on the first tick. - Confirm keys (DPad center / Enter / NumPad enter) now count as user interaction and abandon any in-flight resolve, closing this class of race for all pages. Verified on the remote TV emulator: a single click on the current page's rail item lands focus in the content with no yank-back at 1s and 4s; menu-key landing still follows the current page. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Entering the rail (menu key or left edge) and coming back now returns focus to the exact item that was focused before, on three paths: clicking the current page's rail item, pressing menu again, and pressing back while the rail has focus (the latter two also collapse the rail). Previously focus landed on the geometrically nearest node. Mechanism is a new TvFocusMemory in the focus framework: the shell provides it to the content subtree via a CompositionLocal, focusable foundation components (TvPosterCard, TvHeroButton) report their own FocusRequester on focus, and the shell restores via requestFocus. Page switches clear the memory so the new page's InitialFocus takes over. Compose's saveFocusedChild/focusRestorer could not be used: they only save the first-level child target, which across the shell -> AnimatedContent -> page -> lazy-list hierarchy is a non-focusable intermediate container, so restoration always fails (verified same-frame save=true/restore=false on the TV emulator). Menu becomes a toggle via the new Modifier.tvFocusHotkeyToggle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Opening a subject from any shell page and pressing back now returns focus to the exact card that was clicked. The previous focus memory only held a FocusRequester, which dies with the route's composition; TvFocusMemory now also records a stable identity key (memoryId, e.g. a prefixed subjectId) reported by the focused component. The memory instance moves above the NavHost so it survives route changes; when the Main route recomposes, the shell arms the last identity as a pending restore target, the matching component claims it during composition, and the page's InitialFocus consumes the claim -- polling focus onto the recreated node, falling back to the default initial anchor when the target no longer exists. Verified on the TV emulator from exploration (recommendation grid, after browsing details sub-sections) and from the collection page. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The externally-titled card path lost its last caller when subject cards unified on TvPosterCard; its focus-report extra callback also proved unreliable in device testing. tvLongPressKey stays -- it backs the planned long-press collection menus. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TvFocusMemory moves to its own file owning the whole save/arm/claim/ consume protocol, with the participants and lifecycle documented in one place. Components now wire in with a single Modifier.tvFocusMemorable instead of hand-rolled requester/report/claim code, and the shell arms route-return restoration via memory.ArmOnRouteReturn(). No behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ives TvFocusGrid.kt adds focus-nth-item and edge-switch primitives (state + item/keys modifiers + send-focus effect) with the protocol documented in one file; the collection page drops its ~90 lines of hand-rolled focus logic for declarative wiring. Freeze-while-switching and the empty-tab fallback stay at the page via the switching flag. No behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Konsist now enforces two rules that previously lived only in comments: files owning a TvFocusScope must install Resolver + a nav signal, and raw requesterOf interop is framework-internal (bare requesters have no anchor reporting, so resolve polling never converges -- a real incident). Pure-logic unit tests cover resolveFocusRepeatedly's arrived/abandon semantics and the TvFocusMemory protocol state machine. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The user mandated that focus handling must not rest on timing guesses. All polling and delay sites are replaced with deterministic signals (sections 14.4-8 of atv-architecture.md; Konsist now forbids delay / withFrameNanos under the focus framework directory): - resolveFocusRepeatedly (40-tick polling) is gone. tvFocusAnchor now reports node attach/detach -- the missing Compose event -- and the Resolver reacts to "pending request && target attached" via snapshotFlow: attached targets get focus immediately, recycled ones the instant they re-attach. Single-shot, no retries, user keys cancel. - InitialFocus loses its 300ms layout delay. Cross-route restore is gated on the route's Lifecycle RESUMED event instead: claims register at composition and the restore fires when the return transition completes (granting during the transition gets wiped by its wind-down -- the real event the old delay happened to mask). Late-arriving data restores instantly once live; user interaction cancels. - Grid edge-switch drops its 1500ms timeout: pending clears on focus arrival, on user interaction (nav-generation snapshot), or on the caller's definitive-empty signal. The empty-tab fallback now observes Paging LoadState instead of waiting 800ms. - Edge-switch "corresponding position" is now resolved against the destination grid's actual column count (layout snapshot event); the source-grid approximation landed on the wrong column when tabs had different column counts. - Shell clears focus memory only on real tab changes (snapshotFlow, drop first) -- clearing on route-return recomposition destroyed the just-armed restore. Unit tests cover the scope/memory/grid state machines; the full focus E2E matrix re-verified on the TV emulator. Co-Authored-By: Claude Fable 5 <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.
概要
为
:app:android增加tv产品变体,实现 Android TV 客户端。UI/UX 以 #3217 的实机效果为参照(研读其源码理解布局后自行实现,低层基建改造自该 PR),状态层最大化复用手机端 ViewModel / state objects。全部功能已在真机(Meizu 18X + adb 遥控)逐轮验证。
架构
getTvCommonKoinModule,空媒体缓存引擎、TV 不装配 BT/缓存);verifyTvManifestPurity守护 TV 清单纯净(无 firebase/gms)。app/android/ui-<feature>-tv(foundation/main/exploration/subject/episode/collection/search/schedule/login/settings),src/tv只留出包胶水。UserCollectionsViewModel、ScheduleViewModel+ScheduleScreenState、EmailLoginViewModel、ExplorationPageViewModel/State、SubjectDetailsViewModel/State等;顺带重构了SubjectDetailsStateLoader(非空状态流、单一load(force)入口、clear()真清态,6 个消费者适配)。TvFocusScope(ui-foundation-tv/focus/):页面用TvFocusKey枚举声明锚点,Modifier.tvFocusAnchor / tvFocusLink / tvFocusEnterGate / tvFocusExit / tvFocusHotkey声明层级与去向;request(key)轮询送焦 + 到位确认 + 用户按键即放弃(不与用户抢焦点)。这是真机上多轮调出来的焦点稳定性方案(LazyColumn 回收、touch-mode、跨大间距空间搜索不可靠等坑均有注释)。TmdbImageService/TmdbEpisodeMatcher,探索/详情 backdrop 与选集剧照优先 TMDB,未配 token 全链路静默退化。需在local.properties配ani.tmdb.api.token。页面
ani-tv-*APK(M4;应用内更新按维护者意见暂缓)。遗留(后续 PR)
EpisodeScreenVariant状态复用SearchViewModel/PagingSearchState🤖 Generated with Claude Code