Modern Android development moves fast, especially around media playback. Libraries evolve, APIs shift, and examples that worked a year ago can already feel dated. I ran into this exact problem recently while working on a Jetpack Compose project that needed to play videos inside a card based layout.
Most tutorials and snippets I found online still relied on older patterns. Even the official Android documentation often demonstrates video playback using PlayerView, which is fundamentally a View based approach. For example, both the playback app guide and the Media3 ExoPlayer hello world sample showcase PlayerView rather than Compose first APIs.
These examples are still valid, but when you are building a UI that is entirely Compose driven, falling back to PlayerView or AndroidView interop feels like a step backwards. It works, but it does not reflect the direction Compose and Media3 are moving toward.
So I took some time to dig into the newer Media3 APIs and, more importantly, their Compose specific UI integrations. The result is a clean, modern way to play videos directly in Compose using PlayerSurface, proper lifecycle handling, and without relying on outdated or mixed UI approaches.
In this article, I will walk through how I implemented video playback inside a composable card, explain the key pieces involved, and show how this approach aligns with the current Media3 and Jetpack Compose ecosystem.
Defining the Goal — A Composable, Self-Contained Video Card
Before diving into Media3 or ExoPlayer details, it helps to clearly define what we are trying to build.
The goal here is not just to play a video. It is to create a self contained composable that fits naturally into a Compose UI. In this case, that composable is a card that:
- Plays a video automatically
- Crops and scales the video correctly
- Handles its own lifecycle
- Does not rely on
PlayerVieworAndroidView - Works cleanly with Material 3 theming
This is important because video playback is often embedded inside scrolling lists, dashboards, or feature cards. Treating it as a reusable composable makes it easier to reason about, test, and reuse across the app.
At a high level, the MediaCard composable is responsible for three things:
- Layout and stylingThe card shape, elevation, size, and clipping behavior are handled entirely by Compose and Material 3 components.
- Player creation and lifecycleExoPlayer is created inside the composable, remembered across recompositions, and released when the composable leaves the composition.
- Video rendering in ComposeThe video itself is rendered using
PlayerSurface, which is part of Media3’s Compose UI module and designed specifically for Compose based apps.
By keeping these responsibilities inside a single composable, the implementation stays focused and avoids leaking player or lifecycle logic into higher levels of the UI.
In the next section, we will start breaking down the code itself, beginning with the MediaCard composable and its Material 3 card setup.
Building the Card Layout with Material 3
The foundation of this implementation is a Material 3 Card. This might seem straightforward, but it plays an important role in how the video is presented and clipped within the UI.
Here is the composable definition and card setup as used in the project:
Why a Card Works Well for Video
Using a Card here is not just a visual choice. It gives us a few practical benefits:
- Clipping and shape controlThe rounded corners define a clear visual boundary for the video content.
- Elevation and layeringSubtle elevation helps the video stand out when placed inside lists or stacked layouts.
- Material themingBy using
MaterialTheme.colorScheme.surface, the card automatically adapts to light and dark themes without extra work.
The cardHeight parameter is also intentional. Video content often needs a predictable height to avoid layout jumps, especially when used in lazy lists. Making this configurable keeps the composable flexible without complicating its usage.
At this stage, the card is purely structural. It does not know anything about video playback yet. That separation is useful, because it allows us to reason about layout and styling independently from media concerns.
In the next section, we will introduce ExoPlayer and look at how the player is created and remembered inside a composable without breaking Compose’s lifecycle rules.
Creating and Managing ExoPlayer in Compose
This is the part where a lot of older examples start to drift into View based territory. If you follow many tutorials, you will often see a PlayerView created and then hosted inside Compose via AndroidView. That works, but it puts you back into the world of View lifecycles inside a Compose screen.
Instead, this approach keeps the player creation and cleanup fully inside the composable, using remember to keep it stable across recompositions and DisposableEffect to release it at the right time.
Here is the exact player setup from the implementation:
Why remember Matters Here
remember
Compose will re-run composable functions frequently, sometimes for reasons that have nothing to do with your player. Without remember, you would risk rebuilding the player during recomposition, which is expensive and can cause playback glitches.
Using:
remember(media.assetPath)ensures the player is kept as long as the asset path stays the same- and recreated only when that input changes (for example, the card is reused for a different video)
Why LocalInspectionMode Is Included
LocalInspectionMode
A small but useful detail is guarding player creation when running in preview/inspection mode:
- Compose previews do not have a full Android runtime environment
- creating an ExoPlayer instance in preview can crash the preview renderer
So this pattern keeps the preview stable while still working normally on device.
Why DisposableEffect Is Non-Negotiable
DisposableEffect
ExoPlayer holds onto resources that must be released:
- decoders
- buffers
- surfaces
- audio focus
DisposableEffect(Unit) ties cleanup to the composable lifecycle. When the MediaCard leaves composition, the onDispose block runs and releases the player.
This is the piece that keeps the implementation safe when the composable is used inside navigation, lists, or conditional UI. Without it, you can leak resources quickly.
In the next section we will move from “player exists” to “player is rendered”, using Media3’s Compose UI integration via PlayerSurface, and we will also introduce the presentation state that makes sizing and cover behavior easier to manage.
Rendering Video with PlayerSurface
At this point we have a correctly managed ExoPlayer instance, but nothing is visible yet. This is where Media3’s Compose UI integration becomes the key difference compared to older approaches.
Instead of rendering through PlayerView, Media3 now provides a Compose-first surface via androidx.media3.ui.compose. This is the module that unlocks PlayerSurface, sizing helpers, and state objects designed specifically for Compose UIs. We will link to the official docs here because it is the best reference point when these APIs evolve:
In your implementation, rendering starts with a presentation state:
val presentationState = rememberPresentationState(exoPlayer)
Why rememberPresentationState Is Useful
rememberPresentationState
PlayerSurface can work without it, but rememberPresentationState(exoPlayer) gives you Compose-friendly state derived from the player, including:
- the video’s size (exposed here as
videoSizeDp) - whether Media3 thinks the surface should currently be covered (exposed as
coverSurface)
That state becomes especially useful when you want consistent cropping behavior and a clean fallback while the player is not ready to render frames yet.
The Actual Compose Rendering
Here is the rendering part exactly as in your code:
There are a few important details here.
clipToBounds() Makes the Card Feel Correct
clipToBounds()
Because the card has rounded corners, you want the video to respect the visible area. clipToBounds() ensures the rendered surface does not draw outside the box bounds.
This becomes more obvious with ContentScale.Crop, where the video will intentionally overflow its layout space in order to fill the container. Cropping is expected, but drawing outside the container is not.
Cropping Correctly in Compose
This line is doing most of the visual heavy lifting:
modifier = Modifier.resizeWithContentScale(ContentScale.Crop, presentationState.videoSizeDp),
Instead of manually calculating aspect ratios, resizeWithContentScale takes:
- how big the video actually is (
presentationState.videoSizeDp) - how you want it scaled (
ContentScale.Crop)
and applies the correct sizing behavior for a Compose layout.
Surface Type Choice
You are explicitly selecting:
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
This matters because the underlying surface implementation impacts how video is rendered and how it behaves in various UI scenarios. You are being deliberate here rather than relying on defaults, which is helpful when you want predictable rendering behavior.
Covering the Surface During Transitions
This block:
if (presentationState.coverSurface) {
Box(Modifier.background(Color.Black))
}
acts as a simple but effective fallback layer. When Media3 determines the surface should be covered (for example during certain transitions, sizing updates, or when the first frame is not ready), you draw a black overlay instead of letting the surface show visual noise.
It is one of those small details that makes the component feel more polished in real usage.
In the next section, we will pull everything together and walk through the full composable as one piece, then call out a few practical usage notes (like where this pattern fits best, and what to watch out for when you scale it up).
Putting It All Together and Practical Notes
At this point, all the pieces are in place:
- A Material 3 card that defines the visual container
- An ExoPlayer instance that is stable across recompositions
- Correct cleanup with
DisposableEffect - Media3 Compose UI rendering via
PlayerSurface - Presentation state to handle sizing and surface cover behavior
Here is the full composable exactly as you provided it, with everything in one place:
Practical Notes from Real Usage
A few things are worth calling out once you move beyond a single demo screen and start using this pattern in production UI.
This Works Best When the Card Owns the Player
This component is intentionally self-contained. That is great for screens where each card is responsible for its own playback and lifecycle.
If you later need more advanced behavior (for example, shared playback across multiple screens, handoff between list items, or a single player instance reused across the app), you might lift the player out of the card and inject it instead. But as a default pattern, keeping ownership local is simple and safe.
Be Careful with Many Players in a Lazy List
If you render lots of these cards in a scrolling list, you can quickly end up with multiple active players. Even if only one is visible, the resource cost adds up.
This composable is still a solid base, but in list scenarios you usually also add constraints like:
- only play the currently visible item
- pause when the item leaves the viewport
- reuse a shared player
The important part is that the lifecycle hooks you already have (remember and DisposableEffect) are the building blocks for those improvements.
The Compose UI Module Is the Key Difference
If someone is coming from older examples, the big upgrade is that PlayerSurface and related helpers are in Media3’s Compose UI integration. That is the direction the ecosystem is moving toward, and it is why this pattern stays Compose-first without interop.
Compose-First Playback That Matches Where Media3 Is Going
What I like about this approach is that it does not feel like a workaround. You are not embedding a View inside Compose just to get video on screen. You are using Media3’s Compose UI integration the way it was intended: the player is managed inside the composable, the surface is rendered with PlayerSurface, and the UI behavior (cropping, clipping, overlays) stays in Compose where it belongs.
Yes, the official docs still demonstrate PlayerView in a couple of the more common entry points, and that can easily send you down a slightly outdated path when you are building a Compose-only screen. But once you lean into the Media3 Compose module, the setup becomes straightforward: remember the player, release it reliably, and render it with a surface that understands Compose sizing.
The end result is a MediaCard that feels reusable and production-friendly. It fits naturally into Material 3 layouts, it behaves predictably through recompositions, and it avoids the hidden complexity that comes with mixing UI systems. If you are already writing Compose, this is the kind of media playback code that will age well as the ecosystem continues to move forward.