Skip to content

feat: Android TV client as a tv flavor of :app:android - #3271

Draft
StageGuard wants to merge 46 commits into
mainfrom
tv/m0-bootstrap-and-flavor
Draft

feat: Android TV client as a tv flavor of :app:android#3271
StageGuard wants to merge 46 commits into
mainfrom
tv/m0-bootstrap-and-flavor

Conversation

@StageGuard

Copy link
Copy Markdown
Member

概要

:app:android 增加 tv 产品变体,实现 Android TV 客户端。UI/UX 以 #3217 的实机效果为参照(研读其源码理解布局后自行实现,低层基建改造自该 PR),状态层最大化复用手机端 ViewModel / state objects。

全部功能已在真机(Meizu 18X + adb 遥控)逐轮验证。

架构

  • v3「约定边界」(与维护者确认):双 flavor 共享完整依赖树,差异收敛为 DI 装配门控(getTvCommonKoinModule,空媒体缓存引擎、TV 不装配 BT/缓存);verifyTvManifestPurity 守护 TV 清单纯净(无 firebase/gms)。
  • TV UI 模块化app/android/ui-<feature>-tv(foundation/main/exploration/subject/episode/collection/search/schedule/login/settings),src/tv 只留出包胶水。
  • Konsist 守护:TV 模块禁 import 手机 composable,状态层白名单放行(ViewModel/State 复用是明确方向)。
  • 状态层复用UserCollectionsViewModelScheduleViewModel+ScheduleScreenStateEmailLoginViewModelExplorationPageViewModel/StateSubjectDetailsViewModel/State 等;顺带重构了 SubjectDetailsStateLoader(非空状态流、单一 load(force) 入口、clear() 真清态,6 个消费者适配)。
  • 统一焦点框架 TvFocusScopeui-foundation-tv/focus/):页面用 TvFocusKey 枚举声明锚点,Modifier.tvFocusAnchor / tvFocusLink / tvFocusEnterGate / tvFocusExit / tvFocusHotkey 声明层级与去向;request(key) 轮询送焦 + 到位确认 + 用户按键即放弃(不与用户抢焦点)。这是真机上多轮调出来的焦点稳定性方案(LazyColumn 回收、touch-mode、跨大间距空间搜索不可靠等坑均有注释)。
  • TMDB:移植 feat(tv): add Android TV platform support #3217TmdbImageService/TmdbEpisodeMatcher,探索/详情 backdrop 与选集剧照优先 TMDB,未配 token 全链路静默退化。需在 local.propertiesani.tmdb.api.token

页面

  • 探索:沉浸式 hero(轮播驱动、左右键切换、TMDB backdrop、简介/评分/连载信息)+ 继续观看行 + 为你推荐自适应网格,整页单 LazyColumn 滚动;卡片聚焦标题跑马灯。
  • 条目详情:全屏 backdrop 三态、贴底信息带(续播按钮/统计/标签墙/评分直方图)、选集剧照轮播、角色/制作人员/关联条目/评论区块、返回三级分层。
  • 播放:WEB 源自动选源 → WebView 解析 → ExoPlayer + libass + 弹幕(主链路真机全通)。
  • 追番:分类 tab(聚焦即选中)+ 自适应网格;网格上缘按上/按返回回当前 tab;左右缘按左右切相邻分类并落"对应位置"。
  • 新番时间表:手机 Medium 多列布局复刻(两周窗口、当前时间指示)。
  • 搜索 / 登录(邮箱 OTP)/ 设置子集;侧边栏进入落点跟随当前页。
  • 发布流水线:release 出 ani-tv-* APK(M4;应用内更新按维护者意见暂缓)。

遗留(后续 PR)

  • 播放页与 feat(tv): add Android TV platform support #3217 的控制层对齐(胶囊行、面板、帧预览)及 EpisodeScreenVariant 状态复用
  • 搜索页迁移 SearchViewModel/PagingSearchState
  • 详情页圆钮行/选集网格菜单/标签菜单/吸附滚动
  • 真 TV 设备(Leanback 盒子)验证;目前在手机上以 TV 交互模型验证

🤖 Generated with Claude Code

StageGuard and others added 30 commits August 1, 2026 19:36
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>
StageGuard and others added 16 commits August 4, 2026 10:58
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>
@StageGuard StageGuard mentioned this pull request Aug 5, 2026
11 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant