leanback-to-compose-tv-migration について
Android TV アプリを Leanback から Jetpack Compose に移行するための開発スキルです。テレビ向けレイアウト、D-pad のフォーカス制御、Media3 による再生画面の移行を扱います。
主な機能
- 0110フィート UI の設計指針
- 02TV Material 3 のコンポーネント選定
- 03D-pad のフォーカスとナビゲーション
- 04Lazy リストのフォーカス復元
- 05Media3 Compose 再生への移行
活用シーン
- 01Leanback 画面の Compose 移行
- 02テレビのカタログ画面での D-pad 設定
- 03旧式の動画再生画面の Media3 移行
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ android/ skills --skill leanback-to-compose-tv-migrationインストール後、Claude Code で/leanback-to-compose-tv-migrationを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
references/android/training/tv/get-started/navigation.mdreferences/android/training/tv/playback/compose/browse.mdindex.md詳細
10フィート UI
「10フィート UI」とは、約3メートル(10フィート)離れた場所からの視聴に合わせた、テレビ向けのデザインパラダイムです。この体験向けにデザインする際は、次の主な特性を考慮してください。
- 視聴距離:視聴者は画面から離れた場所に座り、「くつろいだ姿勢」で視聴します。画面レイアウトはすっきりさせ、テキストや UI 要素は、密集したテキストの塊を避けながら、離れた場所からでも快適に読める大きさにします。
- 色のコントラスト:コントラスト比の低いテレビ画面で色が薄く見えるのを防ぐため、コントラストの高い配色と明確な視覚的インジケーターを使い、さまざまなテレビパネルでもフォーカス状態が見えるようにします。
- 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)の表示を妨げます。
- Back キーをインターセプトするクリック可能な TV サーフェスラッパー(
onPreviewKeyEvent) : 標準の M3TextFieldを、フォーカス可能な TVSurface(onClick = { focusRequester.requestFocus() }, scale = ClickableSurfaceDefaults.scale(focusedScale = 1.01f), border = ClickableSurfaceDefaults.border(focusedBorder = Border(BorderStroke(2.dp, Color.White))))でラップして、フォーカス時の枠線と D-pad フォーカススケーリングを提供します。Modifier.onPreviewKeyEventをテキストフィールドまたはラッパーにアタッチしてKey.BackとKey.Escapeをインターセプトし、ユーザーが画面を終了せずに入力フィールドからフォーカスを外せるようにする必要があります。 - Back キーのインターセプトが必須である理由 : Android TV でテキストフィールドを編集しているとき、D-pad の Back ボタンを押すと通常は戻る動作となり画面が終了します。
onPreviewKeyEvent内のKeyUpでKey.BackとKey.Escapeをインターセプトして編集を停止(フォーカスをクリア)することで、ユーザーはテキストフィールドに閉じ込められたり誤って画面を終了したりすることなく、D-pad によるフォーム操作に戻ることができます。 - 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 を使用する必要があります。カタログ閲覧レイアウトについては、カタログブラウザガイドの手順に従ってください。
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で失敗します。- 行のフォーカス記憶(
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)と、半透明のトランスポートコントロールオーバーレイを組み合わせて使用します。
- 必須の 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'を追加する必要があります。 - D-pad による方向指定シーク(
onPreviewKeyEvent):リモコンの D-pad で前後にシークできるようにするには、コンテナーまたはコントロールオーバーレイにModifier.onPreviewKeyEventを付け、Compose のKey.DirectionLeftとKey.DirectionRight(例:keyEvent.key == Key.DirectionLeft)を捕捉して、10 秒前または後にシークします(exoPlayer.seekTo(exoPlayer.currentPosition - 10000))。従来の Android View キーコード(KeyEvent.KEYCODE_DPAD_LEFTやnativeKeyEvent.keyCode)は使用しないでください。 - 従来の AndroidView ラッパーの禁止:従来の
PlayerViewやStyledPlayerViewをAndroidView { ... }でラップしないでください。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 / 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 Coroutines / withContext(Dispatchers.IO) |
OnboardingSupportFragment | OnboardingScreen(HorizontalPager と D-Pad ナビゲーションボタン) |
LeanbackSettingsFragment / PreferenceFragment | SettingsScreen(最初の 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()
}
}