mcpField
L
leanback-to-compose-tv-migration

leanback-to-compose-tv-migration

github.com/android开发10.0k844 次安装2 天前更新
代码仓库

关于 leanback-to-compose-tv-migration

为将 Android TV 应用从 Leanback 迁移到 Jetpack Compose 提供开发指导,面向实现电视界面的开发者。内容涵盖 D-pad 焦点、TV Material 3、媒体播放和分阶段迁移。

主要功能

  1. 0110 英尺电视界面设计规范
  2. 02TV Material 3 组件选型
  3. 03D-pad 焦点与导航管理
  4. 04惰性列表及焦点恢复
  5. 05Media3 Compose 播放迁移

适用场景

  1. 01将 Leanback 界面迁移到 Compose
  2. 02为电视目录页配置 D-pad 导航
  3. 03将旧视频播放界面迁移到 Media3

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/android/skills --skill leanback-to-compose-tv-migration

安装后在 Claude Code 中输入/leanback-to-compose-tv-migration确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/android/training/tv/get-started/
navigation.md
references/android/training/tv/playback/compose/
browse.md
index.md

详细介绍

10 英尺 UI

“10 英尺 UI”是一种面向电视的设计范式,专为约 3 米(10 英尺)外的观看距离打造。在设计这种体验时,请考虑以下关键特征:

  • 观看距离:观看者坐得离屏幕较远,处于“后仰”状态。屏幕布局应简洁,文本和 UI 元素应足够大,以便在远处也能轻松阅读,并避免使用密集的文本块。
  • 色彩对比度:为避免低对比度电视显示屏上的颜色显得暗淡,设计应采用高对比度配色,并使用明显的视觉指示,确保焦点状态在不同电视面板上都清晰可见。
  • 方向键导航:交互依赖方向有限的遥控器,只支持 4 个方向的导航(Up、Down、Left、Right),组件应排列成清晰的空间网格和轮播布局,避免焦点陷阱。

核心架构和库选择

将 Android TV 应用迁移到 Jetpack Compose 时,必须使用 androidx.tv 库,并遵循这些 10 英尺 UI 模式:

  • UI 现代化:优先采用自定义的电影感布局,而非沿用旧版的一对一模板。动态渐变主视觉背景、自定义焦点动画、自定义导航抽屉和自定义布局等 TV 功能必须使用 Jetpack Compose 实现。
  • 主要设计系统:必须始终使用 androidx.tv.material3.*(androidx.tv:tv-material),而不是移动端的 androidx.compose.material3.*。TV Material 3 内置了方向键焦点处理、焦点缩放,以及针对 TV 优化的排版和形状。若要设置 TV 版 Compose 依赖项,请参阅 TV 版 Compose 设置。
  • 焦点缩放动画:对于可交互卡片,必须使用 CompactCard、ClassicCard 或 WideCardContainer,并设置 scale = CardDefaults.scale(focusedScale = 1.1f),以提供标准的 TV 焦点动画。
  • Coil 图片加载:若要使用声明式的 AsyncImage(model, contentDescription, ...),而不传入显式的 ImageLoader 参数,必须在 Gradle 依赖项中加入 io.coil-kt:coil-compose。
  • 显式导入:必须始终显式导入 TV Material 3 类(例如,import androidx.tv.material3.Surface、import androidx.tv.material3.ListItem),而不是使用通配符导入(import androidx.tv.material3.*)。
  • 文件命名约定:必须根据屏幕为 Composable 屏幕文件命名(例如,将 BrowseScreen 命名为 BrowseScreen.kt,将 PlaybackScreen 命名为 PlaybackScreen.kt,并将 AuthenticationScreen 命名为 AuthenticationScreen.kt)。不要使用 Main 之类的通用前缀。
  • 过扫描和边框:必须为根容器、轮播和顶部栏应用水平内边距(例如,horizontal = 48.dp 或 32.dp,vertical = 24.dp),以避免内容被裁切。
  • 阅读宽度限制:对于长篇文本,必须使用 Modifier.widthIn(max = 600.dp) 限制文本列的阅读宽度。
  • Media3 Compose 依赖项:现代化媒体播放屏幕时,需在 app/build.gradle 中加入 androidx.media3:media3-ui-compose。
  • 禁止使用旧版 AndroidView 包装器:不要使用旧版 AndroidView 包装器将基于 View 的组件嵌入 Jetpack Compose 屏幕。所有迁移后的屏幕必须使用 Compose 组件或 Media3 Compose 表面(PlayerSurface)。

方向键焦点处理和导航

Jetpack Compose for TV(androidx.tv.material3)需要显式管理焦点,因为组件不会自动获取初始焦点,且导航使用二维空间坐标。若要配置 TV 方向键导航,请遵循TV 导航指南。

初始焦点

进入每个屏幕时,必须使用 FocusRequester 将初始焦点设到主要交互元素上 (例如第一个操作按钮、卡片或 ListItem)。定义 val focusRequester = remember { FocusRequester() },将 Modifier.focusRequester(focusRequester) 附加到主要元素上,并在 LaunchedEffect(Unit) { focusRequester.requestFocus() } 中请求焦点:

val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current

LaunchedEffect(Unit) {
    focusRequester.requestFocus()
}

注意:对于具有动态状态或分页器的屏幕(例如使用 HorizontalPager 的 OnboardingScreen),必须将状态键传递给 LaunchedEffect(例如 LaunchedEffect(pagerState.currentPage)),以便在页面变化时重新应用焦点。

双向焦点路由与避免焦点陷阱

当交互元素位于屏幕两侧时,标准的 2D 空间导航无法找到另一侧的目标。这会造成焦点陷阱,使用户无法使用方向键导航离开某个区域。

为了实现无焦点陷阱的对称双向方向键导航,必须尽可能依赖 Compose 的 2D 空间焦点引擎。连接可滚动容器(如 LazyColumn 或 LazyRow)中的相邻 UI 元素时,不要设置指向懒加载列表中单个元素的方向覆盖项(up = ...、down = ...)。垂直导航时,如果某个元素滚动到屏幕外,其 FocusRequester 会变为未初始化状态,导致焦点搜索时抛出 IllegalStateException:

Row(
    modifier = Modifier
        .fillMaxWidth()
        .padding(horizontal = 48.dp, vertical = 16.dp),
    horizontalArrangement = Arrangement.End
) {
    Button(
        onClick = { /* Search */ },
        modifier = Modifier.focusRequester(topBarFocusRequester)
    ) { Text("Search") }
}

行焦点回忆(Modifier.focusRestorer)

在水平轮播列表(LazyRow)之间垂直导航时,Compose 的默认 2D 空间焦点引擎会沿着当前聚焦元素的 X 坐标进行搜索。如果用户在第 1 行向右滚动(例如滚动到 X=800dp 处的第 4 个元素),然后按向下方向键导航到第 2 行,空间路由会聚焦于第 2 行中位于 X=800dp 的元素。

为了让每一行都能单独记住卡片焦点(再次进入时将焦点恢复到上次访问的元素),必须将**Modifier.focusRestorer**(不带参数)直接附加到 LazyRow 上。不要在懒加载容器中传入自定义的备用 FocusRequester lambda,因为在快速按方向键滚动时,对尚未附加或位于屏幕外的元素调用 requestFocus 会抛出 IllegalStateException。

LazyRow(
    modifier = Modifier.focusRestorer(),
    contentPadding = PaddingValues(horizontal = 48.dp),
    horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
    itemsIndexed(videos) { vidIndex, video ->
        CompactCard(
            onClick = { onVideoClick(video) },
            image = {
                AsyncImage(
                    model = video.cardImageUrl,
                    contentDescription = video.title,
                    contentScale = ContentScale.Crop,
                    modifier = Modifier.fillMaxSize()
                )
            },
            title = { Text(video.title) },
            modifier = Modifier
                .then(
                    if (catIndex == 0 && vidIndex == 0) {
                        Modifier.focusRequester(firstCardFocusRequester)
                    } else {
                        Modifier
                    }
                )
                .onFocusChanged { focusState ->
                    if (focusState.isFocused) {
                        focusedVideo = video
                        focusedCategoryIndex = catIndex
                    }
                }
        )
    }
}

