Skip to content

feat: Android TV 遥控器支持与播放器控制栏重构 - #77

Open
wdw89 wants to merge 41 commits into
lanlinju:mainfrom
wdw89:pr/tv-player-ui
Open

wdw89 wants to merge 41 commits into
lanlinju:mainfrom
wdw89:pr/tv-player-ui

Conversation

@wdw89

@wdw89 wdw89 commented Sep 14, 2026

Copy link
Copy Markdown

背景

当前 main 上 Android TV 基本不可用:没有 LEANBACK_LAUNCHER 入口,焦点高亮是 Material 默认效果(电视上几乎看不出来),播放器的进度条 / 倍速 / 选集都依赖触摸拖动。

这个 PR 补上 TV 遥控器(D-pad)支持,并顺带重构了播放器控制栏。只动界面层,不涉及数据源解析、网络与数据库。

改动

1. TV 入口与声明

AndroidManifest.xml 增加 LEANBACK_LAUNCHER intent-filter 与 android:bannerandroid.software.leanbackandroid.hardware.touchscreen 均声明为 required="false",所以手机端的安装与启动行为不变。

2. 焦点导航基础件(新增 util/focus/

  • rememberIsFocused() —— 收敛原先散落在各页面的 isFocused 样板(约 65 处)。
  • FocusBorder —— 用 drawWithContent 绘制焦点边框。焦点状态只在绘制阶段读取,焦点变化不触发重组与重布局;这是电视上卡片列表滚动掉帧的主因(e6f44e7df2ed1a)。
  • DpadKeyHandler —— 在布局边界上拦截方向键,用于默认 2D 空间算法给出错误目标的位置。

3. 焦点高亮统一

所有可聚焦控件(按钮、弹窗项、列表、滑块、播放器控件、底部导航)统一走 FocusHighlight.ktfocusHighlightColors():聚焦时 primary 底 + onPrimary 前景。想调整高亮样式只需改这一处。

4. 逐页焦点修复

首页、搜索页、详情页、设置页的焦点顺序、初始焦点与边界行为。

5. 播放器控制栏

  • 多线路 / 剧集选择对话框,当前线路自动获得焦点。
  • 倍速与画面比例侧栏合并为一个 SelectableSideSheet(原先两份几乎相同的实现),修掉标签截断与重复的焦点状态机。
  • 滑块:拖动边缘安全区、缩略图亚像素白边、电视上更紧凑的进度浮层。
  • 副标题栏显示分辨率、文件大小与码率。

6. 其他

按电视设计规范去掉退出确认弹窗;搜索栏状态栏 padding 与编辑态高度稳定;新增纯色背景开关。

7. 修掉一处失效的 CI 步骤

Set up Android SDK 这一步挂在构建之前,之后所有步骤被 skip,job 11~18 秒结束、日志里没有 Gradle 输出。

原因是 android-actions/setup-android 会比对镜像里的 cmdline-tools 版本,不一致就自行下载它绑定的那一版,而 Google 已把那些 zip 从仓库下架

Downloading commandline tools from .../commandlinetools-*-<rev>_latest.zip
HTTPError: Unexpected HTTP response: 404

上游 android-actions/setup-android#536 自 8 月起开着,最新版 v4 同样中招(v4.0.1 是 4 月的,没有修复版)。GitHub 把 Node20 的 action 强制跑在 Node24 上又放大了这个问题(check run 注解里有提示)。

判定为环境问题而非本仓库代码问题的依据:同一份 workflow、同一行 @v3,在 main 上 2026-05-29 是成功的,2026-09 全挂。

修法是直接删掉该步骤 —— 它本来就是多余的。ubuntu-latest 镜像已预装本项目需要的全部 SDK 且已接受许可:

需要的东西 镜像自带
ANDROID_HOME /usr/local/lib/android/sdk
cmdline-tools 12.0
platforms;android-36(本项目 compileSdk = 36
build-tools 37.0.0 / 36.0.0 / 36.1.0 / 35.x / 34.0.0

顺带把 actions/setup-java@v4 升到 @v5(v4 已被官方标记弃用)。未改动任何构建逻辑release.yml 里的同类步骤一并处理,否则下次打 tag 发版会在同一个地方失败。

追加修复:详情页相关推荐区按 ↓ 崩溃(4dfc4db

详情页把焦点停在剧集控制行(线路 / 正序 / 更多)时按遥控器 ↓,会抛 IllegalStateException: FocusRequester is not initialized

firstRelatedFocusRequester 只挂在相关推荐 LazyRowindex == 0 那张卡片上。当这张卡片被列表回收(或该页根本没有相关推荐)时,修饰符已从焦点树上摘除,requester 处于未初始化状态,requestFocus() 直接抛异常;而这个调用在 key event 回调里同步执行,异常沿 AndroidComposeView.dispatchKeyEvent 一路冒到 MainActivity,进程直接崩溃。

修法:EpisodeListControlonDownFocusRequest 改为可空(DpadKeyHandler 原本就是「传 null 即放行该方向键」的语义),相关推荐为空时不拦截 ↓,并且整块不再渲染;非空时给 requestFocus()runCatching 守卫,失败退回 focusManager.moveFocus(FocusDirection.Down) 走默认焦点搜索。

真机验证(Sony BRAVIA 4K VH21 / Android 12 / 1.3.5):详情页 → 控制行 → 相关列表右移 14 次使首项被回收 → 回控制行 → 按 ↓。修复前必现 FATAL EXCEPTION,产出的崩溃日志与用户上报的那一份 SHA-256 逐字节相同;修复后无异常,焦点正常下移。

兼容性

  • 无数据库 schema 变更,无数据源 / 网络改动,升级不需要迁移。
  • 手机端不受影响:焦点相关逻辑只在存在键盘 / 手柄 / 遥控器时生效,uses-feature 均为非必需。

验证

  • ./gradlew :app:assembleDebug:app:assembleRelease(含 R8)通过。
  • ./gradlew :app:test → 15 passed / 0 failed。
  • 在 AOSP TV 模拟器与一台平板形态设备(Android 15)上安装运行。
  • CI 修复后需要你点一次 Approve and run(fork PR 的 workflow 默认待审批)才会实际跑。

评审建议

41 个提交里大多是逐页的调整,直接看最终 diff 即可。几个值得单独看的:

  • 92d1501 提取 focus 工具函数 —— 这次改动的地基
  • e6f44e7df2ed1a 电视端滚动性能
  • 630b5ee 播放器多线路选择
  • 4dfc4db 详情页相关推荐区按 ↓ 崩溃的修复(见上面「追加修复」)

需要你知道的一点

有 4 个测试类在 main 上就是失败的,与本 PR 无关:CycanimeSourceTest / GogoanimeTest / NyafunSourceTest 引用的 fixture 路径仍是包名重命名前的 com/sakura/anime/...(在 ee0a788 上同样失败),YhdmSourceTestExceptionInInitializerError。这些文件会在 #78 中重写 / 删除。

后续

数据源侧的问题(HLS 播放、次元城登录与 token 续期、失效数据源、持久化兼容)拆在 #78,堆叠在这个 PR 之上。建议先合并本 PR 再合并 #78,这样上面那 4 个失败测试也会一并解决。

wdw89 added 30 commits June 21, 2026 22:31
- MediaSmall: Card(onClick) + focusGroup() + border焦点边框
- FavouriteScreen: 触摸长按(LaunchedEffect) + D-pad长按(Handler)
- HomeScreen: Tab改用Surface + LazyVerticalGrid添加focusGroup()
- VideoPlayerScreen: D-pad控制改用KeyUp,左右键隐藏时先展示UI
- VideoPlayerControl: 添加modifier和sliderFocusRequester参数
- Slider: 添加focusRequester参数
- Card border颜色统一为#BCC0C8(深色浅色一致)
- Week页面TopAppBar标题区域改为圆角矩形焦点背景
- Home页面站源文本移除clickable,不再可聚焦
- 详情页标签改为非交互Box,不再可聚焦
- SourceHolder.isSourceChanged改为计数器,避免布尔标志竞争条件
- 两个页面切换源时递增计数器,所有页面同时刷新
- Home页面站源改为Surface+SourceSwitchDialog,与Week页面统一
- Home页面站源添加圆角矩形焦点背景
- 移除Week页面源切换的确认/取消按钮,选中即切换
- 移除Home站源自定义焦点颜色中的冗余代码
- 统一使用surfaceVariant焦点背景色
搜索页:
- 重构搜索栏为双模式布局(编辑/非编辑),适配TV遥控器D-pad导航
- 编辑模式支持方向键防冲突、自动聚焦输入框
- 非编辑模式显示当前查询文本,点击/按确认键进入编辑
- 搜索结果自动聚焦第一项,移除isAndroidTV条件判断
- 添加BackHandler处理返回键优先级(菜单>编辑>返回)

验证码:
- CaptchaWebViewActivity 添加 windowSoftInputMode=adjustResize
- 自动轮询点击"我已了解/知道了/确定"公告按钮
- 自动聚焦验证码输入框,提升操作效率
- 添加BackHandler焦点流转: WebView→确认按钮→退出
- 按钮添加焦点边框高亮,支持TV导航

焦点导航统一:
- 所有IconButton添加onFocusChanged高亮(primary色容器背景)
- MediaSmall焦点边框改为primary主题色
- 移除各处isAndroidTV条件判断,统一支持所有设备
- SourceHolder.isSourceChanged改为MutableStateFlow
- VideoPlayerScreen移除isAndroidTV守卫,统一焦点行为
- build.gradle.kts: ui.tooling改为implementation作用域
- Add focus utility toolkit: handleDPadKeyEvents, rememberIsFocused
- Detail page: replace Box with Column layout, route vertical focus
  between episodes/controls/related via FocusRequester
- Settings page: route ColorBall vertical focus from outer rows,
  focus to first color item, use onSurface for border contrast
- Add VS Code build task for install & launch

Change: detail-settings-focus-fixes
… detail page

- Replace lastPlayedFocusRef (v1-v4) with function-level FocusRequester
  on last played item + LaunchedEffect(Unit) for initial focus only
- EpisodeListControl.onUp and FavouriteIcon.onDown use moveFocus
  for natural D-pad navigation without external FocusRequester refs
- Archive detail-settings-focus-fixes change
- Sync delta spec to openspec/specs/tv-focus-traversal/
…hanges

Changes were too trivial for formal openspec workflow — direct implementation committed separately.
…, compact seek overlay

- Slider: fix onFocusChanged modifier order (before focusable) so thumb animates on focus (15→20dp + 3dp white border)
- Slider: onKeyEvent uses onClick instead of onValueChange+onValueChangeFinished to avoid isSeeking toggle
- Slider: D-pad seek step unified to 10s; remove unused focusedColor param
- focusProperties explicit routing: back↔slider↔playPause via FocusRequester (bypasses outer fillMaxSize Box)
- Hidden-UI LEFT/RIGHT: 10s seek + compact overlay (isSeeking hides header/playback) + focus slider for continuous seek
- hideAfterMs: 1.5s short timer during seek, 6s default; onSeeked no longer resets isSeeking (no full UI flash)
- onTimedSeek added to VideoPlayerState interface+impl; UP/DOWN/OK show full UI with deferred focus
- hideControlUi no longer resets isSeeking; showControlUi resets isSeeking=false + hideAfterMs=6s
- Fullscreen button hidden on TV (FEATURE_LEANBACK)
- Speed/resize side sheets: auto-focus first item + BackHandler + selected item bold
- Auto-hide timer resets on D-pad via onPreviewKeyEvent + onUserInteraction
- TimelineIndicator hidden with control UI (add isControlUiVisible check)
- Remove unused controlFocusRequester, wildcard imports
- Fix crash when pressing UP/DOWN during compact seek mode (Slider consumed keys unconditionally, now only during isSeeking)
- Fix side sheet overlapping: outer key handler now skips when episode/speed/resize sheets are open
- Fix failure page: retry button now receives initial focus via FocusRequester
- Add player gradient overlay (top/bottom 55% black → 25%/75% transparent)
- Gradient hidden during compact seek mode
- New spec: player-focus-dpad-safety
- Updated spec: player-focus-navigation (side-sheet guard + compact seek exit)
- Updated spec: player-gradient-overlay
- Root cause: .clip(CircleShape) anti-aliased edge pixels blending with gradient overlay
- Second cause: .border(0.dp) still inserts BorderStroke in Skia rendering path
- Fix 1: .background(color, CircleShape) replaces .clip().background() — rasterizes directly as circle
- Fix 2: .then(if active border else Modifier) instead of .border(0.dp)
- Also: reduced active border from 3dp to 2dp
- Spec archived at openspec/changes/archive/2026-07-12-player-slider-thumb-render
- Unify rememberInteractionFocus → rememberIsFocused across all screens
- Remove rememberInteractionFocus from FocusHighlight.kt
- Player Back key intercept via root onKeyEvent (tablet fix)
- D-pad OK toggle: pause+showUI / play only (no UI)
- Remove showControlUi() on player initial load
- Delete mode: FavouriteScreen + DownloadDetailScreen
- App exit confirmation dialog on MainScreen
- Download episode picker auto-focus
- DownloadDetail screen: anime detail nav button
- DownloadDetail: expose sourceMode in navigation route
- Add statusBarsPadding to prevent overlap with system status bar on tablets
- Use onGloballyPositioned to measure InputField height and match non-editing Row
- Add missing BackHandler for non-editing mode (back button works now)
- Fix OK/DirectionCenter key in editing mode to trigger search
- Match Row edge padding (12dp) to InputField internal padding for zero visual jump
- Remove unused imports (SearchBar, rememberSaveable, focusable, AnimeTheme)
- Add pureBackground preference with toggle in Appearance settings
- Override background and surface colors: white (#FFFFFF) for light, #121212 for dark
- Skip background blending on HomeScreen and AnimeDetailScreen when enabled
- Compatible with dynamic color (Material You) — only background/surface affected
- MediaSmall: use Card(onClick) with zero elevation + fixed-width border
  (3dp, color-only change) to eliminate elevation animation and relayout
  on focus change. Release build jank: 7.41% (vs official 7.65%)
- WeekItem: add focus border + zero elevation to ElevatedCard
- build.gradle.kts: fallback to debug signing when release keystore
  env vars are not set
Prevent timeline seeking from activating when drag starts within 40dp
of screen edges, avoiding conflict with system back gesture navigation.
…below title

- Increase gradient overlay opacity from 0.55 to 0.75 and extend
  gradient coverage (0.25→0.35 top, 0.75→0.65 bottom) for better
  title readability
- Display video resolution (e.g. 1920×1080) and bitrate (e.g. 5.2Mbps)
  as subtitle line below the title in the player header
Replace composition-phase Modifier.border + rememberIsFocused with
draw-phase focusBorder (drawWithContent + onFocusChanged via
Modifier.composed). Focus state is read only in the draw lambda,
so focus changes trigger draw invalidation only — no recomposition,
no relayout.

- FocusBorder.kt: new draw-phase modifier using composed{}
- MediaSmall.kt: use focusBorder on Card modifier
- WeekScreen.kt: use focusBorder on ElevatedCard modifier
- AnimeDetailScreen.kt: simplify isActive to isFocused only
wdw89 added 9 commits August 2, 2026 22:11
- 线路/选集组合:侧栏支持线路 tab + 集数选择,线路数据随 PlayerParameters 传递
- 侧栏交互:tab/集数分层(左右切线路保同 index、UP 聚焦当前线路 tab、播放指示跨线路保留)
- 详情页线路选择记忆修复(Room 重发不覆盖用户所选线路)
- 切换剧集/线路失败保留播放器 + 错误浮层可重试;取消上一条加载防竞态
- 播放器:胶囊按钮样式、setLoading 清除播放错误、tab 播放指示、错误/加载圈正确切换
- 移除 hasShownOnce:pendingFocusIndex 初始化为 focusIndex,首开/左右切统一由 pending 驱动
- channelFocusRequesters List 改为单个 channelFocusRequester(UP 恒聚焦当前线路 tab)
- 合并侧栏按钮共用样式、错误浮层
…ll border

- Add shared focus helpers (focusedIconButtonColors, focusedTextButtonColors,
  focusedOutlinedButtonColors, FocusedDropdownMenuItem) replacing ~30
  duplicated inline color blocks; convert remaining raw focus state
  boilerplate to rememberIsFocused
- Extract SourceModeMenu (SearchScreen) and DeleteOverlay (Favourite/
  DownloadDetail) shared composables
- Restore MediaSmall onLongClick via combinedClickable, removing the
  hand-rolled pointer long-press hack in FavouriteScreen; keep focusBorder
  outside the clickable layer so the D-pad focus border renders correctly
- Trim unused onLeft/onRight/onEnter params from DpadKeyHandler and dead
  isTv from VideoPlayerControl
- Drop redundant onRefresh/onSourceChanged plumbing (source switch already
  refreshes via isSourceChanged StateFlow)
- Revert ui-tooling to debugImplementation so preview tooling is not
  bundled into release builds
之前的 .gitignore 把整个 .github 目录一并忽略,并连带删除了工作流的跟踪,
这里恢复 android_ci.yml / release.yml。

仅本地使用的笔记(docs/、AGENTS.md)不再跟踪,改由 .git/info/exclude 管理,
避免混进 PR 给评审增加噪音。

顺带回退 app/build.gradle.kts 中「release 签名缺失时回退到 debug 签名」的
改动,构建脚本保持与上游一致。
wdw89 and others added 2 commits September 16, 2026 22:46
两个 workflow 的 "Set up Android SDK" 步骤挂在构建之前,后续步骤全被 skip:
job 11~18 秒就结束,日志里没有 Gradle 输出。原因是
android-actions/setup-android 会比对镜像里的 cmdline-tools 版本,不一致就自行
下载它固定绑定的那一版,而 Google 已把这些 zip 从仓库下架:

    Downloading commandline tools from .../commandlinetools-*-<rev>_latest.zip
    HTTPError: Unexpected HTTP response: 404

上游 issue android-actions/setup-android#536 自 8 月起开着且没有修复版,
最新版 v4 同样中招。GitHub 把 Node20 的 action 强制跑在 Node24 上后又放大了
这个问题(见 check run 注解)。

判定为环境问题而非本仓库代码问题的依据:同一份 workflow、同一行 @V3,
在 main 上 2026-05-29 是成功的,2026-09 全挂。

修法:直接删掉该步骤。ubuntu-latest 镜像已经预装本项目需要的 SDK 并且
已接受许可,这一步本来也是多余的:
  ANDROID_HOME=/usr/local/lib/android/sdk
  cmdline-tools 12.0 / platform-tools 37.0.1
  platforms;android-36(本项目 compileSdk = 36)
  build-tools 37.0.0 / 36.0.0 / 36.1.0 / 35.x / 34.0.0

顺带 actions/setup-java@v4 -> @v5(v4 已被官方标记弃用,见同一处注解)。
未改动任何构建逻辑。
详情页把焦点停在剧集控制行(线路/正序/更多)按遥控器 ↓ 时,抛
IllegalStateException: FocusRequester is not initialized。

firstRelatedFocusRequester 只在 AnimeRelated 的 LazyRow index == 0 的卡片上挂
Modifier.focusRequester()。当该卡片被 LazyRow 回收(或在相关推荐为空的页面
从未挂上)时,修饰符从焦点树上摘除,requester 变为未初始化,此时
requestFocus() 直接抛异常。

修复:

- EpisodeListControl 的 onDownFocusRequest 改为可空。DpadKeyHandler 的语义
  本就是「传 null 即放行该方向键」,所以相关推荐为空时不再拦截 ↓。
- 相关推荐为空时整块不渲染,不再留下一个空的标题区块。
- 相关推荐非空但首项已被回收时,requestFocus() 加 runCatching 守卫,
  失败则退回 focusManager.moveFocus(Down) 走默认焦点搜索。

真机验证(Sony BRAVIA 4K VH21 / Android 12 / 1.3.5):
详情页 → 控制行 → 相关列表右移 14 次使首项回收 → 回控制行 → 按 ↓

- 修复前:必现 FATAL EXCEPTION,进程死亡并跳转崩溃页;产出的崩溃日志与
  用户上报的那份 SHA-256 逐字节相同(BA5AE586…65C79)。
- 修复后:无异常,焦点正常下移到相关推荐卡片。

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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