关于 leanback-to-compose-tv-migration
为将 Android TV 应用从 Leanback 迁移到 Jetpack Compose 提供开发指导,面向实现电视界面的开发者。内容涵盖 D-pad 焦点、TV Material 3、媒体播放和分阶段迁移。
主要功能
- 0110 英尺电视界面设计规范
- 02TV Material 3 组件选型
- 03D-pad 焦点与导航管理
- 04惰性列表及焦点恢复
- 05Media3 Compose 播放迁移
适用场景
- 01将 Leanback 界面迁移到 Compose
- 02为电视目录页配置 D-pad 导航
- 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.mdreferences/android/training/tv/playback/compose/browse.mdindex.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)。
- 带返回键拦截(
onPreviewKeyEvent)的可点击 TV 表面包装器:将标准 M3TextField放入可聚焦的 TVSurface(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,这样用户就能移除输入字段的焦点,而不会退出屏幕。 - 为什么必须拦截返回键:在 Android TV 上编辑文本字段时,按下 D-pad Back 按钮通常会返回并退出屏幕。在
onPreviewKeyEvent中于KeyUp时拦截Key.Back和Key.Escape,停止编辑(清除焦点),用户便能返回使用 D-pad 浏览表单,而不会被困在文本字段中或意外退出屏幕。 - 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。对于目录浏览布局,请遵循目录浏览指南中的说明。
- 使用
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。 - 行焦点记忆(
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),并叠加半透明传输控制栏:
- 必需的 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'。 - 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)。 - 禁止使用旧版 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 阶段:最终统一与清理
- 移除所有剩余的旧版
.javaActivity、Fragment、Presenter 和 XML 布局文件。 - 确保所有 Activity 都继承
ComponentActivity或FragmentActivity,并调用setContent { ... }。 - 从
res/values/styles.xml和res/values/themes.xml中彻底移除旧版 Leanback 主题和样式声明(例如,任何继承自Theme.Leanback或引用lb_样式的声明)。
组件和类映射指南
| 旧版 Leanback / View 类 | 现代 Jetpack Compose 对应项 |
|---|---|
BrowseSupportFragment / MainFragment | BrowseScreen(包含分类 LazyRow 和 Hero 横幅的 LazyColumn) |
DetailsSupportFragment | VideoDetailsScreen(海报图片、文本列、操作按钮、相关内容 LazyRow) |
VideoSupportFragment / PlaybackGlue | PlaybackScreen(Media3 ExoPlayer + Compose PlayerSurface) |
GuidedStepSupportFragment | GuidedStepScreen(分屏布局:左侧指导面板占 40%,右侧操作面板占 60%) |
SearchSupportFragment | SearchScreen(BasicTextField + 实时筛选 + LazyVerticalGrid) |
VerticalGridSupportFragment | VerticalGridScreen(LazyVerticalGrid(columns = GridCells.Fixed(5))) |
ArrayObjectAdapter / Presenter | 声明式 @Composable 函数,观察不可变的 State<List<T>> |
CursorMapper / LoaderManager / CursorLoader | 在 Repository 对象中使用 Kotlin Coroutines / withContext(Dispatchers.IO) |
OnboardingSupportFragment | OnboardingScreen(HorizontalPager + D-Pad 导航按钮) |
LeanbackSettingsFragment / PreferenceFragment | SettingsScreen(在第一个 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()
}
}