### 文本输入、硬件键盘回车拦截与 IME 焦点链式切换

将 Leanback(SearchSupportFragment、GuidedStepSupportFragment)中的搜索栏或登录表单迁移过来时,不要使用裸的 BasicTextField 容器或空的 Surface(onClick = {}) 包装器,否则 D-pad CENTER 无法调出虚拟键盘(IME)。

  1. 带返回键拦截(onPreviewKeyEvent)的可点击 TV 表面包装器:将标准 M3 TextField 放入可聚焦的 TV Surface(onClick = { focusRequester.requestFocus() }, scale = ClickableSurfaceDefaults.scale(focusedScale = 1.01f), border = ClickableSurfaceDefaults.border(focusedBorder = Border(BorderStroke(2.dp, Color.White)))) 中,以显示聚焦边框并实现 D-pad 焦点缩放。必须在文本字段或包装器上添加 Modifier.onPreviewKeyEvent,拦截 Key.Back 和 Key.Escape,这样用户就能移除输入字段的焦点,而不会退出屏幕。
  2. 为什么必须拦截返回键:在 Android TV 上编辑文本字段时,按下 D-pad Back 按钮通常会返回并退出屏幕。在 onPreviewKeyEvent 中于 KeyUp 时拦截 Key.Back 和 Key.Escape,停止编辑(清除焦点),用户便能返回使用 D-pad 浏览表单,而不会被困在文本字段中或意外退出屏幕。
  3. IME 焦点链式切换:对于多字段表单(例如身份验证界面中的用户名和密码),在顶部字段上使用 ImeAction.Next,并添加 KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),将焦点移至下一个输入框;在底部字段上使用 ImeAction.Done,将焦点直接移至提交按钮:
Surface(
    onClick = { focusRequester.requestFocus() },
    border = ClickableSurfaceDefaults.border(focusedBorder = Border(border = BorderStroke(2.dp, Color.White))),
    modifier = Modifier
        .fillMaxWidth()
        .focusRequester(focusRequester)
) {
    OutlinedTextField(
        value = username,
        onValueChange = { username = it },
        label = { Text("Username") },
        singleLine = true,
        keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
        keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
        modifier = Modifier
            .fillMaxWidth()
            .onPreviewKeyEvent { event ->
                if (event.type == KeyEventType.KeyDown && (event.key == Key.Enter || event.key == Key.NumPadEnter)) {
                    focusManager.moveFocus(FocusDirection.Down)
                    true
                } else false
            }
    )
}

Surface(
    onClick = {},
    border = ClickableSurfaceDefaults.border(focusedBorder = Border(border = BorderStroke(2.dp, Color.White))),
    modifier = Modifier.fillMaxWidth()
) {
    OutlinedTextField(
        value = password,
        onValueChange = { password = it },
        label = { Text("Password") },
        singleLine = true,
        visualTransformation = PasswordVisualTransformation(),
        keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
        keyboardActions = KeyboardActions(onDone = { onLoginSuccess() }),
        modifier = Modifier
            .fillMaxWidth()
            .onPreviewKeyEvent { event ->
                if (event.type == KeyEventType.KeyDown && (event.key == Key.Enter || event.key == Key.NumPadEnter)) {
                    onLoginSuccess()
                    true
                } else false
            }
    )
}

懒加载容器

