DEV Community

Cover image for I Built a Compose Video Player Library Because ExoPlayer Setup Was Driving Me Crazy
Tule Simon
Tule Simon

Posted on

I Built a Compose Video Player Library Because ExoPlayer Setup Was Driving Me Crazy

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 ExoPlayer with the right LoadControl
  • Configure BandwidthMeter for 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
Enter fullscreen mode Exit fullscreen mode

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)
    )
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
    )
}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
)
Enter fullscreen mode Exit fullscreen mode

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.Default
  • XMediaConfig.HighPerformance
  • XMediaConfig.LowLatency
  • XMediaConfig.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
    )
)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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") }
    }
}
Enter fullscreen mode Exit fullscreen mode
// build.gradle.kts
dependencies {
    implementation("com.github.TuleSimon:XComposeMediaPlayer:1.0.0")
}
Enter fullscreen mode Exit fullscreen mode

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)