Blog Infos
Author
Published
Topics
, , , ,
Published

 

When I first launched MovieLand, it didn’t have any video playback – just posters, ratings, and descriptions. But pretty quickly, user feedback made it clear: they wanted more than static content. They wanted to watch trailerssee previews, and get a real feel for the movies and shows directly in the app.

Since my app had fully migrated from XML to Jetpack Compose, the challenge was clear: how can I embed YouTube videos natively inside a Compose-based UI, without redirecting to the YouTube app or breaking the user experience?

I needed to support:

  • Seamless video playback within the app
  • Smooth orientation changes (portrait/landscape)
  • Resuming playback from the same position on rotation

At the time, there was no official Compose-friendly YouTube API, and no clean solution that worked out of the box. So I rolled up my sleeves and built one using AndroidView and the YouTube Player library.

In this article, I’ll show exactly how I did it — with real production code, UI previews, and practical takeaways you can apply in your own Compose app.

Step 1: Showing YouTube Thumbnails in Jetpack Compose

 

Before diving into full video playback, we first need a way to visually represent videos in the UI — cleanly and performantly.

YouTube makes this easy: you can fetch a thumbnail for any video using its video ID and a static URL.

val thumbnailUrl = "https://img.youtube.com/vi/$videoId/hqdefault.jpg"

This gives us a high-quality preview image for the video — no need for any additional APIs.

Building the YouTubeThumbnail Composable

Here’s the YouTubeThumbnail I use in my app. It’s clean, lightweight, and works perfectly in LazyRow, grids, or detail screens:

@Composable
fun YouTubeThumbnail(
    videoId: String,
    videoName: String,
    onVideoClick: (String) -> Unit
) {
    val thumbnailUrl = "https://img.youtube.com/vi/$videoId/hqdefault.jpg"

    Box(
        modifier = Modifier
            .width(200.dp)
            .aspectRatio(16f / 9f)
            .clip(RoundedCornerShape(AppTheme.dimens.radiusM))
            .background(Color.Black)
            .clickable { onVideoClick(videoId) }
    ) {
        AsyncImage(
            model = thumbnailUrl,
            contentDescription = "YouTube Thumbnail for $videoName",
            contentScale = ContentScale.Crop,
            modifier = Modifier.fillMaxSize()
        )

        Icon(
            imageVector = Icons.Default.PlayArrow,
            contentDescription = "Play $videoName",
            tint = Color.White,
            modifier = Modifier
                .align(Alignment.Center)
                .size(48.dp)
                .background(Color.Black.copy(alpha = 0.6f), CircleShape)
                .padding(8.dp)
        )
    }
}
What’s Good About This
  • Uses AsyncImage from Coil for efficient image loading
  • Fully composable — no need for Views
  • Responsive sizing using aspectRatio
  • Includes a play icon overlay for clarity
  • Clickable — triggers video playback when tapped

You can use this composable in a LazyRow like this:

LazyRow(
    horizontalArrangement = Arrangement.spacedBy(8.dp),
    contentPadding = PaddingValues(horizontal = AppTheme.dimens.spaceM)
) {
    items(videos) { video ->
        YouTubeThumbnail(
            videoId = video.id,
            videoName = video.name,
            onVideoClick = { selectedId ->
                onAction(MovieDetailsAction.OpenVideo(selectedId))
            }
        )
    }
}

This gives users a fast, elegant way to browse and tap into trailers.

Where to Get the YouTube Video ID?

In my app, I use The Movie Database (TMDB) API to fetch video data for each movie or show. TMDB provides direct access to YouTube video IDs – so I can easily pass them into my player and thumbnail UI.

But if you’re building something simpler or experimenting locally, you can get the video ID directly from a YouTube URL. Just grab the part after v=.

For example:

https://www.youtube.com/watch?v=5PSNL1qE6VY
                                 ^^^^^^^^^^^
                             This is the video ID

In this case, the ID is:

val videoId = "5PSNL1qE6VY"