在 Jetpack Compose for TV 中实现可滚动列表或网格时,必须使用 androidx.compose.foundation.lazy 和 androidx.compose.foundation.lazy.grid 中的标准 LazyColumn、LazyRow 和 LazyVerticalGrid。对于目录浏览布局,请遵循目录浏览指南中的说明。

  1. 使用 BringIntoViewSpec 实现枢轴滚动:使用 BringIntoViewSpec 和 CompositionLocalProvider(LocalBringIntoViewSpec provides ...) 为目录行定义自定义枢轴滚动线时,必须确保项目使用 Compose Foundation 1.7.0+ 编译:在 app/build.gradle 中添加 implementation platform('androidx.compose:compose-bom:2024.06.00')(或更新版本),或添加 implementation 'androidx.compose.foundation:foundation:1.7.0'。如果 Compose Foundation 低于 1.7.0,import androidx.compose.foundation.gestures.LocalBringIntoViewSpec 将报错:Unresolved reference: LocalBringIntoViewSpec。
  2. 行焦点记忆(Modifier.focusRestorer):使用 @OptIn(ExperimentalFocusRestorerApi::class, ExperimentalComposeUiApi::class) 注解可组合项,并在每个类别的 LazyRow 上添加 Modifier.focusRestorer,以便在目录各行之间垂直导航时记住并恢复上次聚焦的卡片。

填充可聚焦的 TV Material 3 组件(CompactCard、ListItem、Button)后,标准 Compose 懒加载容器会自动处理二维 D-pad 焦点路由、焦点记忆和边缘滚动:

@Composable
fun CatalogBrowser(
    featuredContentList: List<Movie>,
    sectionList: List<Section>,
    modifier: Modifier = Modifier,
    onItemSelected: (Movie) -> Unit = {},
) {
    LazyColumn(
        modifier = modifier.fillMaxSize(),
        verticalArrangement = Arrangement.spacedBy(16.dp)
    ) {
        items(sectionList.size) { index ->
            val section = sectionList[index]
            SectionRow(section, onItemSelected = onItemSelected)
        }
    }
}

## Media3 视频播放与传输控制

迁移旧版 Leanback 视频播放(VideoSupportFragment / PlaybackGlue)时,请使用 Compose Media3 PlayerSurface (androidx.media3.ui.compose.PlayerSurface),并叠加半透明传输控制栏:

  1. 必需的 Media3 传输控制按钮(PlayPauseButton):必须在 PlayerSurface 上叠加一个半透明的底部控制栏(Box(modifier = Modifier.align(Alignment.BottomCenter))),其中包含明确的 Media3 UI Compose 按钮:至少包括 PlayPauseButton、SeekBackButton 和 SeekForwardButton。切勿让传输控制叠加层为空。要使用这些可组合项,必须在 app/build.gradle 依赖项中,连同 androidx.media3:media3-ui-compose 一起添加 implementation 'androidx.media3:media3-ui-compose-material3:1.6.0'。
  2. D-pad 方向键跳转(onPreviewKeyEvent):要支持使用遥控器 D-pad 向后和向前跳转,请在容器或控制叠加层上附加 Modifier.onPreviewKeyEvent,并拦截 Compose 的 Key.DirectionLeft 和 Key.DirectionRight(例如 keyEvent.key == Key.DirectionLeft),以向后和向前跳转 10 秒(exoPlayer.seekTo(exoPlayer.currentPosition - 10000))。切勿使用旧版 Android View 键码(KeyEvent.KEYCODE_DPAD_LEFT 或 nativeKeyEvent.keyCode)。
  3. 禁止使用旧版 AndroidView 包装器:不要将旧版 PlayerView 或 StyledPlayerView 包装在 AndroidView { ... } 中。在 Compose for TV 中渲染视频时,必须将 PlayerSurface(androidx.media3.ui.compose.PlayerSurface)与 ExoPlayer 搭配使用。

分阶段迁移策略

要在不破坏编译或引入循环依赖的情况下干净地迁移应用,请分为以下 5 个阶段执行:

  • 阶段 1:基础与设计系统
  • 阶段 2:叶子页面与独立页面
  • 阶段 3:核心浏览与发现页面
  • 阶段 4:详情与媒体播放
  • 阶段 5:最终整合与清理
