
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 trailers, see 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

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:
- 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
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.