leanback-to-compose-tv-migration

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

The 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
    ,
    Right
    ) with components organized into clear spatial grids and carousels without focus traps.
"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
androidx.tv
libraries and follow these 10-foot UI patterns:
  • 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.*
    (
    androidx.tv:tv-material
    ) over mobile
    androidx.compose.material3.*
    . 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.
  • Focus zoom animation : For interactive cards, you must use
    CompactCard
    ,
    ClassicCard
    , or
    WideCardContainer
    with
    scale = CardDefaults.scale(focusedScale = 1.1f)
    to provide standard TV focus animation.
  • Coil image loading : To use declarative
    AsyncImage(model, contentDescription, ...)
    without passing an explicit
    ImageLoader
    parameter, you must include
    io.coil-kt:coil-compose
    in your Gradle dependencies.
  • Explicit imports : You must always import TV Material 3 classes explicitly (for example,
    import androidx.tv.material3.Surface
    ,
    import androidx.tv.material3.ListItem
    ) instead of using wildcard imports (
    import androidx.tv.material3.*
    ).
  • File naming conventions : You must name Composable screen files after the screen (for example, name
    BrowseScreen
    as
    BrowseScreen.kt
    ,
    PlaybackScreen
    as
    PlaybackScreen.kt
    , and
    AuthenticationScreen
    as
    AuthenticationScreen.kt
    ). Don't use generic prefixes like
    Main
    .
  • Overscan and bezels : You must apply horizontal padding (for example,
    horizontal = 48.dp
    or
    32.dp
    ,
    vertical = 24.dp
    ) to root containers, carousels, and top bars to prevent clipping.
  • Reading width constrainment : You must constrain reading width using
    Modifier.widthIn(max = 600.dp)
    on text columns for long-form text.
  • Media3 Compose dependencies : Include
    androidx.media3:media3-ui-compose
    in
    app/build.gradle
    when modernizing media playback screens.
  • Prohibition of legacy AndroidView wrappers : Don't use legacy
    AndroidView
    wrappers to embed View-based components into Jetpack Compose screens. All migrated screens must use Compose components or Media3 Compose surfaces (
    PlayerSurface
    ).
将Android TV应用迁移到Jetpack Compose时,必须使用
androidx.tv
库并遵循以下10英尺UI模式:
  • UI现代化:优先采用自定义电影级布局,而非旧版的1:1直接模板。必须使用Jetpack Compose for TV的特性,如动态渐变英雄背景、自定义焦点动画、自定义导航抽屉和自定义布局。
  • 核心设计系统:必须始终使用
    androidx.tv.material3.*
    androidx.tv:tv-material
    ),而非移动端的
    androidx.compose.material3.*
    。TV Material 3提供内置的D-Pad焦点处理、焦点缩放动画,以及针对电视优化的排版和形状。如需配置Compose for TV依赖,请遵循Compose for TV 配置指南
  • 焦点缩放动画:对于交互式卡片,必须使用
    CompactCard
    ClassicCard
    WideCardContainer
    ,并设置
    scale = CardDefaults.scale(focusedScale = 1.1f)
    ,以提供标准的电视焦点动画。
  • 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.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包装器:请勿使用旧版
    AndroidView
    包装器将基于View的组件嵌入Jetpack Compose屏幕。所有迁移后的屏幕必须使用Compose组件或Media3 Compose表面(
    PlayerSurface
    )。

D-pad focus handling and navigation

D-pad焦点处理与导航

Jetpack Compose for TV (
androidx.tv.material3
) 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.
Jetpack Compose for TV(
androidx.tv.material3
)需要显式的焦点管理,因为组件不会自动获得初始焦点,且导航使用2D空间坐标。如需配置电视D-pad导航,请遵循TV导航指南中的说明。

Initial focus

初始焦点

You must assign initial focus to the primary interactive element on every screen (such as the first action button, card, or
ListItem
) using
FocusRequester
when entering a screen. Define
val focusRequester = remember { FocusRequester() }
, attach
Modifier.focusRequester(focusRequester)
to the primary element, and request focus inside
LaunchedEffect(Unit) { focusRequester.requestFocus() }
:
<br />
kotlin
val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current

LaunchedEffect(Unit) {
    focusRequester.requestFocus()
}
   