阶段 1:基础与设计系统
  • 创建 TvTheme.kt,用自定义的 ColorScheme、Typography 和 Shapes 封装 TvMaterialTheme。
  • 构建可复用的原子组件:MovieCard、SectionHeader、LoadingIndicator、ErrorState。
阶段 2:叶子页面与独立页面
  • 优先迁移没有外部导航的页面,例如错误消息、引导页面或设置页面,将其替换为 ErrorDialog、OnboardingScreen 和 SettingsScreen。
  • 将使用 BaseLeanbackPreferenceFragmentCompat、BaseLeanbackPreferenceFragment 或 LeanbackSettingsFragment 的内容改为 Compose,例如使用 ListItem + Switch 并直接绑定到 SharedPreferences,同时替换 findPreference 实现。
  • 确保页面使用 FocusRequester 将 D-pad 的初始焦点设在该页面的第一个或主要组件上,例如第一个 ListItem。
  • 将旧版 Fragment 类替换为 ComponentActivity 类型的 Activity,并以声明式方式使用 Compose 中的组件。
  • 清理 res/values/styles.xml 和 res/values/themes.xml 中,在移除 leanback 依赖后不再受支持的旧版样式和主题引用(例如,移除指向 @style/PreferenceThemeOverlay.v14.Leanback 的 preferenceTheme):
@Composable
fun TvSettingsScreen(
    modifier: Modifier = Modifier
) {
    var autoPlayNext by remember { mutableStateOf(true) }
    var highQualityAudio by remember { mutableStateOf(false) }

    Column(
        modifier = modifier
            .fillMaxSize()
            .padding(48.dp)
    ) {
        Text(
            text = "Settings",
            style = MaterialTheme.typography.headlineMedium,
            modifier = Modifier.padding(bottom = 24.dp)
        )

        LazyColumn(
            verticalArrangement = Arrangement.spacedBy(12.dp),
            contentPadding = PaddingValues(vertical = 8.dp)
        ) {
            item {
                ListItem(
                    selected = false,
                    onClick = { autoPlayNext = !autoPlayNext },
                    headlineContent = { Text("Autoplay Next Video") },
                    supportingContent = { Text("Automatically start playing next item in queue") },
                    trailingContent = {
                        Switch(
                            checked = autoPlayNext,
                            onCheckedChange = null
                        )
                    }
                )
            }

            item {
                ListItem(
                    selected = false,
                    onClick = { highQualityAudio = !highQualityAudio },
                    headlineContent = { Text("High Quality Audio") },
                    supportingContent = { Text("Use spatial audio and multi-channel output when available") },
                    trailingContent = {
                        Switch(
                            checked = highQualityAudio,
                            onCheckedChange = null
                        )
                    }
                )
            }
        }
    }
}

阶段 3:核心浏览与发现页面
  • 迁移 VerticalGridScreen(LazyVerticalGrid)、SearchScreen(使用 BasicTextField 实现实时列表筛选)和 BrowseScreen(由 LazyRow 组成的 LazyColumn)。
  • 清除旧版 ArrayObjectAdapter、ListRowPresenter、CardPresenter 和 HeaderItem 类。
第 4 阶段:详情和媒体播放
  • 迁移 VideoDetailsScreen 和 GuidedStepScreen。
  • 使用 Compose Media3 PlayerSurface(androidx.media3.ui.compose.PlayerSurface)迁移 PlaybackScreen,并搭配包含 Media3 Compose 播放控制按钮(例如 PlayPauseButton、SeekBackButton、SeekForwardButton)的半透明底部覆盖层。
第 5 阶段:最终统一与清理
  • 移除所有剩余的旧版 .java Activity、Fragment、Presenter 和 XML 布局文件。
  • 确保所有 Activity 都继承 ComponentActivity 或 FragmentActivity,并调用 setContent { ... }。
  • 从 res/values/styles.xml 和 res/values/themes.xml 中彻底移除旧版 Leanback 主题和样式声明(例如,任何继承自 Theme.Leanback 或引用 lb_ 样式的声明)。

