leanback-to-compose-tv-migration
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseThe 10-foot UI
10英尺UI
A "10-foot UI" is a design paradigm for televisions that tailors an interface for viewing from approximately 3 meters (10 feet) away. When designing for this experience, account for these key characteristics:
- Viewing distance: Viewers are sitting far from the screen, "leaning back". Screen layouts are uncluttered, with text and UI elements that are large enough to be comfortably readable from a distance, without dense blocks of text.
- Color contrast: To avoid washed out colors on TV displays with low contrast ratios, the design uses high-contrast palettes and distinct visual indicators so focused states remain visible across different TV panels.
- D-pad navigation : Interaction relies on a directional remote control with limited 4-way navigation (,
Up,Down,Left) with components organized into clear spatial grids and carousels without focus traps.Right
"10英尺UI"是针对电视的设计范式,专为距离屏幕约3米(10英尺)的观看场景量身打造。设计此类界面时,需考虑以下关键特性:
- 观看距离:用户坐在离屏幕较远的位置,处于“后仰”状态。屏幕布局应简洁,文字和UI元素需足够大,确保从远处能轻松阅读,避免密集的文本块。
- 色彩对比度:为避免在低对比度的电视显示屏上出现色彩褪色问题,设计需采用高对比度调色板和清晰的视觉指示器,确保聚焦状态在不同电视面板上都清晰可见。
- D-pad导航:交互依赖带有限4向导航(、
Up、Down、Left)的方向键遥控器,组件需组织成清晰的空间网格和轮播布局,避免出现焦点陷阱。Right
Core architecture and library selection
核心架构与库选择
When migrating an Android TV application to Jetpack Compose, you must use libraries and follow these 10-foot UI patterns:
androidx.tv- UI modernization: Focus on custom, cinematic layouts over legacy direct 1:1 templates. You must use Jetpack Compose for TV features like dynamic gradient hero backdrops, custom focus animations, custom navigation drawers, and custom layouts.
- Primary design system : You must always use (
androidx.tv.material3.*) over mobileandroidx.tv:tv-material. TV Material 3 provides built-in D-Pad focus handling, focus zoom scaling, and TV-optimized typography and shapes. To set up Compose for TV dependencies, follow Compose for TV setup.androidx.compose.material3.* - Focus zoom animation : For interactive cards, you must use ,
CompactCard, orClassicCardwithWideCardContainerto provide standard TV focus animation.scale = CardDefaults.scale(focusedScale = 1.1f) - Coil image loading : To use declarative without passing an explicit
AsyncImage(model, contentDescription, ...)parameter, you must includeImageLoaderin your Gradle dependencies.io.coil-kt:coil-compose - Explicit imports : You must always import TV Material 3 classes explicitly (for example, ,
import androidx.tv.material3.Surface) instead of using wildcard imports (import androidx.tv.material3.ListItem).import androidx.tv.material3.* - File naming conventions : You must name Composable screen files after the screen (for example, name as
BrowseScreen,BrowseScreen.ktasPlaybackScreen, andPlaybackScreen.ktasAuthenticationScreen). Don't use generic prefixes likeAuthenticationScreen.kt.Main - Overscan and bezels : You must apply horizontal padding (for example, or
horizontal = 48.dp,32.dp) to root containers, carousels, and top bars to prevent clipping.vertical = 24.dp - Reading width constrainment : You must constrain reading width using on text columns for long-form text.
Modifier.widthIn(max = 600.dp) - Media3 Compose dependencies : Include in
androidx.media3:media3-ui-composewhen modernizing media playback screens.app/build.gradle - Prohibition of legacy AndroidView wrappers : Don't use legacy wrappers to embed View-based components into Jetpack Compose screens. All migrated screens must use Compose components or Media3 Compose surfaces (
AndroidView).PlayerSurface
将Android TV应用迁移到Jetpack Compose时,必须使用库并遵循以下10英尺UI模式:
androidx.tv- UI现代化:优先采用自定义电影级布局,而非旧版的1:1直接模板。必须使用Jetpack Compose for TV的特性,如动态渐变英雄背景、自定义焦点动画、自定义导航抽屉和自定义布局。
- 核心设计系统:必须始终使用(
androidx.tv.material3.*),而非移动端的androidx.tv:tv-material。TV Material 3提供内置的D-Pad焦点处理、焦点缩放动画,以及针对电视优化的排版和形状。如需配置Compose for TV依赖,请遵循Compose for TV 配置指南。androidx.compose.material3.* - 焦点缩放动画:对于交互式卡片,必须使用、
CompactCard或ClassicCard,并设置WideCardContainer,以提供标准的电视焦点动画。scale = CardDefaults.scale(focusedScale = 1.1f) - Coil图片加载:如需使用声明式的且无需传入显式
AsyncImage(model, contentDescription, ...)参数,必须在Gradle依赖中添加ImageLoader。io.coil-kt:coil-compose - 显式导入:必须始终显式导入TV Material 3类(例如、
import androidx.tv.material3.Surface),而非使用通配符导入(import androidx.tv.material3.ListItem)。import androidx.tv.material3.* - 文件命名规范:Composable屏幕文件需以屏幕名称命名(例如将浏览屏幕命名为,播放屏幕命名为
BrowseScreen.kt,认证屏幕命名为PlaybackScreen.kt)。请勿使用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包装器:请勿使用旧版包装器将基于View的组件嵌入Jetpack Compose屏幕。所有迁移后的屏幕必须使用Compose组件或Media3 Compose表面(
AndroidView)。PlayerSurface
D-pad focus handling and navigation
D-pad焦点处理与导航
Jetpack Compose for TV () requires explicit focus management, as components don't receive initial focus automatically and navigation uses 2D spatial coordinates. To configure TV D-pad navigation, follow instructions in TV Navigation guide.
androidx.tv.material3Jetpack Compose for TV()需要显式的焦点管理,因为组件不会自动获得初始焦点,且导航使用2D空间坐标。如需配置电视D-pad导航,请遵循TV导航指南中的说明。
androidx.tv.material3Initial focus
初始焦点
You must assign initial focus to the primary interactive element on every screen (such as the first action button, card, or ) using when entering a screen. Define , attach to the primary element, and request focus inside :
<br />
ListItemFocusRequesterval focusRequester = remember { FocusRequester() }Modifier.focusRequester(focusRequester)LaunchedEffect(Unit) { focusRequester.requestFocus() }kotlin
val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
Note: For screens with dynamic state or pagers (like using ), you must pass the state key to (for example ) so that focus is re-applied when the page changes.
OnboardingScreenHorizontalPagerLaunchedEffectLaunchedEffect(pagerState.currentPage)进入屏幕时,必须为每个屏幕上的主要交互元素(如第一个操作按钮、卡片或)分配初始焦点,可使用实现。定义,将附加到主要元素上,并在中请求焦点:
<br />
ListItemFocusRequesterval focusRequester = remember { FocusRequester() }Modifier.focusRequester(focusRequester)LaunchedEffect(Unit) { focusRequester.requestFocus() }kotlin
val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
注意:对于带有动态状态或分页器的屏幕(例如使用的),必须将状态键传递给(例如),以便页面切换时重新应用焦点。
HorizontalPagerOnboardingScreenLaunchedEffectLaunchedEffect(pagerState.currentPage)Bidirectional focus routing and avoiding focus traps
双向焦点路由与避免焦点陷阱
When interactive elements sit on opposite sides of the display, standard 2D spatial navigation fails to find targets across them. This creates focus traps where users are unable to navigate out of an area using the D-pad.
For symmetrical, bidirectional D-pad navigation without focus traps, you must rely on Compose's 2D spatial focus engine whenever possible. When connecting adjacent UI elements across scrollable containers (like or ), don't set directional overrides (, ) targeting individual items inside lazy lists. When an item scrolls off-screen during vertical navigation, its becomes uninitialized, throwing during focus searches:
<br />
LazyColumnLazyRowup = ...down = ...FocusRequesterIllegalStateExceptionkotlin
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 48.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.End
) {
Button(
onClick = { /* Search */ },
modifier = Modifier.focusRequester(topBarFocusRequester)
) { Text("Search") }
}
当交互元素位于显示屏的两侧时,标准的2D空间导航无法跨区域找到目标,这会导致焦点陷阱,用户无法通过D-pad导航离开该区域。
为实现无焦点陷阱的对称双向D-pad导航,必须尽可能依赖Compose的2D空间焦点引擎。连接滚动容器(如或)中的相邻UI元素时,请勿为懒加载列表内的单个项目设置方向覆盖(、)。垂直导航时,若项目滚动出屏幕,其会变为未初始化状态,在焦点搜索时抛出:
<br />
LazyColumnLazyRowup = ...down = ...FocusRequesterIllegalStateExceptionkotlin
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 48.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.End
) {
Button(
onClick = { /* Search */ },
modifier = Modifier.focusRequester(topBarFocusRequester)
) { Text("Search") }
}
Row focus recollection (Modifier.focusRestorer
)
Modifier.focusRestorer行焦点记忆(Modifier.focusRestorer
)
Modifier.focusRestorerWhen navigating vertically between horizontal carousels (), Compose's default 2D spatial focus engine searches along the X coordinate of the focused item. If a user scrolls right in Row 1 (for example to Item 4 at X=800dp) and presses DOWN to navigate to Row 2, spatial routing focuses whatever item sits at X=800dp in Row 2.
LazyRowTo make every row maintain its own recollection of card focus (restoring focus to the previously visited item when revisited), you must attach (with no arguments) directly to the . Don't pass custom fallback lambdas in lazy containers, as calling on an unattached or off-screen item during rapid D-pad scrolling throws .
<br />
Modifier.focusRestorerLazyRowFocusRequesterrequestFocusIllegalStateExceptionkotlin
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
}
}
)
}
}
在水平轮播()之间垂直导航时,Compose默认的2D空间焦点引擎会沿聚焦项目的X坐标搜索。例如,用户在第1行向右滚动到X=800dp的项目4,然后按DOWN键导航到第2行,空间路由会聚焦第2行中X=800dp的项目。
LazyRow为让每一行都能保持自身的卡片焦点记忆(重新访问时恢复之前聚焦的项目),必须直接在上附加****(无参数)。请勿在懒加载容器中传递自定义 fallback lambda,因为快速D-pad滚动时,对未附加或屏幕外项目调用会抛出。
<br />
LazyRowModifier.focusRestorerFocusRequesterrequestFocusIllegalStateExceptionkotlin
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
}
}
)
}
}
Text input, hardware keyboard enter interception, and IME focus chaining
文本输入、硬件键盘回车键拦截与IME焦点链
When migrating search bars or login forms from Leanback (, ), don't use bare containers or empty wrappers, as they prevent D-pad CENTER from attaching the virtual keyboard (IME).
SearchSupportFragmentGuidedStepSupportFragmentBasicTextFieldSurface(onClick = {})- Clickable TV surface wrapper with Back-key interception () : Wrap standard M3
onPreviewKeyEventinside a focusable TVTextFieldto provide a focused border outline and D-pad focus scaling. You must attachSurface(onClick = { focusRequester.requestFocus() }, scale = ClickableSurfaceDefaults.scale(focusedScale = 1.01f), border = ClickableSurfaceDefaults.border(focusedBorder = Border(BorderStroke(2.dp, Color.White))))on the text field or wrapper to interceptModifier.onPreviewKeyEventandKey.Backso the user's able to remove focus from the input field without exiting the screen.Key.Escape - Why Back-key interception is mandatory : When editing a text field on Android TV, pressing the D-pad Back button normally navigates back and exits the screen. By intercepting and
Key.BackonKey.EscapeinKeyUpto stop editing (clearing focus), the user's able to return to D-pad form navigation without being trapped in the text field or accidentally exiting the screen.onPreviewKeyEvent - IME focus chaining : For multi-field forms (such as Username and Password in authentication screens), attach with
KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) })on top fields to route focus down to the next input box, andImeAction.Nexton the bottom field to route focus directly to the submit button:ImeAction.Done
kotlin
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
}
)
}
将搜索栏或登录表单从Leanback(、)迁移时,请勿使用裸容器或空包装器,因为它们会阻止D-pad CENTER键唤起虚拟键盘(IME)。
SearchSupportFragmentGuidedStepSupportFragmentBasicTextFieldSurface(onClick = {})- 可点击TV Surface包装器与返回键拦截():将标准M3
onPreviewKeyEvent包裹在可聚焦的TVTextField中,以提供聚焦边框和D-pad焦点缩放效果。必须在文本字段或包装器上附加Surface(onClick = { focusRequester.requestFocus() }, scale = ClickableSurfaceDefaults.scale(focusedScale = 1.01f), border = ClickableSurfaceDefaults.border(focusedBorder = Border(BorderStroke(2.dp, Color.White)))),拦截Modifier.onPreviewKeyEvent和Key.Back,让用户无需退出屏幕即可移除输入字段的焦点。Key.Escape - 返回键拦截的必要性:在Android TV上编辑文本字段时,按D-pad的返回按钮通常会返回并退出屏幕。通过在的
onPreviewKeyEvent事件中拦截KeyUp和Key.Back以停止编辑(清除焦点),用户可以回到D-pad表单导航,而不会被困在文本字段中或意外退出屏幕。Key.Escape - IME焦点链:对于多字段表单(如认证屏幕中的用户名和密码),在顶部字段上设置和
KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),将焦点向下路由到下一个输入框;在底部字段上设置ImeAction.Next,将焦点直接路由到提交按钮:ImeAction.Done
kotlin
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
}
)
}
Lazy containers
懒加载容器
When implementing scrollable lists or grids in Jetpack Compose for TV, you must use standard , , and from and . For catalog browsing layouts, follow instructions in Catalog Browser guide.
LazyColumnLazyRowLazyVerticalGridandroidx.compose.foundation.lazyandroidx.compose.foundation.lazy.grid- Pivot scrolling with : When defining a custom pivot scroll line for catalog rows using
BringIntoViewSpecandBringIntoViewSpec, you must ensure your project compiles against Compose Foundation 1.7.0+ by addingCompositionLocalProvider(LocalBringIntoViewSpec provides ...)(or newer) orimplementation platform('androidx.compose:compose-bom:2024.06.00')inimplementation 'androidx.compose.foundation:foundation:1.7.0'. Without Compose Foundation 1.7.0+,app/build.gradlewill fail withimport androidx.compose.foundation.gestures.LocalBringIntoViewSpec.Unresolved reference: LocalBringIntoViewSpec - Row focus recollection () : Annotate your composable with
Modifier.focusRestorerand attach@OptIn(ExperimentalFocusRestorerApi::class, ExperimentalComposeUiApi::class)on every categoryModifier.focusRestorerto remember and restore the last focused card when navigating vertically across catalog rows.LazyRow
When populated with focusable TV Material 3 components (, , ), standard Compose lazy containers handle 2D D-Pad focus routing, focus memory, and edge scrolling automatically:
<br />
CompactCardListItemButtonkotlin
@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)
}
}
}
在Jetpack Compose for TV中实现滚动列表或网格时,必须使用和中的标准、和。如需实现目录浏览布局,请遵循目录浏览器指南中的说明。
androidx.compose.foundation.lazyandroidx.compose.foundation.lazy.gridLazyColumnLazyRowLazyVerticalGrid- 基于的轴心滚动:使用
BringIntoViewSpec和BringIntoViewSpec为目录行定义自定义轴心滚动线时,必须确保项目编译时依赖Compose Foundation 1.7.0+,可通过在CompositionLocalProvider(LocalBringIntoViewSpec provides ...)中添加app/build.gradle(或更新版本)或implementation platform('androidx.compose:compose-bom:2024.06.00')来实现。如果没有Compose Foundation 1.7.0+,implementation 'androidx.compose.foundation:foundation:1.7.0'会因import androidx.compose.foundation.gestures.LocalBringIntoViewSpec而失败。Unresolved reference: LocalBringIntoViewSpec - 行焦点记忆():使用
Modifier.focusRestorer注解你的composable,并在每个分类@OptIn(ExperimentalFocusRestorerApi::class, ExperimentalComposeUiApi::class)上附加LazyRow,以便在目录行之间垂直导航时记住并恢复最后聚焦的卡片。Modifier.focusRestorer
当填充可聚焦的TV Material 3组件(、、)时,标准Compose懒加载容器会自动处理2D D-Pad焦点路由、焦点记忆和边缘滚动:
<br />
CompactCardListItemButtonkotlin
@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 video playback and transport controls
Media3视频播放与传输控件
When migrating legacy Leanback video playback ( / ), use Compose Media3 () combined with a translucent transport controls overlay:
VideoSupportFragmentPlaybackGluePlayerSurfaceandroidx.media3.ui.compose.PlayerSurface- Mandatory Media3 transport control buttons () : Over the
PlayPauseButton, you must layer a translucent bottom controls bar (PlayerSurface) containing explicit Media3 UI Compose buttons: at minimumBox(modifier = Modifier.align(Alignment.BottomCenter)),PlayPauseButton, andSeekBackButton. Never leave the transport controls overlay empty. To use these composables, you must addSeekForwardButtonalongsideimplementation 'androidx.media3:media3-ui-compose-material3:1.6.0'in yourandroidx.media3:media3-ui-composedependencies.app/build.gradle - D-pad directional seeking () : To support seeking backward and forward with the remote control D-pad, attach
onPreviewKeyEventon the container or controls overlay and intercept ComposeModifier.onPreviewKeyEventandKey.DirectionLeft(for example,Key.DirectionRight) to seek backward and forward by 10 seconds (keyEvent.key == Key.DirectionLeft). Never use legacy Android View keycodes (exoPlayer.seekTo(exoPlayer.currentPosition - 10000)orKeyEvent.KEYCODE_DPAD_LEFT).nativeKeyEvent.keyCode - Prohibition of legacy AndroidView wrappers : Don't wrap legacy or
PlayerViewinStyledPlayerView. You must useAndroidView { ... }(PlayerSurface) withandroidx.media3.ui.compose.PlayerSurfacefor video rendering in Compose for TV.ExoPlayer
迁移旧版Leanback视频播放( / )时,需结合使用Compose Media3 ()和半透明传输控件覆盖层:
VideoSupportFragmentPlaybackGluePlayerSurfaceandroidx.media3.ui.compose.PlayerSurface- 必填Media3传输控制按钮():必须在
PlayPauseButton上方添加半透明底部控制栏(PlayerSurface),包含明确的Media3 UI Compose按钮:至少需包含Box(modifier = Modifier.align(Alignment.BottomCenter))、PlayPauseButton和SeekBackButton。传输控件覆盖层不能为空。如需使用这些composable,必须在SeekForwardButton依赖中添加app/build.gradle,并与implementation 'androidx.media3:media3-ui-compose-material3:1.6.0'一起使用。androidx.media3:media3-ui-compose - D-pad方向快进/快退():如需支持遥控器D-pad的快进和快退功能,需在容器或控件覆盖层上附加
onPreviewKeyEvent,拦截Compose的Modifier.onPreviewKeyEvent和Key.DirectionLeft(例如Key.DirectionRight),实现快退10秒(keyEvent.key == Key.DirectionLeft)。请勿使用旧版Android View键码(exoPlayer.seekTo(exoPlayer.currentPosition - 10000)或KeyEvent.KEYCODE_DPAD_LEFT)。nativeKeyEvent.keyCode - 禁用旧版AndroidView包装器:请勿将旧版或
PlayerView包裹在StyledPlayerView中。必须使用AndroidView { ... }(PlayerSurface)搭配androidx.media3.ui.compose.PlayerSurface在Compose for TV中进行视频渲染。ExoPlayer
Phased migration strategy
分阶段迁移策略
To migrate an app cleanly without breaking compilation or introducing circular dependencies, execute in five distinct phases:
- Phase 1: Foundation and design system
- Phase 2: Leaf and standalone screens
- Phase 3: Core browsing and discovery screens
- Phase 4: Details and media playback
- Phase 5: Final unification and cleanup
为了在不破坏编译或引入循环依赖的情况下干净地迁移应用,请按以下五个阶段执行:
- 阶段1:基础架构与设计系统
- 阶段2:叶子屏幕与独立屏幕
- 阶段3:核心浏览与发现屏幕
- 阶段4:详情与媒体播放
- 阶段5:最终整合与清理
Phase 1: Foundation and design system
阶段1:基础架构与设计系统
- Create wrapping
TvTheme.ktwith customTvMaterialTheme,ColorScheme, andTypography.Shapes - Build atomic reusable components: ,
MovieCard,SectionHeader,LoadingIndicator.ErrorState
- 创建,封装
TvTheme.kt并自定义TvMaterialTheme、ColorScheme和Typography。Shapes - 构建原子级可复用组件:、
MovieCard、SectionHeader、LoadingIndicator。ErrorState
Phase 2: Leaf and standalone screens
阶段2:叶子屏幕与独立屏幕
- Migrate screens with no outbound navigation first, such as error messages, onboarding screens, or settings screens, by replacing them with ,
ErrorDialog, andOnboardingScreen.SettingsScreen - Replace anything using ,
BaseLeanbackPreferenceFragmentCompat, orBaseLeanbackPreferenceFragmentto use Compose, for example by usingLeanbackSettingsFragment+ListItembound directly toSwitchand replacing theSharedPreferencesimplementation.findPreference - Ensure screens receive initial D-pad focus on the first or main component of that screen using , for example on the first
FocusRequester.ListItem - Replace legacy classes with activities of type
Fragmentthat declaratively use components in Compose.ComponentActivity - Clean up legacy style and theme references in and
res/values/styles.xml(such as removingres/values/themes.xmlthat points topreferenceTheme) that aren't supported once leanback dependencies are removed:@style/PreferenceThemeOverlay.v14.Leanback
kotlin
@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
)
}
)
}
}
}
}
- 先迁移无外部导航的屏幕,如错误提示、引导屏幕或设置屏幕,用、
ErrorDialog和OnboardingScreen替换旧版实现。SettingsScreen - 替换所有使用、
BaseLeanbackPreferenceFragmentCompat或BaseLeanbackPreferenceFragment的实现,改用Compose,例如使用LeanbackSettingsFragment+ListItem直接绑定Switch,并替换SharedPreferences的实现。findPreference - 确保屏幕使用将初始D-pad焦点设置在屏幕的第一个或主要组件上,例如第一个
FocusRequester。ListItem - 用声明式使用Compose组件的类型的Activity替换旧版
ComponentActivity类。Fragment - 清理和
res/values/styles.xml中不再支持的旧版样式和主题引用(例如移除指向res/values/themes.xml的@style/PreferenceThemeOverlay.v14.Leanback),这些引用在移除leanback依赖后将不再有效:preferenceTheme
kotlin
@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
)
}
)
}
}
}
}
Phase 3: Core browsing and discovery screens
阶段3:核心浏览与发现屏幕
- Migrate (
VerticalGridScreen),LazyVerticalGrid(SearchScreenwith live list filtering), andBasicTextField(BrowseScreenofLazyColumns).LazyRow - Eradicate legacy ,
ArrayObjectAdapter,ListRowPresenter, andCardPresenterclasses.HeaderItem
- 迁移(
VerticalGridScreen)、LazyVerticalGrid(带实时列表过滤的SearchScreen)和BasicTextField(由BrowseScreen组成的LazyRow)。LazyColumn - 移除旧版、
ArrayObjectAdapter、ListRowPresenter和CardPresenter类。HeaderItem
Phase 4: Details and media playback
阶段4:详情与媒体播放
- Migrate and
VideoDetailsScreen.GuidedStepScreen - Migrate using Compose Media3
PlaybackScreen(PlayerSurface) paired with a translucent bottom overlay containing Media3 Compose transport controls (such asandroidx.media3.ui.compose.PlayerSurface,PlayPauseButton,SeekBackButton).SeekForwardButton
- 迁移和
VideoDetailsScreen。GuidedStepScreen - 使用Compose Media3 (
PlayerSurface)搭配包含Media3 Compose传输控件(如androidx.media3.ui.compose.PlayerSurface、PlayPauseButton、SeekBackButton)的半透明底部覆盖层,迁移SeekForwardButton。PlaybackScreen
Phase 5: Final unification and cleanup
阶段5:最终整合与清理
- Remove all remaining legacy activities, fragments, presenters, and XML layout files.
.java - Ensure all activities extend or
ComponentActivitycallingFragmentActivity.setContent { ... } - Completely remove legacy Leanback themes and style declarations from and
res/values/styles.xml(for example, any styles inheriting fromres/values/themes.xmlor referencingTheme.Leanbackstyles).lb_
- 移除所有剩余的旧版Activity、Fragment、Presenter和XML布局文件。
.java - 确保所有Activity都继承自或
ComponentActivity,并调用FragmentActivity。setContent { ... } - 彻底移除和
res/values/styles.xml中的旧版Leanback主题和样式声明(例如任何继承自res/values/themes.xml或引用Theme.Leanback样式的样式)。lb_
Component and class mapping guide
组件与类映射指南
| Legacy Leanback / View Class | Modern Jetpack Compose Equivalent |
|---|---|
| |
| |
| |
| |
| |
| |
| Declarative |
| Kotlin Coroutines / |
| |
| |
| 旧版Leanback / View类 | 现代Jetpack Compose等效组件 |
|---|---|
| |
| |
| |
| |
| |
| |
| 观察不可变 |
| Repository对象中的Kotlin协程 / |
| |
| |
Reference implementations and battle-tested patterns
参考实现与经过验证的模式
Modern TV immersive list architecture (BrowseScreen
)
BrowseScreen现代TV沉浸式列表架构(BrowseScreen
)
BrowseScreenWhen building a 10-foot TV browse screen or Immersive List, don't place a hero banner before or outside a scrolling list, and don't fight Compose's automatic system with programmatic calls.
BringIntoViewanimateScrollToItemInstead, use with from Compose Foundation to define exact TV pivot scrolling (for example, pivoting active rows at 35% from the top edge of the display). Combine this with a reshaping immersive row: when lower rows are focused (), hide the hero text and display a normal section header on Row 0:
<br />
BringIntoViewSpecLocalBringIntoViewSpecfocusedCategoryIndex > 0kotlin
@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)
}
构建10英尺TV浏览屏幕或沉浸式列表时,请勿将英雄横幅放在滚动列表之前或之外,也不要通过程序化的调用对抗Compose的自动系统。
animateScrollToItemBringIntoView相反,请使用Compose Foundation中的和定义精确的TV轴心滚动(例如,将活动行定位在距离显示屏顶部边缘35%的位置)。结合可变形的沉浸式行:当聚焦下方行时(),隐藏英雄文本并在第0行显示普通的章节标题:
<br />
BringIntoViewSpecLocalBringIntoViewSpecfocusedCategoryIndex > 0kotlin
@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)
}
Media3 playback in Compose TV (PlaybackScreen
)
PlaybackScreenCompose TV中的Media3播放(PlaybackScreen
)
PlaybackScreenImplement a custom playback screen using as the video rendering canvas. Layer Material3 transport controls (, , from ) over the surface in a translucent bottom overlay, and handle D-pad remote key events with an auto-hide timeout:
<br />
androidx.media3.ui.compose.PlayerSurfaceSeekBackButtonPlayPauseButtonSeekForwardButtonandroidx.media3:media3-ui-compose-material3kotlin
@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)
}
}
}
}
}
使用作为视频渲染画布,实现自定义播放屏幕。在表面上方的半透明底部覆盖层中添加Material3传输控件(来自的、、),并通过自动隐藏超时处理D-pad遥控器按键事件:
<br />
androidx.media3.ui.compose.PlayerSurfaceandroidx.media3:media3-ui-compose-material3SeekBackButtonPlayPauseButtonSeekForwardButtonkotlin
@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)
}
}
}
}
}
Replacing CursorLoader with reactive coroutine flow
用响应式协程流替换CursorLoader
Replace legacy and with a repository returning a that observes database changes and triggers asynchronous fetching when empty:
<br />
LoaderManager.LoaderCallbacks<Cursor>CursorObjectAdapterFlowkotlin
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()
}
}
用返回的Repository替换旧版和,该Flow可观察数据库变化,并在数据为空时触发异步获取:
<br />
FlowLoaderManager.LoaderCallbacks<Cursor>CursorObjectAdapterkotlin
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()
}
}