You can then create a thumbnail using the static YouTube image URL:

val thumbnailUrl = "https://img.youtube.com/vi/$videoId/hqdefault.jpg"

No need for an extra API — YouTube serves thumbnail images directly.

Step 2: Embedding YouTube Player in a Compose Screen

 

Once a user taps on a thumbnail, we need to open the full video — ideally in a fullscreen, immersive player, without redirecting them to the YouTube app.
Jetpack Compose doesn’t yet offer native support for web content or complex video players like YouTube, so we use the AndroidView Composable to bridge the gap between Compose and the View-based YouTubePlayerView.

To make this work, I used a View-based library: android-youtube-player

This library provides a YouTubePlayerView with lifecycle support and built-in integration for the web-based iframe player. It’s stable, customizable, and works well inside a Compose screen via interop.

Add the Library to Your Project

First, include the dependency in your build.gradle:

dependencies {
    implementation "com.pierfrancescosoffritti.androidyoutubeplayer:core:12.1.0"
}
Embedding the Player with AndroidView (Step by Step)

Here’s the full implementation of my YouTubePlayerScreen. I’ll explain each section below to show how it solves common challenges like orientation changes, playback resuming, and immersive video.

@Composable
fun YouTubePlayerScreen(videoId: String, onBack: () -> Unit) {
    val lifecycleOwner = LocalLifecycleOwner.current
    var playbackPosition by rememberSaveable { mutableFloatStateOf(0f) }
    val context = LocalContext.current
    val orientation = remember { mutableIntStateOf(context.resources.configuration.orientation) }

    val configuration = LocalConfiguration.current
    LaunchedEffect(configuration) {
        orientation.intValue = configuration.orientation
    }

    val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE

    val window = (LocalView.current.context as ComponentActivity).window
    SideEffect {
        WindowCompat.getInsetsController(window, window.decorView).apply {
            hide(WindowInsetsCompat.Type.statusBars())
            hide(WindowInsetsCompat.Type.navigationBars())
        }
    }

    DisposableEffect(Unit) {
        onDispose {
            WindowCompat.getInsetsController(window, window.decorView).apply {
                show(WindowInsetsCompat.Type.statusBars())
                show(WindowInsetsCompat.Type.navigationBars())
            }
        }
    }

    val isWifiConnected = remember { mutableStateOf(checkIfWifiConnected(context)) }
    val coroutineScope = rememberCoroutineScope()

    Box(
        contentAlignment = Alignment.Center,
        modifier = Modifier
            .fillMaxSize()
            .background(Color.Black)
    ) {
        AndroidView(
            modifier = Modifier
                .then(if (isLandscape) Modifier.fillMaxHeight() else Modifier.fillMaxWidth())
                .aspectRatio(16f / 9f)
                .align(Alignment.Center),
            factory = { context ->
                YouTubePlayerView(context).apply {
                    lifecycleOwner.lifecycle.addObserver(this)

                    addYouTubePlayerListener(object : AbstractYouTubePlayerListener() {
                        override fun onReady(youTubePlayer: YouTubePlayer) {
                            youTubePlayer.loadVideo(videoId, playbackPosition)

                            if (isWifiConnected.value) {
                                coroutineScope.launch {
                                    delay(500)
                                    youTubePlayer.loadVideo(videoId, playbackPosition)
                                }
                            }
                        }

                        override fun onCurrentSecond(youTubePlayer: YouTubePlayer, second: Float) {
                            playbackPosition = second
                        }
                    })
                }
            }
        )

        Box(
            modifier = Modifier
                .align(Alignment.TopEnd)
                .padding(top = 16.dp, end = 16.dp)
                .background(Color.White.copy(alpha = 0.1f), shape = CircleShape)
                .clickable { onBack() }
                .size(40.dp),
            contentAlignment = Alignment.Center
        ) {
            Icon(
                imageVector = Icons.Filled.Close,
                contentDescription = null,
                tint = Color.White,
                modifier = Modifier.size(24.dp)
            )
        }
    }
}
Let’s Break It Down

rememberSaveable + playbackPosition

var playbackPosition by rememberSaveable { mutableFloatStateOf(0f) }

This stores the current playback position and preserves it across recompositions and configuration changes like screen rotation. So when the device rotates, the video will resume from the same second.

Orientation Detection

val configuration = LocalConfiguration.current
val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE

We track current orientation to control layout and fullscreen behavior. When the phone is in landscape, we want to make the video truly fullscreen and immersive.

Hiding System UI for Immersive Mode

val window = (LocalView.current.context as ComponentActivity).window
SideEffect {
    WindowCompat.getInsetsController(window, window.decorView).apply {
        hide(WindowInsetsCompat.Type.statusBars())
        hide(WindowInsetsCompat.Type.navigationBars())
    }
}

When the video screen is shown, we hide both the status bar and the navigation bar — giving the user a true fullscreen playback experience.

And we restore the UI when the Composable leaves:

DisposableEffect(Unit) {
    onDispose {
        WindowCompat.getInsetsController(window, window.decorView).apply {
            show(WindowInsetsCompat.Type.statusBars())
            show(WindowInsetsCompat.Type.navigationBars())
        }
    }
}

WiFi Check for Better Quality Playback

val isWifiConnected = remember { mutableStateOf(checkIfWifiConnected(context)) }

If the user is on WiFi, we slightly delay and reload the video again after 500ms:

if (isWifiConnected.value) {
    coroutineScope.launch {
        delay(500)
        youTubePlayer.loadVideo(videoId, playbackPosition)
    }
}

This is a little hack I discovered in practice: it lets YouTube buffer a higher resolution stream, especially if the first load used a low-bitrate mobile fallback.

Lifecycle Management

lifecycleOwner.lifecycle.addObserver(this)

This is essential. It ensures the YouTube player is aware of the host activity’s lifecycle and can pause/resume/clean upcorrectly.

Close Button UI

Box(...) {
    Icon(...)
}

We add a simple close button in the top-right corner so the user can exit the video easily. This triggers the onBack() lambda passed from navigation.

Helper Function: Check for WiFi

fun checkIfWifiConnected(context: Context): Boolean {
    ...
}

This uses ConnectivityManager to check if the current connection is WiFi-based. It’s used only to optimize playback quality — not required, but a nice touch in production.

Step 3: Navigating to the YouTube Player Screen

Once the thumbnail is clicked, we want to open the YouTubePlayerScreen with the correct video ID. In a Compose app, this is typically done using the Navigation component from Jetpack Compose.

In my app, I pass the video ID as a navigation argument and trigger the screen transition using a sealed Action.

Triggering Navigation from the UI

Here’s how I trigger the video screen from a thumbnail tap inside a LazyRow:

LazyRow(...) {
    items(videos) { video ->
        YouTubeThumbnail(
            videoId = video.key,
            videoName = video.name,
            onVideoClick = { selectedId ->
                onAction(MovieDetailsAction.OpenVideo(selectedId))
            }
        )
    }
}

In this example, MovieDetailsAction is a sealed class that handles screen events:

sealed class MovieDetailsAction {
    data class OpenVideo(val videoId: String) : MovieDetailsAction()
}

The onAction() handler will map this to navigation.

Define the Route in NavHost

Next, we define the route in your Compose NavHost:

composable(
    route = AppNavRoutes.YouTubePlayer.route,
    arguments = listOf(navArgument("videoId") { type = NavType.StringType })
) { backStackEntry ->
    val videoId = backStackEntry.arguments?.getString("videoId")
    videoId?.let {
        YouTubePlayerScreen(videoId = videoId) {
            navController.popBackStack()
        }
    }
}

The route itself might look like this:

object AppNavRoutes {
    val YouTubePlayer = object {
        const val route = "youtube_player/{videoId}"
        fun buildRoute(videoId: String) = "youtube_player/$videoId"
    }
}
Putting It All Together

When a thumbnail is tapped:

  1. You dispatch OpenVideo(videoId)

2. Your ViewModel or UI handler calls:

navController.navigate(AppNavRoutes.YouTubePlayer.buildRoute(videoId))

3. The video ID is passed to YouTubePlayerScreen, which plays the correct trailer

Lessons Learned from Production

Integrating YouTube playback in a Compose app sounds straightforward — but when you ship it to real users on real devices, you start to notice the details that matter.

Here are some lessons I learned while running this implementation in production:

Orientation Handling Matters More Than You Think

If your player resets every time the device rotates, it’s a huge UX issue — especially with trailers or short-form content. Using rememberSaveable for playbackPosition was essential for keeping the experience smooth.

Also, relying on LocalConfiguration lets you easily adapt the layout (and system UI) based on current orientation.

WiFi-Based Reloading Actually Improves Quality

The YouTube iframe player often defaults to low quality on mobile networks. By detecting when the device is on WiFi, I added a small delay(500) and reloaded the video. In testing, this consistently resulted in better video quality — even on good connections.

if (isWifiConnected.value) {
    coroutineScope.launch {
        delay(500)
        youTubePlayer.loadVideo(videoId, playbackPosition)
    }
}

Small change, big result.

System UI Control Makes the Experience Feel Native

Hiding the system bars when in landscape dramatically improves immersion. Especially on notch phones or gesture nav, it helps the video feel like it truly belongs in the app — not like an embedded web widget.

Just don’t forget to restore them when leaving the screen:

DisposableEffect(Unit) {
    onDispose {
        WindowCompat.getInsetsController(window, window.decorView).apply {
            show(WindowInsetsCompat.Type.statusBars())
            show(WindowInsetsCompat.Type.navigationBars())
        }
    }
}

 

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Kobweb:Creating websites in Kotlin leveraging Compose HTML

Kobweb is a Kotlin web framework that aims to make web development enjoyable by building on top of Compose HTML and drawing inspiration from Jetpack Compose.
Watch Video

Kobweb:Creating websites in Kotlin leveraging Compose HTML

David Herman
Ex-Googler, author of Kobweb

Kobweb:Creating websites in Kotlin leveraging Compose HTML

David Herman
Ex-Googler, author o ...

Kobweb:Creating websites in Kotlin leveraging Compose HTML

David Herman
Ex-Googler, author of Kob ...

Jobs

Try It Yourself

Want to see how this works in a real app? You can try it right now.

MovieLand is available on Google Play — just search for a movie or TV show, scroll down to the trailers section, and tap a video thumbnail.

👉 Install MovieLand on Google Play

It’s a Compose-first app with a clean UI and fully integrated video playback — no redirection to the YouTube app, just smooth, fullscreen playback right inside the experience.

Conclusion

Jetpack Compose is powerful — but not everything is Composable (yet). When working with real-world features like YouTube playback, using AndroidView to integrate View-based components can be the most effective and pragmatic choice.

By combining:

  • Compose-native UI,
  • A YouTube player wrapped with lifecycle awareness,
  • Orientation handling and immersive fullscreen support, and
  • A production-ready approach to media UX,

…I was able to build a seamless, engaging video experience inside my Compose-based app — and ship it to real users.

If you’re building something similar, I hope this article helps you avoid a few of the pitfalls and gives you a solid base to start from.

Found this useful?

If this article helped you embed YouTube in your Compose app — or saved you from hours of trial and error — feel free to drop a few claps 👏 to help more Android devs discover it too!

Have your own approach to video playback in Compose? Or ran into an edge case I didn’t cover?

👉 Leave a comment below — I’d love to hear how you’re tackling it.

And if you’re into Android development, Jetpack Compose, or just building better mobile UX — feel free to connect on LinkedIn. Always happy to share, learn, and chat with fellow devs.

This article was previously published on proandroiddev.com.

Menu