组件和类映射指南

旧版 Leanback / View 类现代 Jetpack Compose 对应项
BrowseSupportFragment / MainFragmentBrowseScreen(包含分类 LazyRow 和 Hero 横幅的 LazyColumn)
DetailsSupportFragmentVideoDetailsScreen(海报图片、文本列、操作按钮、相关内容 LazyRow)
VideoSupportFragment / PlaybackGluePlaybackScreen(Media3 ExoPlayer + Compose PlayerSurface)
GuidedStepSupportFragmentGuidedStepScreen(分屏布局:左侧指导面板占 40%,右侧操作面板占 60%)
SearchSupportFragmentSearchScreen(BasicTextField + 实时筛选 + LazyVerticalGrid)
VerticalGridSupportFragmentVerticalGridScreen(LazyVerticalGrid(columns = GridCells.Fixed(5)))
ArrayObjectAdapter / Presenter声明式 @Composable 函数,观察不可变的 State<List<T>>
CursorMapper / LoaderManager / CursorLoader在 Repository 对象中使用 Kotlin Coroutines / withContext(Dispatchers.IO)
OnboardingSupportFragmentOnboardingScreen(HorizontalPager + D-Pad 导航按钮)
LeanbackSettingsFragment / PreferenceFragmentSettingsScreen(在第一个 ListItem 上使用 FocusRequester + 绑定到 SharedPreferences 的尾随 Switch)

参考实现和经过实战验证的模式

现代 TV 沉浸式列表架构(BrowseScreen)

构建 10 英尺 TV 浏览界面或沉浸式列表时,不要将 Hero 横幅放在可滚动列表之前或之外,也不要通过程序化调用 animateScrollToItem 与 Compose 的自动 BringIntoView 系统对抗。

相反,使用 Compose Foundation 中的 BringIntoViewSpec 和 LocalBringIntoViewSpec 定义精确的 TV 枢轴滚动行为(例如,使当前激活行的枢轴位于显示屏顶部边缘下方 35% 处)。结合会重新布局的沉浸式行:当焦点位于较低行(focusedCategoryIndex > 0)时,隐藏 Hero 文本,并在 Row 0 显示普通分区标题:

@Composable
fun ImmersiveBrowseScreen(
    categories: Map<String, List<Video>>,
    onVideoClick: (Video) -> Unit
) {
    var focusedVideo by remember { mutableStateOf<Video?>(null) }
    var focusedCategoryIndex by remember { mutableStateOf(0) }
    val listState = rememberLazyListState()
    val topBarFocusRequester = remember { FocusRequester() }
    val firstCardFocusRequester = remember { FocusRequester() }

    Box(modifier = Modifier.fillMaxSize()) {
        AsyncImage(
            model = focusedVideo?.bgImageUrl,
            contentDescription = null,
            contentScale = ContentScale.Crop,
            modifier = Modifier.fillMaxSize()
        )

        PositionFocusedItemInLazyLayout(parentFraction = 0.35f, childFraction = 0.5f) {
            LazyColumn(
                state = listState,
                contentPadding = PaddingValues(top = 36.dp, bottom = 64.dp),
                verticalArrangement = Arrangement.spacedBy(28.dp),
                modifier = Modifier.fillMaxSize()
            ) {
                item {
                    Row(
                        modifier = Modifier
                            .fillMaxWidth()
                            .padding(horizontal = 48.dp, vertical = 16.dp),
                        horizontalArrangement = Arrangement.End
                    ) {
                        Button(
                            onClick = { /* Search */ },
                            modifier = Modifier.focusRequester(topBarFocusRequester)
                        ) { Text("Search") }
                    }
                }

                itemsIndexed(categories.entries.toList()) { catIndex, (categoryName, videos) ->
                    Column {
                        if (catIndex == 0 && focusedCategoryIndex == 0) {
                            Column(
                                modifier = Modifier
                                    .heightIn(min = 200.dp)
                                    .padding(horizontal = 48.dp)
                            ) {
                                Text(
                                    text = focusedVideo?.title ?: "",
                                    style = MaterialTheme.typography.displayMedium
                                )
                                Text(
                                    text = focusedVideo?.description ?: "",
                                    style = MaterialTheme.typography.bodyLarge
                                )
                            }
                        } else {
                            Text(
                                text = categoryName,
                                style = MaterialTheme.typography.titleMedium,
                                modifier = Modifier.padding(horizontal = 48.dp, vertical = 8.dp)
                            )
                        }

                        LazyRow(
                            modifier = Modifier.focusRestorer(),
                            contentPadding = PaddingValues(horizontal = 48.dp),
                            horizontalArrangement = Arrangement.spacedBy(16.dp)
                        ) {
                            itemsIndexed(videos) { vidIndex, video ->
                                CompactCard(
                                    onClick = { onVideoClick(video) },
                                    image = {
                                        AsyncImage(
                                            model = video.cardImageUrl,
                                            contentDescription = video.title,
                                            contentScale = ContentScale.Crop,
                                            modifier = Modifier.fillMaxSize()
                                        )
                                    },
                                    title = { Text(video.title) },
                                    modifier = Modifier
                                        .then(
                                            if (catIndex == 0 && vidIndex == 0) {
                                                Modifier.focusRequester(firstCardFocusRequester)
                                            } else {
                                                Modifier
                                            }
                                        )
                                        .onFocusChanged { focusState ->
                                            if (focusState.isFocused) {
                                                focusedVideo = video
                                                focusedCategoryIndex = catIndex
                                            }
                                        }
                                )
                            }
                        }
                    }
                }
            }
        }
    }
}

@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun PositionFocusedItemInLazyLayout(
    parentFraction: Float = 0.35f,
    childFraction: Float = 0.5f,
    content: @Composable () -> Unit,
) {
    val bringIntoViewSpec = remember(parentFraction, childFraction) {
        object : BringIntoViewSpec {
            override fun calculateScrollDistance(
                offset: Float,
                size: Float,
                containerSize: Float
            ): Float {
                if (offset >= 0f && offset <= containerSize * 0.45f) {
                    return 0f
                }
                val initialTargetForLeadingEdge = parentFraction * containerSize - (childFraction * size)
                val targetForLeadingEdge = if (size <= containerSize && (containerSize - initialTargetForLeadingEdge) < size) {
                    containerSize - size
                } else {
                    initialTargetForLeadingEdge
                }
                return offset - targetForLeadingEdge
            }
        }
    }
    CompositionLocalProvider(LocalBringIntoViewSpec provides bringIntoViewSpec, content = content)
}

Compose TV 中的 Media3 播放(PlaybackScreen)

使用 androidx.media3.ui.compose.PlayerSurface 作为视频渲染画布,实现自定义播放界面。在画布上叠加 Material3 播放控制按钮(来自 androidx.media3:media3-ui-compose-material3 的 SeekBackButton、PlayPauseButton、SeekForwardButton),放在半透明底部覆盖层中,并通过自动隐藏超时处理 D-pad 遥控器按键事件:

