If you’ve ever implemented video playback in an Android app, you know the drill.
You don’t “just play a video.”
You:
- Set up
ExoPlayerwith the rightLoadControl - Configure
BandwidthMeterfor adaptive streaming - Handle lifecycle (pause in background, release on destroy)
- Manage playback state manually (isPlaying, progress, duration…)
- Configure track selection for HLS quality switching
- Remember the wake lock
- Somehow make it all work with Compose’s declarative model
And you do this:
Every.
Single.
Time.
After copy-pasting 200+ lines of boilerplate across multiple projects, I decided I was done.
So I built XComposeMediaPlayer — a small open-source library that wraps ExoPlayer into a clean, Compose-native API.
The Before and After
Before: Typical ExoPlayer Setup
val bandwidthMeter = DefaultBandwidthMeter.Builder(context).build()
val trackSelector = DefaultTrackSelector(
context,
AdaptiveTrackSelection.Factory()
)
val loadControl = DefaultLoadControl.Builder()
.setBufferDurationsMs(...)
.build()
val player = ExoPlayer.Builder(context)
.setTrackSelector(trackSelector)
.setLoadControl(loadControl)
.setBandwidthMeter(bandwidthMeter)
.build()
val mediaSource = HlsMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(url))
player.setMediaSource(mediaSource)
player.prepare()
player.play()
// Then lifecycle handling...
// State management...
// Listeners...
// Another 100+ lines
That’s just to start playback.
Now compare that to this.
After: With XComposeMediaPlayer
@Composable
fun VideoScreen() {
val state = rememberXMediaState()
XMediaPlayer(
state = state,
url = "https://example.com/video.m3u8",
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
)
}
That’s it.
Five lines.
What XComposeMediaPlayer Actually Does
This isn’t magic. It’s just good abstraction over common video use cases.
1. Automatic Format Detection
Pass any URL — the library determines whether it’s HLS, DASH, MP4, or a local file.
XMediaPlayer(state, "https://example.com/stream.m3u8") // HLS
XMediaPlayer(state, "https://example.com/video.mp4") // Progressive
XMediaPlayer(state, contentUri.toString()) // Local
No manual media source juggling.
2. State as Flows (Compose-Friendly)
All playback state is exposed as StateFlow, so building reactive UI becomes trivial.
val state = rememberXMediaState()
val isPlaying by state.isPlaying.collectAsState()
val progress by state.progress.collectAsState()
val duration by state.duration.collectAsState()
Now you can build fully custom controls:
Slider(
value = progress.toFloat() / duration,
onValueChange = { state.seekToPercent(it) }
)
IconButton(onClick = { state.togglePlayPause() }) {
Icon(
if (isPlaying) Icons.Pause else Icons.Play
)
}
No manual listeners. No awkward bridging between callbacks and Compose.
3. HLS Quality Selection
For adaptive streams, available qualities are extracted automatically.
val qualities by state.availableQualities.collectAsState()
state.setQuality(
qualities.find { it.height == 1080 }!!
)
// Or just:
state.setAutoQuality()
Manual control when you want it. Automatic when you don’t.
4. Built-in Disk Caching
Enable caching via configuration:
val state = rememberXMediaState(XMediaConfig.HighPerformance)
Content is cached while playing.
Replay? Instant.
Reduced bandwidth usage? Yes.
5. Preloading for Feeds
Building a TikTok-style or Instagram-style feed?
Preload the next video:
state.preCacheHls(
url = nextVideoUrl,
context = context,
durationMs = 15_000L
)
Smooth scrolling. No visible buffering.
Configuration Without Overwhelming Complexity
Most apps don’t need to tune every ExoPlayer knob. But when you do, you can.
Use presets:
XMediaConfig.DefaultXMediaConfig.HighPerformanceXMediaConfig.LowLatencyXMediaConfig.DataSaver
Or customize everything:
XMediaConfig(
minBufferMs = 5000,
maxBufferMs = 60000,
cacheConfig = CacheConfig(
enabled = true,
maxCacheSize = 500L * 1024 * 1024
),
trackSelectorConfig = TrackSelectorConfig(
maxVideoHeight = 1080,
bandwidthFraction = 1.2f
),
bandwidthConfig = BandwidthConfig(
initialBitrateEstimate = 5_000_000L
)
)
You get flexibility — without writing infrastructure from scratch.
Lifecycle? Already Handled.
- Background → auto pause
- Foreground → resume if previously playing
- Dispose → player released
- Configuration changes → survives rotation
No DisposableEffect boilerplate.
When You Need More Power
This library doesn’t hide ExoPlayer from you.
Need something advanced?
state.player?.setPlaybackSpeed(1.5f)
It’s a wrapper, not a wall.
What It Doesn’t Try To Be
Let’s be clear:
- Not a background playback service
- Not a DRM abstraction
- Not an ad SDK integration
- Not Picture-in-Picture
If you need MediaSessionService, DRM configuration, IMA ads, or PiP — use Media3 directly.
This library targets the 80% use case:
Play video in Compose cleanly and correctly.
Why I Built This
ExoPlayer is incredibly powerful.
But power comes with complexity, and most apps don’t need to configure every internal component manually.
I wanted:
- A Compose-native API
- Reactive state out of the box
- Quality selection and caching built-in
- Sensible defaults
- Zero boilerplate
So I built it.
Try It Out
Add JitPack:
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
maven { url = uri("https://jitpack.io") }
}
}
// build.gradle.kts
dependencies {
implementation("com.github.TuleSimon:XComposeMediaPlayer:1.0.0")
}
GitHub:
https://github.com/TuleSimon/XComposeMediaPlayer
Final Thoughts
We don’t need to rewrite infrastructure for every project.
Sometimes the best engineering decision isn’t building something complex — it’s building something simple on top of something complex.
If you’re building video features in Jetpack Compose, I hope this saves you hours.
If you find it useful, star the repo.
If something breaks, open an issue.
It’s open source.
Happy coding.

Top comments (0)