Skip to content

Migrate Leanback sample app to Jetpack Compose#226

Open
pflammertsma wants to merge 15 commits into
android:mainfrom
pflammertsma:leanback-to-compose-migration
Open

Migrate Leanback sample app to Jetpack Compose#226
pflammertsma wants to merge 15 commits into
android:mainfrom
pflammertsma:leanback-to-compose-migration

Conversation

@pflammertsma

Copy link
Copy Markdown
Member

This PR introduces a complete reference implementation showing how to migrate the legacy Leanback TV application to Jetpack Compose for TV (androidx.tv:tv-material).

It provides a side-by-side comparison (Before vs. After) of common TV UI patterns and resolves key TV-specific interaction challenges.

Key Changes

  • Project Rename: Renamed the migrated project directory to LeanbackToCompose.
  • Compose for TV Implementation: Replaced the legacy Leanback UI (Java/XML) with a modern declarative Compose UI (Kotlin) covering:
    • Onboarding Sequence
    • Catalog Browsing (Horizontal List Rows)
    • Immersive Details View & Video Player (Media3)
    • Vertical Grid Layout
    • In-app Search
    • Guided Step Wizard
    • Preference Settings
  • Interaction & Focus Fixes:
    • Focus Traps: Introduced a custom TvTextField that intercepts D-pad ENTER keys and chains focus properly, resolving focus traps on the Authentication and Search screens.
    • Initial Focus: Fixed startup focus in the VerticalGrid and Error screens to ensure a composable element gains focus immediately on launch, preventing the GTV launcher from taking over navigation.
  • Documentation & Showcase: Updated the project README.md with a detailed migration guide and a side-by-side showcase using updated 1080p screenshots comparing the legacy Leanback UI and the new TV Compose UI.

Paul Lammertsma added 13 commits July 7, 2026 11:55
- Migrate VideoPlayerGlue and PlaybackFragment from deprecated ExoPlayer 2 to AndroidX Media3.

- Add android:exported="true" to launcher activities in AndroidManifest.xml for Android 12+ compatibility.

- Add PendingIntent.FLAG_IMMUTABLE in RecommendationReceiver for Android 12+ alarm mutability compliance.

- Resolve static analysis and lint warnings (add hashCode() to Video, specify Locale.ROOT in VideoProvider, pass parent ViewGroup to inflate in IconHeaderItemPresenter, fix array declaration syntax).

TAG=agy

CONV=c90143f0-3646-4a01-89f5-44a5938a65f2
…ng crashes

- Upgrade to stable androidx.tv:tv-material:1.0.0 and compose-bom:2024.09.02 (compatible with SDK 34), removing deprecated tv-foundation.
- Implement bringIntoViewIfChildrenAreFocused using BringIntoViewResponder to prevent LazyColumn from scrolling hero metadata off screen during horizontal carousel navigation.
- Apply no-arg Modifier.focusRestorer() across carousels to eliminate FocusRequester initialization crashes during lazy item recycling.
- Remove directional focusProperties overrides pointing across dynamic LazyColumn items.
- Add density-independent top-lock (offset <= containerSize * 0.45f) in BringIntoViewSpec to ensure zero unwanted vertical scrolling when browsing top rows across all TV resolutions.
…ideoDetails screen

- Create reusable StudioBadge component and integrate across Browse and VideoDetails screens
- Add Modifier.focusRestorer() to carousels to preserve item focus when navigating vertically
- Migrate PlaybackScreen from legacy PlayerView bridge to native Compose Media3 PlayerSurface
- Fix OnboardingScreen indicator alignment and unify action buttons to retain focus on final page
- Modernize VideoDetailsScreen with top-level widescreen overlay and bottom fade-out gradient
- Implement sub-pixel zero-jump Related Videos placeholder with MovieCardPlaceholder skeleton cards
- Anchor Details action buttons to bottom of column to prevent layout shifts when READ MORE appears
- Update JVM heap and Android target SDK configuration
…, D-pad focus memory, and white card outlines
- Add reusable TvTextField component with physical keyboard Enter interception and IME focus chaining

- Refactor AuthenticationScreen and SearchScreen to use TvTextField, eliminating focus traps

- Layer Media3 1.9.0 modular Material3 transport buttons (SeekBack, PlayPause, SeekForward) over PlayerSurface in PlaybackScreen with D-pad 10s seeking and auto-hide timeout

- Update SettingsScreen with ListItem trailing switches