@OptIn(UnstableApi::class)
@Composable
fun Media3PlaybackScreen(
    video: Video,
    onFinish: () -> Unit,
    modifier: Modifier = Modifier
) {
    val context = LocalContext.current
    val exoPlayer = remember(context) { ExoPlayer.Builder(context).build() }
    var showControls by remember { mutableStateOf(true) }
    val focusRequester = remember { FocusRequester() }
    val coroutineScope = rememberCoroutineScope()
    var autoHideJob by remember { mutableStateOf<Job?>(null) }

    fun scheduleAutoHide() {
        autoHideJob?.cancel()
        autoHideJob = coroutineScope.launch {
            delay(5000)
            showControls = false
        }
    }

    DisposableEffect(exoPlayer) {
        val listener = object : Player.Listener {
            override fun onPlaybackStateChanged(playbackState: Int) {
                if (playbackState == Player.STATE_ENDED) {
                    onFinish()
                }
            }
        }
        exoPlayer.addListener(listener)
        onDispose {
            autoHideJob?.cancel()
            exoPlayer.removeListener(listener)
            exoPlayer.release()
        }
    }

    LaunchedEffect(video) {
        focusRequester.requestFocus()
        scheduleAutoHide()

        val mediaItem = MediaItem.fromUri(video.videoUrl)
        exoPlayer.setMediaItem(mediaItem)
        exoPlayer.prepare()
        exoPlayer.playWhenReady = true
    }

    Box(
        modifier = modifier
            .fillMaxSize()
            .background(Color.Black)
            .focusRequester(focusRequester)
            .focusable()
            .onPreviewKeyEvent { event ->
                if (event.type == KeyEventType.KeyDown) {
                    when (event.key) {
                        Key.DirectionCenter, Key.Enter, Key.NumPadEnter, Key.MediaPlayPause, Key.Spacebar -> {
                            if (exoPlayer.isPlaying) exoPlayer.pause() else exoPlayer.play()
                            showControls = true
                            scheduleAutoHide()
                            true
                        }
                        Key.DirectionLeft, Key.MediaRewind -> {
                            val newPos = (exoPlayer.currentPosition - 10_000L).coerceAtLeast(0L)
                            exoPlayer.seekTo(newPos)
                            showControls = true
                            scheduleAutoHide()
                            true
                        }
                        Key.DirectionRight, Key.MediaFastForward -> {
                            val duration = exoPlayer.duration.coerceAtLeast(0L)
                            val newPos = (exoPlayer.currentPosition + 10_000L).coerceAtMost(duration)
                            exoPlayer.seekTo(newPos)
                            showControls = true
                            scheduleAutoHide()
                            true
                        }
                        Key.DirectionUp, Key.DirectionDown -> {
                            showControls = !showControls
                            if (showControls) scheduleAutoHide() else autoHideJob?.cancel()
                            true
                        }
                        else -> false
                    }
                } else false
            },
        contentAlignment = Alignment.Center
    ) {
        PlayerSurface(
            player = exoPlayer,
            modifier = Modifier.fillMaxSize()
        )

        if (showControls) {
            Box(
                modifier = Modifier
                    .fillMaxSize()
                    .background(Color(0x80000000))
                    .padding(48.dp),
                contentAlignment = Alignment.BottomCenter
            ) {
                Row(
                    horizontalArrangement = Arrangement.spacedBy(24.dp),
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    SeekBackButton(player = exoPlayer)
                    PlayPauseButton(player = exoPlayer)
                    SeekForwardButton(player = exoPlayer)
                }
            }
        }
    }
}

用响应式协程流替换 CursorLoader

将旧版 LoaderManager.LoaderCallbacks<Cursor> 和 CursorObjectAdapter 替换为返回 Flow 的 Repository;该 Flow 用于观察数据库变更,并在数据为空时触发异步获取:

object VideoFlowRepository {
    fun getVideosFlow(context: Context, contentUri: Uri): Flow<List<Video>> = callbackFlow {
        val contentObserver = object : ContentObserver(Handler(Looper.getMainLooper())) {
            override fun onChange(selfChange: Boolean) {
                trySend(queryVideos(context, contentUri))
            }
        }

        context.contentResolver.registerContentObserver(
            contentUri,
            true,
            contentObserver
        )

        val initialVideos = queryVideos(context, contentUri)
        trySend(initialVideos)

        awaitClose {
            context.contentResolver.unregisterContentObserver(contentObserver)
        }
    }.flowOn(Dispatchers.IO)

    private fun queryVideos(context: Context, uri: Uri): List<Video> {
        // Query database or ContentProvider
        return emptyList()
    }
}

评论

支持 Markdown