<br />
Note: For screens with dynamic state or pagers (like
OnboardingScreen
using
HorizontalPager
), you must pass the state key to
LaunchedEffect
(for example
LaunchedEffect(pagerState.currentPage)
) so that focus is re-applied when the page changes.
进入屏幕时,必须为每个屏幕上的主要交互元素(如第一个操作按钮、卡片或
ListItem
)分配初始焦点,可使用
FocusRequester
实现。定义
val focusRequester = remember { FocusRequester() }
,将
Modifier.focusRequester(focusRequester)
附加到主要元素上,并在
LaunchedEffect(Unit) { focusRequester.requestFocus() }
中请求焦点:
<br />
kotlin
val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current

LaunchedEffect(Unit) {
    focusRequester.requestFocus()
}
   
<br />
注意:对于带有动态状态或分页器的屏幕(例如使用
HorizontalPager
OnboardingScreen
),必须将状态键传递给
LaunchedEffect
(例如
LaunchedEffect(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
LazyColumn
or
LazyRow
), don't set directional overrides (
up = ...
,
down = ...
) targeting individual items inside lazy lists. When an item scrolls off-screen during vertical navigation, its
FocusRequester
becomes uninitialized, throwing
IllegalStateException
during focus searches:
<br />
kotlin
Row(
    modifier = Modifier
        .fillMaxWidth()
        .padding(horizontal = 48.dp, vertical = 16.dp),
    horizontalArrangement = Arrangement.End
) {
    Button(
        onClick = { /* Search */ },
        modifier = Modifier.focusRequester(topBarFocusRequester)
    ) { Text("Search") }
}
   
<br />
当交互元素位于显示屏的两侧时,标准的2D空间导航无法跨区域找到目标,这会导致焦点陷阱,用户无法通过D-pad导航离开该区域。
为实现无焦点陷阱的对称双向D-pad导航,必须尽可能依赖Compose的2D空间焦点引擎。连接滚动容器(如
LazyColumn
LazyRow
)中的相邻UI元素时,请勿为懒加载列表内的单个项目设置方向覆盖(
up = ...
down = ...
)。垂直导航时,若项目滚动出屏幕,其
FocusRequester
会变为未初始化状态,在焦点搜索时抛出
IllegalStateException
<br />
kotlin
Row(
    modifier = Modifier
        .fillMaxWidth()
        .padding(horizontal = 48.dp, vertical = 16.dp),
    horizontalArrangement = Arrangement.End
) {
    Button(
        onClick = { /* Search */ },
        modifier = Modifier.focusRequester(topBarFocusRequester)
    ) { Text("Search") }
}
   
<br />

Row focus recollection (
Modifier.focusRestorer
)

行焦点记忆(
Modifier.focusRestorer

When navigating vertically between horizontal carousels (
LazyRow
), 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.
To make every row maintain its own recollection of card focus (restoring focus to the previously visited item when revisited), you must attach
Modifier.focusRestorer
(with no arguments) directly to the
LazyRow
. Don't pass custom fallback
FocusRequester
lambdas in lazy containers, as calling
requestFocus
on an unattached or off-screen item during rapid D-pad scrolling throws
IllegalStateException
.
<br />
kotlin
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
                    }
                }
        )
    }
}
   
<br />
在水平轮播(
LazyRow
)之间垂直导航时,Compose默认的2D空间焦点引擎会沿聚焦项目的X坐标搜索。例如,用户在第1行向右滚动到X=800dp的项目4,然后按DOWN键导航到第2行,空间路由会聚焦第2行中X=800dp的项目。
为让每一行都能保持自身的卡片焦点记忆(重新访问时恢复之前聚焦的项目),必须直接在
LazyRow
上附加**
Modifier.focusRestorer
**(无参数)。请勿在懒加载容器中传递自定义 fallback
FocusRequester
lambda,因为快速D-pad滚动时,对未附加或屏幕外项目调用
requestFocus
会抛出
IllegalStateException
<br />
kotlin
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
                    }
                }
        )
    }
}
   
<br />

Text input, hardware keyboard enter interception, and IME focus chaining

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

When migrating search bars or login forms from Leanback (
SearchSupportFragment
,
GuidedStepSupportFragment
), don't use bare
BasicTextField
containers or empty
Surface(onClick = {})
wrappers, as they prevent D-pad CENTER from attaching the virtual keyboard (IME).
  1. Clickable TV surface wrapper with Back-key interception (
    onPreviewKeyEvent
    )
    : Wrap standard M3
    TextField
    inside a focusable TV
    Surface(onClick = { focusRequester.requestFocus() }, scale = ClickableSurfaceDefaults.scale(focusedScale = 1.01f), border = ClickableSurfaceDefaults.border(focusedBorder = Border(BorderStroke(2.dp, Color.White))))
    to provide a focused border outline and D-pad focus scaling. You must attach
    Modifier.onPreviewKeyEvent
    on the text field or wrapper to intercept
    Key.Back
    and
    Key.Escape
    so the user's able to remove focus from the input field without exiting the screen.
  2. 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
    Key.Back
    and
    Key.Escape
    on
    KeyUp
    in
    onPreviewKeyEvent
    to 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.
  3. IME focus chaining : For multi-field forms (such as Username and Password in authentication screens), attach
    KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) })
    with
    ImeAction.Next
    on top fields to route focus down to the next input box, and
    ImeAction.Done
    on the bottom field to route focus directly to the submit button:
<br />
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
            }
    )
}
   
<br />
将搜索栏或登录表单从Leanback(
SearchSupportFragment
GuidedStepSupportFragment
)迁移时,请勿使用裸
BasicTextField
容器或空
Surface(onClick = {})
包装器,因为它们会阻止D-pad CENTER键唤起虚拟键盘(IME)。
  1. 可点击TV Surface包装器与返回键拦截(
    onPreviewKeyEvent
    :将标准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的返回按钮通常会返回并退出屏幕。通过在
    onPreviewKeyEvent
    KeyUp
    事件中拦截
    Key.Back
    Key.Escape
    以停止编辑(清除焦点),用户可以回到D-pad表单导航,而不会被困在文本字段中或意外退出屏幕。
  3. IME焦点链:对于多字段表单(如认证屏幕中的用户名和密码),在顶部字段上设置
    KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) })
    ImeAction.Next
    ,将焦点向下路由到下一个输入框;在底部字段上设置
    ImeAction.Done
    ,将焦点直接路由到提交按钮:
<br />
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
            }
    )
}
   
<br />

Lazy containers

懒加载容器

When implementing scrollable lists or grids in Jetpack Compose for TV, you must use standard
LazyColumn
,
LazyRow
, and
LazyVerticalGrid
from
androidx.compose.foundation.lazy
and
androidx.compose.foundation.lazy.grid
. For catalog browsing layouts, follow instructions in Catalog Browser guide.
  1. Pivot scrolling with
    BringIntoViewSpec
    : When defining a custom pivot scroll line for catalog rows using
    BringIntoViewSpec
    and
    CompositionLocalProvider(LocalBringIntoViewSpec provides ...)
    , you must ensure your project compiles against Compose Foundation 1.7.0+ by adding
    implementation platform('androidx.compose:compose-bom:2024.06.00')
    (or newer) or
    implementation 'androidx.compose.foundation:foundation:1.7.0'
    in
    app/build.gradle
    . Without Compose Foundation 1.7.0+,
    import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
    will fail with
    Unresolved reference: LocalBringIntoViewSpec
    .
  2. Row focus recollection (
    Modifier.focusRestorer
    )
    : Annotate your composable with
    @OptIn(ExperimentalFocusRestorerApi::class, ExperimentalComposeUiApi::class)
    and attach
    Modifier.focusRestorer
    on every category
    LazyRow
    to remember and restore the last focused card when navigating vertically across catalog rows.
When populated with focusable TV Material 3 components (
CompactCard
,
ListItem
,
Button
), standard Compose lazy containers handle 2D D-Pad focus routing, focus memory, and edge scrolling automatically:
<br />
kotlin
@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)
        }
    }
}
   
<br />
在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)
    注解你的composable,并在每个分类
    LazyRow
    上附加
    Modifier.focusRestorer
    ,以便在目录行之间垂直导航时记住并恢复最后聚焦的卡片。
当填充可聚焦的TV Material 3组件(
CompactCard
ListItem
Button
)时,标准Compose懒加载容器会自动处理2D D-Pad焦点路由、焦点记忆和边缘滚动:
<br />
kotlin
@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)
        }
    }
}
   
<br />

Media3 video playback and transport controls

Media3视频播放与传输控件

When migrating legacy Leanback video playback (
VideoSupportFragment
/
PlaybackGlue
), use Compose Media3
PlayerSurface
(
androidx.media3.ui.compose.PlayerSurface
) combined with a translucent transport controls overlay:
  1. Mandatory Media3 transport control buttons (
    PlayPauseButton
    )
    : Over the
    PlayerSurface
    , you must layer a translucent bottom controls bar (
    Box(modifier = Modifier.align(Alignment.BottomCenter))
    ) containing explicit Media3 UI Compose buttons: at minimum
    PlayPauseButton
    ,
    SeekBackButton
    , and
    SeekForwardButton
    . Never leave the transport controls overlay empty. To use these composables, you must add
    implementation 'androidx.media3:media3-ui-compose-material3:1.6.0'
    alongside
    androidx.media3:media3-ui-compose
    in your
    app/build.gradle
    dependencies.
  2. D-pad directional seeking (
    onPreviewKeyEvent
    )
    : To support seeking backward and forward with the remote control D-pad, attach
    Modifier.onPreviewKeyEvent
    on the container or controls overlay and intercept Compose
    Key.DirectionLeft
    and
    Key.DirectionRight
    (for example,
    keyEvent.key == Key.DirectionLeft
    ) to seek backward and forward by 10 seconds (
    exoPlayer.seekTo(exoPlayer.currentPosition - 10000)
    ). Never use legacy Android View keycodes (
    KeyEvent.KEYCODE_DPAD_LEFT
    or
    nativeKeyEvent.keyCode
    ).
  3. Prohibition of legacy AndroidView wrappers : Don't wrap legacy
    PlayerView
    or
    StyledPlayerView
    in
    AndroidView { ... }
    . You must use
    PlayerSurface
    (
    androidx.media3.ui.compose.PlayerSurface
    ) with
    ExoPlayer
    for video rendering in Compose for TV.
迁移旧版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
    。传输控件覆盖层不能为空。如需使用这些composable,必须在
    app/build.gradle
    依赖中添加
    implementation 'androidx.media3:media3-ui-compose-material3:1.6.0'
    ,并与
    androidx.media3:media3-ui-compose
    一起使用。
  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 { ... }
    中。必须使用
    PlayerSurface
    androidx.media3.ui.compose.PlayerSurface
    )搭配
    ExoPlayer
    在Compose for TV中进行视频渲染。

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
    TvTheme.kt
    wrapping
    TvMaterialTheme
    with custom
    ColorScheme
    ,
    Typography
    , and
    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
    ,
    OnboardingScreen
    , and
    SettingsScreen
    .
  • Replace anything using
    BaseLeanbackPreferenceFragmentCompat
    ,
    BaseLeanbackPreferenceFragment
    , or
    LeanbackSettingsFragment
    to use Compose, for example by using
    ListItem
    +
    Switch
    bound directly to
    SharedPreferences
    and replacing the
    findPreference
    implementation.
  • Ensure screens receive initial D-pad focus on the first or main component of that screen using
    FocusRequester
    , for example on the first
    ListItem
    .
  • Replace legacy
    Fragment
    classes with activities of type
    ComponentActivity
    that declaratively use components in Compose.
  • Clean up legacy style and theme references in
    res/values/styles.xml
    and
    res/values/themes.xml
    (such as removing
    preferenceTheme
    that points to
    @style/PreferenceThemeOverlay.v14.Leanback
    ) that aren't supported once leanback dependencies are removed:
<br />
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
                        )
                    }
                )
            }
        }
    }
}
   
<br />
  • 先迁移无外部导航的屏幕,如错误提示、引导屏幕或设置屏幕,用
    ErrorDialog
    OnboardingScreen
    SettingsScreen
    替换旧版实现。
  • 替换所有使用
    BaseLeanbackPreferenceFragmentCompat
    BaseLeanbackPreferenceFragment
    LeanbackSettingsFragment
    的实现,改用Compose,例如使用
    ListItem
    +
    Switch
    直接绑定
    SharedPreferences
    ,并替换
    findPreference
    的实现。
  • 确保屏幕使用
    FocusRequester
    将初始D-pad焦点设置在屏幕的第一个或主要组件上,例如第一个
    ListItem
  • 用声明式使用Compose组件的
    ComponentActivity
    类型的Activity替换旧版
    Fragment
    类。
  • 清理
    res/values/styles.xml
    res/values/themes.xml
    中不再支持的旧版样式和主题引用(例如移除指向
    @style/PreferenceThemeOverlay.v14.Leanback
    preferenceTheme
    ),这些引用在移除leanback依赖后将不再有效:
<br />
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
                        )
                    }
                )
            }
        }
    }
}
   
<br />

Phase 3: Core browsing and discovery screens

阶段3:核心浏览与发现屏幕

  • Migrate
    VerticalGridScreen
    (
    LazyVerticalGrid
    ),
    SearchScreen
    (
    BasicTextField
    with live list filtering), and
    BrowseScreen
    (
    LazyColumn
    of
    LazyRow
    s).
  • Eradicate legacy
    ArrayObjectAdapter
    ,
    ListRowPresenter
    ,
    CardPresenter
    , and
    HeaderItem
    classes.
  • 迁移
    VerticalGridScreen
    LazyVerticalGrid
    )、
    SearchScreen
    (带实时列表过滤的
    BasicTextField
    )和
    BrowseScreen
    (由
    LazyRow
    组成的
    LazyColumn
    )。
  • 移除旧版
    ArrayObjectAdapter
    ListRowPresenter
    CardPresenter
    HeaderItem
    类。

Phase 4: Details and media playback

阶段4:详情与媒体播放

  • Migrate
    VideoDetailsScreen
    and
    GuidedStepScreen
    .
  • Migrate
    PlaybackScreen
    using Compose Media3
    PlayerSurface
    (
    androidx.media3.ui.compose.PlayerSurface
    ) paired with a translucent bottom overlay containing Media3 Compose transport controls (such as
    PlayPauseButton
    ,
    SeekBackButton
    ,
    SeekForwardButton
    ).
  • 迁移
    VideoDetailsScreen
    GuidedStepScreen
  • 使用Compose Media3
    PlayerSurface
    androidx.media3.ui.compose.PlayerSurface
    )搭配包含Media3 Compose传输控件(如
    PlayPauseButton
    SeekBackButton
    SeekForwardButton
    )的半透明底部覆盖层,迁移
    PlaybackScreen

Phase 5: Final unification and cleanup

阶段5:最终整合与清理

  • Remove all remaining legacy
    .java
    activities, fragments, presenters, and XML layout files.
  • Ensure all activities extend
    ComponentActivity
    or
    FragmentActivity
    calling
    setContent { ... }
    .
  • Completely remove legacy Leanback themes and style declarations from
    res/values/styles.xml
    and
    res/values/themes.xml
    (for example, any styles inheriting from
    Theme.Leanback
    or referencing
    lb_
    styles).
  • 移除所有剩余的旧版
    .java
    Activity、Fragment、Presenter和XML布局文件。
  • 确保所有Activity都继承自
    ComponentActivity
    FragmentActivity
    ,并调用
    setContent { ... }
  • 彻底移除
    res/values/styles.xml
    res/values/themes.xml
    中的旧版Leanback主题和样式声明(例如任何继承自
    Theme.Leanback
    或引用
    lb_
    样式的样式)。

Component and class mapping guide

组件与类映射指南