- Add Baseline Profiles (profileinstaller) for startup performance optimization
@pflammertsma
pflammertsma requested a review from chikoski as a code owner July 13, 2026 13:55

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request migrates the Android TV application from the legacy Leanback UI Toolkit to Jetpack Compose for TV, replacing Java/XML files with Kotlin Compose screens and components. The review feedback highlights critical Android TV and Compose-specific improvements: offloading database queries from the main thread in VideoRepository, supporting physical remotes by handling Key.DirectionCenter in TvTextField, optimizing D-pad scroll performance in VideoDetailsScreen using dispatchRawDelta, managing player listener lifecycles in PlaybackScreen to prevent leaks, avoiding focus requests in onGloballyPositioned in BrowseScreen, and ensuring immediate focus is requested on key input fields and settings items upon screen launch to deliver a seamless D-pad navigation experience.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

.onPreviewKeyEvent { event ->
Log.i("TvTextField", "onPreviewKeyEvent: key=${event.key}, type=${event.type}")
when (event.key) {
Key.Enter, Key.NumPadEnter -> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

On Android TV, the D-pad Center button on physical remotes typically sends KeyEvent.KEYCODE_DPAD_CENTER (which maps to Key.DirectionCenter in Compose), whereas Key.Enter is usually sent by emulator keyboards or external keyboards.\n\nTo ensure that pressing the D-pad Center button on a physical remote triggers the IME action or focus change, you should include Key.DirectionCenter in the key interception logic, just as you did in PlaybackScreen.kt.

Suggested change
Key.Enter, Key.NumPadEnter -> {
Key.Enter, Key.NumPadEnter, Key.DirectionCenter -> {

Comment on lines +323 to +334
Key.DirectionDown -> {
coroutineScope.launch {
scrollState.animateScrollTo(scrollState.value + 250)
}
true
}
Key.DirectionUp -> {
coroutineScope.launch {
scrollState.animateScrollTo((scrollState.value - 250).coerceAtLeast(0))
}
true
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When a user holds down the D-pad Up or Down button, the system generates repeated KeyDown events at a high frequency (typically every 50ms).\n\nIn your current implementation, each key event launches a new coroutine and starts a smooth animateScrollTo animation. Because the animation takes longer than the key repeat interval, the previous animation is constantly cancelled and restarted. This leads to extremely janky, stuttering, or unresponsive scrolling.\n\nTo resolve this and achieve smooth, highly responsive scrolling under key repeat, you should use the non-suspending dispatchRawDelta API. This applies the scroll delta instantly without launching or cancelling coroutines.

                                Key.DirectionDown -> {
                                    scrollState.dispatchRawDelta(250f)
                                    true
                                }
                                Key.DirectionUp -> {
                                    scrollState.dispatchRawDelta(-250f)
                                    true
                                }

Comment on lines +68 to +87
LaunchedEffect(video) {
focusRequester.requestFocus()
scheduleAutoHide()

val listener = object : Media3Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Media3Player.STATE_ENDED) {
onFinish()
}
}
}
exoPlayer.addListener(listener)

video.videoUrl?.let { url ->
val mediaItem = MediaItem.fromUri(url)
exoPlayer.setMediaItem(mediaItem)
exoPlayer.prepare()
exoPlayer.playWhenReady = true
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Every time the video parameter changes, LaunchedEffect(video) re-runs and adds a new Media3Player.Listener to the exoPlayer instance without removing the previous one. This causes listeners to accumulate over time if the video changes, which can lead to unexpected behavior or memory leaks.\n\nInstead, you should manage the listener's lifecycle using a DisposableEffect keyed on exoPlayer to ensure only one listener is active and it is cleaned up when disposed.

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

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

        video.videoUrl?.let { url ->
            val mediaItem = MediaItem.fromUri(url)
            exoPlayer.setMediaItem(mediaItem)
            exoPlayer.prepare()
            exoPlayer.playWhenReady = true
        }
    }

Comment on lines +243 to +252
if (catIndex == 0 && vidIndex == 0) {
Modifier
.focusRequester(focusRequester)
.onGloballyPositioned {
if (!initialFocusRequested) {
initialFocusRequested = true
focusRequester.requestFocus()
}
}
} else {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Requesting focus inside onGloballyPositioned is discouraged in Jetpack Compose because it can trigger focus changes during the layout pass, potentially leading to infinite layout loops or crashes. Additionally, the call to focusRequester.requestFocus() is duplicated on lines 249 and 250.\n\nA safer and more idiomatic approach is to request focus inside a LaunchedEffect once the data is loaded, similar to how it is done in VerticalGridScreen.kt.\n\nYou can add this LaunchedEffect at the top of BrowseScreen:\nkotlin\nLaunchedEffect(allVideos) {\n if (!allVideos.isNullOrEmpty()) {\n try {\n focusRequester.requestFocus()\n } catch (e: Exception) {\n // Ignore focus request failures before layout settles\n }\n }\n}\n

                                                    if (catIndex == 0 && vidIndex == 0) {
                                                        Modifier.focusRequester(focusRequester)
                                                    } else {
                                                        Modifier
                                                    }

Comment on lines +58 to +60
LaunchedEffect(Unit) {
allVideos = VideoRepository.getVideos(context)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When the Search screen is opened, no element gains focus initially. On Android TV, it is a best practice to immediately focus the search input field so the user can start typing or trigger voice search right away.\n\nYou should add a LaunchedEffect to request focus on the search text field when the screen is launched.

    LaunchedEffect(Unit) {
        allVideos = VideoRepository.getVideos(context)
        try {
            textFieldFocusRequester.requestFocus()
        } catch (e: Exception) {
            // Ignore focus request failures before layout settles
        }
    }

Comment on lines +57 to +58

TvLeanbackTheme {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When the Authentication screen is opened, no element gains focus initially. On Android TV, it is a best practice to immediately focus the first input field (the username field) so the user can start typing right away.\n\nYou should add a LaunchedEffect to request focus on the username text field when the screen is launched. Note that you will need to import androidx.compose.runtime.LaunchedEffect in this file.

    LaunchedEffect(Unit) {
        try {
            usernameFocusRequester.requestFocus()
        } catch (e: Exception) {
            // Ignore focus request failures before layout settles
        }
    }

    TvLeanbackTheme {

Comment on lines +43 to +45
var recommendationsEnabled by remember {
mutableStateOf(prefs.getBoolean(recKey, true))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To ensure a smooth user experience on Android TV, the first setting item should gain focus immediately when the Settings screen is opened. You can define a FocusRequester and request focus on it using a LaunchedEffect. Note that you will need to import androidx.compose.runtime.LaunchedEffect and androidx.compose.ui.focus.FocusRequester in this file.

    var recommendationsEnabled by remember {
        mutableStateOf(prefs.getBoolean(recKey, true))
    }
    val firstItemFocusRequester = remember { FocusRequester() }

    LaunchedEffect(Unit) {
        try {
            firstItemFocusRequester.requestFocus()
        } catch (e: Exception) {
            // Ignore focus request failures before layout settles
        }
    }

containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant
),
modifier = Modifier.fillMaxWidth()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Attach the firstItemFocusRequester to the first ListItem to ensure it receives initial focus. Note that you will need to import androidx.compose.ui.focus.focusRequester in this file.

                    modifier = Modifier
                        .fillMaxWidth()
                        .focusRequester(firstItemFocusRequester)

@pflammertsma
pflammertsma force-pushed the leanback-to-compose-migration branch 3 times, most recently from 02d5906 to 3a37094 Compare July 15, 2026 09:34
- Rename and modernize the main sample app to 'Leanback-Compose'.
- Upgrade the build system to Gradle 8.7, AGP 8.6.0, and JDK 21, targeting SDK 35.
- Fix UI issues including focus traps in Authentication/Search and initial focus in Vertical Grid/Error screens in the Compose version.
- Modernize the legacy 'Leanback' app (migrated to Media3, targeting SDK 35) to keep it compile-able and runnable on Android 14+.
- Disable background recommendation updates on launch in the legacy app to prevent startup crashes on modern SDKs.
- Update the README.md with a comprehensive migration guide and new 1080p side-by-side comparison screenshots.
@pflammertsma
pflammertsma force-pushed the leanback-to-compose-migration branch from 3a37094 to 9747577 Compare July 15, 2026 09:41
- Add Autoplay Video Previews toggle in SettingsScreen.kt backed by SharedPreferences (pref_autoplay_previews).
- Support live, muted video previews inside MovieCard.kt when focused.
- Add a 500ms debounce delay to avoid unnecessary video loading during fast D-pad navigation.
- Smoothly cross-fade between static thumbnail and video preview using Compose Crossfade.
- Automatically release player after 6 seconds of preview or when focus leaves the card.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant