mcpField
L
leanback-to-compose-tv-migration

leanback-to-compose-tv-migration

github.com/android開発10.0k844 回インストール2 日前に更新
リポジトリ

leanback-to-compose-tv-migration について

Android TV アプリを Leanback から Jetpack Compose に移行するための開発スキルです。テレビ向けレイアウト、D-pad のフォーカス制御、Media3 による再生画面の移行を扱います。

主な機能

  1. 0110フィート UI の設計指針
  2. 02TV Material 3 のコンポーネント選定
  3. 03D-pad のフォーカスとナビゲーション
  4. 04Lazy リストのフォーカス復元
  5. 05Media3 Compose 再生への移行

活用シーン

  1. 01Leanback 画面の Compose 移行
  2. 02テレビのカタログ画面での D-pad 設定
  3. 03旧式の動画再生画面の Media3 移行

インストール

コマンドをコピーしてターミナルで実行します。

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

インストール後、Claude Code で/leanback-to-compose-tv-migrationを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

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

詳細

10フィート UI

「10フィート UI」とは、約3メートル(10フィート)離れた場所からの視聴に合わせた、テレビ向けのデザインパラダイムです。この体験向けにデザインする際は、次の主な特性を考慮してください。

  • 視聴距離:視聴者は画面から離れた場所に座り、「くつろいだ姿勢」で視聴します。画面レイアウトはすっきりさせ、テキストや UI 要素は、密集したテキストの塊を避けながら、離れた場所からでも快適に読める大きさにします。
  • 色のコントラスト:コントラスト比の低いテレビ画面で色が薄く見えるのを防ぐため、コントラストの高い配色と明確な視覚的インジケーターを使い、さまざまなテレビパネルでもフォーカス状態が見えるようにします。
  • D-pad ナビゲーション:操作には、4方向(Up、Down、Left、Right)に限られた方向ボタン付きリモコンを使います。コンポーネントは、フォーカストラップのない明確な空間グリッドやカルーセルに整理します。

コアアーキテクチャとライブラリの選定

Android TV アプリケーションを Jetpack Compose に移行する際は、androidx.tv ライブラリを使用し、次の10フィート UI パターンに従う必要があります。

  • UI の最新化:従来の画面をそのまま置き換えるのではなく、独自の映画的なレイアウトを重視します。動的なグラデーションのヒーロー背景、カスタムフォーカスアニメーション、カスタムナビゲーションドロワー、カスタムレイアウトなど、TV 向け機能には Jetpack Compose を使用する必要があります。
  • 主要なデザインシステム:モバイル向けの androidx.compose.material3.* ではなく、常に androidx.tv.material3.*(androidx.tv:tv-material)を使用する必要があります。TV Material 3 には、D-Pad のフォーカス処理、フォーカス時のズーム拡大、TV 向けに最適化されたタイポグラフィとシェイプが組み込まれています。Compose for TV の依存関係を設定するには、Compose for TV setup に従ってください。
  • フォーカス時のズームアニメーション:インタラクティブなカードには、標準的な TV 向けフォーカスアニメーションを提供するため、scale = CardDefaults.scale(focusedScale = 1.1f) を指定して CompactCard、ClassicCard、または WideCardContainer を使用する必要があります。
  • Coil による画像読み込み:明示的な ImageLoader パラメーターを渡さずに宣言的な AsyncImage(model, contentDescription, ...) を使用するには、Gradle の依存関係に io.coil-kt:coil-compose を含める必要があります。
  • 明示的なインポート:ワイルドカードインポート(import androidx.tv.material3.*)ではなく、TV Material 3 のクラスを常に明示的にインポートする必要があります(例:import androidx.tv.material3.Surface、import androidx.tv.material3.ListItem)。
  • ファイル命名規則:Composable の画面ファイルには、画面名を付ける必要があります(例:BrowseScreen は BrowseScreen.kt、PlaybackScreen は PlaybackScreen.kt、AuthenticationScreen は AuthenticationScreen.kt とします)。Main のような汎用的な接頭辞は使用しないでください。
  • オーバースキャンとベゼル:画面の切り取りを防ぐため、ルートコンテナ、カルーセル、トップバーに水平パディング(例:horizontal = 48.dp または 32.dp、vertical = 24.dp)を適用する必要があります。
  • 読み取り幅の制限:長文テキストの列では、Modifier.widthIn(max = 600.dp) を使って読み取り幅を制限する必要があります。
  • Media3 Compose の依存関係:メディア再生画面を最新化する際は、app/build.gradle に androidx.media3:media3-ui-compose を含めてください。
  • 従来の AndroidView ラッパーの禁止:View ベースのコンポーネントを Jetpack Compose の画面に埋め込むために、従来の AndroidView ラッパーを使用しないでください。移行するすべての画面では、Compose コンポーネントまたは Media3 Compose サーフェス(PlayerSurface)を使用する必要があります。

D-Pad のフォーカス処理とナビゲーション

Jetpack Compose for TV(androidx.tv.material3)では、コンポーネントが自動的に初期フォーカスを受け取らず、ナビゲーションに2次元の空間座標を使うため、明示的なフォーカス管理が必要です。TV の D-Pad ナビゲーションを設定するには、TV Navigation guide の手順に従ってください。

初期フォーカス

画面に入ったときに、各画面の主要なインタラクティブ要素(最初のアクションボタン、カード、または ListItem など)に必ず初期フォーカスを割り当ててください。FocusRequester を使用します。val focusRequester = remember { FocusRequester() } を定義し、主要な要素に Modifier.focusRequester(focusRequester) を付けて、LaunchedEffect(Unit) { focusRequester.requestFocus() } 内でフォーカスをリクエストします。

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

LaunchedEffect(Unit) {
    focusRequester.requestFocus()
}

注:動的な状態やページャーを持つ画面(HorizontalPager を使用する OnboardingScreen など)では、ページが変わるたびにフォーカスが再適用されるよう、LaunchedEffect に状態キー(例:LaunchedEffect(pagerState.currentPage))を渡してください。

双方向のフォーカスルーティングとフォーカストラップの回避

インタラクティブな要素が画面の反対側に配置されている場合、標準の 2D 空間ナビゲーションでは対象を見つけられないことがあります。その結果、ユーザーが D-pad を使って領域から抜け出せないフォーカストラップが発生します。

フォーカストラップのない対称的な双方向 D-pad ナビゲーションを実現するには、可能な限り Compose の 2D 空間フォーカスエンジンを使用してください。スクロール可能なコンテナ(LazyColumn や LazyRow など)をまたいで隣接する UI 要素を接続する場合、遅延リスト内の個々の項目を対象とする方向指定のオーバーライド(up = ...、down = ...)は設定しないでください。垂直方向への移動中に項目が画面外へスクロールすると、その FocusRequester が初期化されていない状態になり、フォーカス検索中に IllegalStateException がスローされます。

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

行ごとのフォーカス再呼び出し(Modifier.focusRestorer)

水平カルーセル(LazyRow)間を垂直方向に移動する際、Compose のデフォルトの 2D 空間フォーカスエンジンは、フォーカス中の項目の X 座標に沿って検索します。ユーザーが Row 1 で右にスクロールし(たとえば X=800dp にある Item 4 まで移動し)、DOWN を押して Row 2 に移動すると、空間ルーティングによって Row 2 の X=800dp にある項目にフォーカスが移ります。

各行でカードのフォーカス位置を個別に記憶し、再訪時に以前表示していた項目へフォーカスを戻すには、引数なしの Modifier.focusRestorer を LazyRow に直接付けてください。遅延コンテナでは、カスタムのフォールバック FocusRequester ラムダを渡さないでください。D-pad で素早くスクロールしているときに、未アタッチまたは画面外の項目で requestFocus を呼び出すと、IllegalStateException がスローされます。

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

### テキスト入力、ハードウェアキーボードの Enter インターセプト、IME フォーカスチェーン

Leanback(SearchSupportFragment、GuidedStepSupportFragment)から検索バーやログインフォームを移行する際は、素の BasicTextField コンテナや空の Surface(onClick = {}) ラッパーを使用しないでください。これらは D-pad の CENTER による仮想キーボード(IME)の表示を妨げます。

  1. Back キーをインターセプトするクリック可能な TV サーフェスラッパー(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. Back キーのインターセプトが必須である理由 : Android TV でテキストフィールドを編集しているとき、D-pad の Back ボタンを押すと通常は戻る動作となり画面が終了します。onPreviewKeyEvent 内の KeyUp で Key.Back と Key.Escape をインターセプトして編集を停止(フォーカスをクリア)することで、ユーザーはテキストフィールドに閉じ込められたり誤って画面を終了したりすることなく、D-pad によるフォーム操作に戻ることができます。
  3. IME フォーカスチェーン : 複数フィールドのフォーム(認証画面のユーザー名とパスワードなど)では、上部のフィールドに KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }) と ImeAction.Next をアタッチしてフォーカスを次の入力ボックスに下へルーティングし、最下部のフィールドには ImeAction.Done を設定してフォーカスを送信ボタンへ直接ルーティングします:
Surface(
    onClick = { focusRequester.requestFocus() },
    border = ClickableSurfaceDefaults.border(focusedBorder = Border(border = BorderStroke(2.dp, Color.White))),
    modifier = Modifier
        .fillMaxWidth()
        .focusRequester(focusRequester)
) {
    OutlinedTextField(
        value = username,
        onValueChange = { username = it },
        label = { Text("Username") },
        singleLine = true,
        keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
        keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
        modifier = Modifier
            .fillMaxWidth()
            .onPreviewKeyEvent { event ->
                if (event.type == KeyEventType.KeyDown && (event.key == Key.Enter || event.key == Key.NumPadEnter)) {
                    focusManager.moveFocus(FocusDirection.Down)
                    true
                } else false
            }
    )
}

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

Lazy コンテナ

Jetpack Compose for TV でスクロール可能なリストやグリッドを実装するときは、androidx.compose.foundation.lazy および androidx.compose.foundation.lazy.grid の標準的な LazyColumn、LazyRow、LazyVerticalGrid を使用する必要があります。カタログ閲覧レイアウトについては、カタログブラウザガイドの手順に従ってください。

  1. BringIntoViewSpec によるピボットスクロール : BringIntoViewSpec と CompositionLocalProvider(LocalBringIntoViewSpec provides ...) を使ってカタログ行のカスタムピボットスクロールラインを定義するときは、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 以降でコンパイルされるようにする必要があります。Compose Foundation 1.7.0 以降がないと、import androidx.compose.foundation.gestures.LocalBringIntoViewSpec は Unresolved reference: LocalBringIntoViewSpec で失敗します。
  2. 行のフォーカス記憶(Modifier.focusRestorer) : composable に @OptIn(ExperimentalFocusRestorerApi::class, ExperimentalComposeUiApi::class) を付与し、各カテゴリの LazyRow に Modifier.focusRestorer をアタッチして、カタログ行を垂直方向に移動するときに最後にフォーカスされたカードを記憶して復元します。

フォーカス可能な TV Material 3 コンポーネント(CompactCard、ListItem、Button)を配置すると、標準の Compose Lazy コンテナが 2D D-Pad フォーカスルーティング、フォーカスメモリ、エッジスクロールを自動的に処理します:

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

## Media3 の動画再生とトランスポートコントロール

従来の Leanback 動画再生(VideoSupportFragment / PlaybackGlue)を移行する場合は、Compose Media3 の PlayerSurface (androidx.media3.ui.compose.PlayerSurface)と、半透明のトランスポートコントロールオーバーレイを組み合わせて使用します。

  1. 必須の Media3 トランスポートコントロールボタン(PlayPauseButton):PlayerSurface の上に、明示的な Media3 UI Compose ボタンを含む半透明の下部コントロールバー(Box(modifier = Modifier.align(Alignment.BottomCenter)))を重ねる必要があります。最低限、PlayPauseButton、SeekBackButton、SeekForwardButton を含めてください。トランスポートコントロールオーバーレイを空のままにしてはいけません。これらのコンポーザブルを使用するには、app/build.gradle の依存関係に androidx.media3:media3-ui-compose とともに implementation 'androidx.media3:media3-ui-compose-material3:1.6.0' を追加する必要があります。
  2. D-pad による方向指定シーク(onPreviewKeyEvent):リモコンの D-pad で前後にシークできるようにするには、コンテナーまたはコントロールオーバーレイに Modifier.onPreviewKeyEvent を付け、Compose の Key.DirectionLeft と Key.DirectionRight(例:keyEvent.key == Key.DirectionLeft)を捕捉して、10 秒前または後にシークします(exoPlayer.seekTo(exoPlayer.currentPosition - 10000))。従来の Android View キーコード(KeyEvent.KEYCODE_DPAD_LEFT や nativeKeyEvent.keyCode)は使用しないでください。
  3. 従来の AndroidView ラッパーの禁止:従来の PlayerView や StyledPlayerView を AndroidView { ... } でラップしないでください。TV 向け Compose で動画をレンダリングするには、ExoPlayer とともに PlayerSurface(androidx.media3.ui.compose.PlayerSurface)を使用する必要があります。

段階的な移行戦略

コンパイルを壊したり循環依存を導入したりせずにアプリを適切に移行するには、次の 5 つの段階に分けて実施します。

  • フェーズ 1:基盤とデザインシステム
  • フェーズ 2:リーフ画面とスタンドアロン画面
  • フェーズ 3:主要なブラウジング画面と探索画面
  • フェーズ 4:詳細画面とメディア再生
  • フェーズ 5:最終的な統合とクリーンアップ
フェーズ 1:基盤とデザインシステム
  • カスタムの ColorScheme、Typography、Shapes を使用する TvMaterialTheme をラップした TvTheme.kt を作成します。
  • MovieCard、SectionHeader、LoadingIndicator、ErrorState など、再利用可能なアトミックコンポーネントを作成します。
フェーズ 2:リーフ画面とスタンドアロン画面
  • エラーメッセージ、オンボーディング画面、設定画面など、画面外へのナビゲーションがない画面から先に移行し、ErrorDialog、OnboardingScreen、SettingsScreen に置き換えます。
  • BaseLeanbackPreferenceFragmentCompat、BaseLeanbackPreferenceFragment、または LeanbackSettingsFragment を使用しているものは、Compose を使用するように置き換えます。たとえば、ListItem と Switch を SharedPreferences に直接バインドし、findPreference の実装を置き換えます。
  • FocusRequester を使用して、画面の最初または主要なコンポーネント(たとえば、最初の ListItem)に画面の初期 D-pad フォーカスが当たるようにします。
  • 従来の Fragment クラスを、Compose のコンポーネントを宣言的に使用する ComponentActivity 型のアクティビティに置き換えます。
  • res/values/styles.xml と res/values/themes.xml にある従来のスタイルおよびテーマへの参照(@style/PreferenceThemeOverlay.v14.Leanback を参照する preferenceTheme の削除など)をクリーンアップします。これらは Leanback の依存関係を削除するとサポートされなくなります。
@Composable
fun TvSettingsScreen(
    modifier: Modifier = Modifier
) {
    var autoPlayNext by remember { mutableStateOf(true) }
    var highQualityAudio by remember { mutableStateOf(false) }

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

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

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

フェーズ 3:主要なブラウジング画面と探索画面
  • VerticalGridScreen(LazyVerticalGrid)、SearchScreen(入力に応じてリストを絞り込む BasicTextField)、BrowseScreen(LazyRow の LazyColumn)を移行します。
  • 従来の ArrayObjectAdapter、ListRowPresenter、CardPresenter、HeaderItem クラスをすべて廃止します。
フェーズ 4:詳細画面とメディア再生
  • VideoDetailsScreen と GuidedStepScreen を移行します。
  • Compose Media3 の PlayerSurface(androidx.media3.ui.compose.PlayerSurface)を使用して PlaybackScreen を移行します。半透明の下部オーバーレイに Media3 Compose の再生コントロール(PlayPauseButton、SeekBackButton、SeekForwardButton など)を配置します。
フェーズ 5:最終的な統一とクリーンアップ
  • 残っているレガシーな .java のアクティビティ、フラグメント、プレゼンター、XML レイアウトファイルをすべて削除します。
  • すべてのアクティビティが ComponentActivity または FragmentActivity を継承し、setContent { ... } を呼び出すようにします。
  • res/values/styles.xml と res/values/themes.xml からレガシーな Leanback テーマとスタイル宣言を完全に削除します(たとえば、Theme.Leanback を継承するスタイルや lb_ スタイルを参照するもの)。

コンポーネントとクラスの対応表

レガシー Leanback / View クラス最新の Jetpack Compose 相当機能
BrowseSupportFragment / MainFragmentBrowseScreen(カテゴリ別の LazyRow とヒーローバナーを含む LazyColumn)
DetailsSupportFragmentVideoDetailsScreen(ポスター画像、テキスト列、アクションボタン、関連項目の LazyRow)
VideoSupportFragment / PlaybackGluePlaybackScreen(Media3 ExoPlayer と Compose PlayerSurface)
GuidedStepSupportFragmentGuidedStepScreen(画面分割レイアウト:左側 40% のガイダンスペイン、右側 60% のアクションペイン)
SearchSupportFragmentSearchScreen(BasicTextField、リアルタイムのフィルタリング、LazyVerticalGrid)
VerticalGridSupportFragmentVerticalGridScreen(LazyVerticalGrid(columns = GridCells.Fixed(5)))
ArrayObjectAdapter / Presenter不変の State<List<T>> を監視する宣言的な @Composable 関数
CursorMapper / LoaderManager / CursorLoaderRepository オブジェクト内の Kotlin Coroutines / withContext(Dispatchers.IO)
OnboardingSupportFragmentOnboardingScreen(HorizontalPager と D-Pad ナビゲーションボタン)
LeanbackSettingsFragment / PreferenceFragmentSettingsScreen(最初の ListItem に FocusRequester を設定し、SharedPreferences にバインドした末尾の Switch)

参考実装と実運用で検証済みのパターン

最新の TV 没入型リストアーキテクチャ(BrowseScreen)

10 フィート TV 向けのブラウズ画面や没入型リストを構築する際は、スクロールリストの前や外にヒーローバナーを配置しないでください。また、プログラムから animateScrollToItem を呼び出して、Compose の自動 BringIntoView システムを妨げないでください。

代わりに、Compose Foundation の BringIntoViewSpec と LocalBringIntoViewSpec を使用して、TV の正確なピボットスクロールを定義します(たとえば、アクティブな行がディスプレイ上端から 35% の位置をピボットするように設定します)。これを形状変化する没入型の行と組み合わせます。下の行がフォーカスされている場合(focusedCategoryIndex > 0)は、ヒーローテキストを非表示にし、Row 0 に通常のセクション見出しを表示します。

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

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

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

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

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

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

Compose TV での Media3 再生(PlaybackScreen)

androidx.media3.ui.compose.PlayerSurface を動画レンダリング用キャンバスとして使用して、カスタムの再生画面を実装します。半透明の下部オーバーレイに、Material3 の再生コントロール(androidx.media3:media3-ui-compose-material3 の SeekBackButton、PlayPauseButton、SeekForwardButton)を重ねて配置し、自動非表示のタイムアウトとともに D-pad リモコンのキーイベントを処理します。

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

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

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

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

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

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

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

CursorLoader をリアクティブなコルーチンフローに置き換える

レガシーな LoaderManager.LoaderCallbacks<Cursor> と CursorObjectAdapter を、データベースの変更を監視し、データが空の場合に非同期フェッチを開始する Flow を返す Repository に置き換えます。

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()
    }
}

コメント