Legacy Leanback / View ClassModern Jetpack Compose Equivalent
BrowseSupportFragment
/
MainFragment
BrowseScreen
(
LazyColumn
containing categorized
LazyRow
s + Hero Banner)
DetailsSupportFragment
VideoDetailsScreen
(Poster image, text column, action buttons, related
LazyRow
)
VideoSupportFragment
/
PlaybackGlue
PlaybackScreen
(Media3
ExoPlayer
+ Compose
PlayerSurface
)
GuidedStepSupportFragment
GuidedStepScreen
(Split-screen layout: 40% left guidance pane, 60% right actions pane)
SearchSupportFragment
SearchScreen
(
BasicTextField
+ live filtering +
LazyVerticalGrid
)
VerticalGridSupportFragment
VerticalGridScreen
(
LazyVerticalGrid(columns = GridCells.Fixed(5))
)
ArrayObjectAdapter
/
Presenter
Declarative
@Composable
functions observing immutable
State<List<T>>
CursorMapper
/
LoaderManager
/
CursorLoader
Kotlin Coroutines /
withContext(Dispatchers.IO)
in a Repository object
OnboardingSupportFragment
OnboardingScreen
(
HorizontalPager
+ D-Pad navigation buttons)
LeanbackSettingsFragment
/
PreferenceFragment
SettingsScreen
(
FocusRequester
on first
ListItem
+ trailing
Switch
bound to
SharedPreferences
)
旧版Leanback / View类现代Jetpack Compose等效组件
BrowseSupportFragment
/
MainFragment
BrowseScreen
(包含分类
LazyRow
和英雄横幅的
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
观察不可变
State<List<T>>
的声明式
@Composable
函数
CursorMapper
/
LoaderManager
/
CursorLoader
Repository对象中的Kotlin协程 /
withContext(Dispatchers.IO)
OnboardingSupportFragment
OnboardingScreen
HorizontalPager
+ D-Pad导航按钮)
LeanbackSettingsFragment
/
PreferenceFragment
SettingsScreen
(第一个
ListItem
上的
FocusRequester
+ 绑定
SharedPreferences
的尾随
Switch

Reference implementations and battle-tested patterns

参考实现与经过验证的模式

Modern TV immersive list architecture (
BrowseScreen
)

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

When 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
BringIntoView
system with programmatic
animateScrollToItem
calls.
Instead, use
BringIntoViewSpec
with
LocalBringIntoViewSpec
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 (
focusedCategoryIndex > 0
), hide the hero text and display a normal section header on Row 0:
<br />
kotlin
@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)
}
   
<br />
构建10英尺TV浏览屏幕或沉浸式列表时,请勿将英雄横幅放在滚动列表之前或之外,也不要通过程序化的
animateScrollToItem
调用对抗Compose的自动
BringIntoView
系统。
相反,请使用Compose Foundation中的
BringIntoViewSpec
LocalBringIntoViewSpec
定义精确的TV轴心滚动(例如,将活动行定位在距离显示屏顶部边缘35%的位置)。结合可变形的沉浸式行:当聚焦下方行时(
focusedCategoryIndex > 0
),隐藏英雄文本并在第0行显示普通的章节标题:
<br />
kotlin
@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)
}
   
<br />

Media3 playback in Compose TV (
PlaybackScreen
)

Compose TV中的Media3播放(
PlaybackScreen

Implement a custom playback screen using
androidx.media3.ui.compose.PlayerSurface
as the video rendering canvas. Layer Material3 transport controls (
SeekBackButton
,
PlayPauseButton
,
SeekForwardButton
from
androidx.media3:media3-ui-compose-material3
) over the surface in a translucent bottom overlay, and handle D-pad remote key events with an auto-hide timeout:
<br />
kotlin
@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)
                }
            }
        }
    }
}
   
<br />
使用
androidx.media3.ui.compose.PlayerSurface
作为视频渲染画布,实现自定义播放屏幕。在表面上方的半透明底部覆盖层中添加Material3传输控件(来自
androidx.media3:media3-ui-compose-material3
SeekBackButton
PlayPauseButton
SeekForwardButton
),并通过自动隐藏超时处理D-pad遥控器按键事件:
<br />
kotlin
@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)
                }
            }
        }
    }
}
   
<br />

Replacing CursorLoader with reactive coroutine flow

用响应式协程流替换CursorLoader

Replace legacy
LoaderManager.LoaderCallbacks<Cursor>
and
CursorObjectAdapter
with a repository returning a
Flow
that observes database changes and triggers asynchronous fetching when empty:
<br />
kotlin
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()
    }
}
   
<br />
用返回
Flow
的Repository替换旧版
LoaderManager.LoaderCallbacks<Cursor>
CursorObjectAdapter
,该Flow可观察数据库变化,并在数据为空时触发异步获取:
<br />
kotlin
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()
    }
}
   
<br />