Android Video with Media3, Caching and Transformer

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

Baseline date: 2026-10-07. Jetpack Media3 is the supported way to play video on Android. The standalone ExoPlayer repository (google/ExoPlayer) is archived, and its code lives on in the androidx.media3 artifacts. Media3 artifacts are published on Google’s Maven repository, so check the current version on the Media3 release page. The packaging that produces what the player consumes is in video streaming: HLS, DASH and CMAF.

Dependencies

val media3 = "<version>"        // use the current stable version from the release page
dependencies {
    implementation("androidx.media3:media3-exoplayer:$media3")
    implementation("androidx.media3:media3-exoplayer-hls:$media3")
    implementation("androidx.media3:media3-exoplayer-dash:$media3")
    implementation("androidx.media3:media3-ui:$media3")
    implementation("androidx.media3:media3-datasource:$media3")           // CacheDataSource and SimpleCache
    implementation("androidx.media3:media3-database:$media3")
    implementation("androidx.media3:media3-transformer:$media3")          // on-device transcoding
    implementation("androidx.media3:media3-effect:$media3")
}

Playing HLS and DASH

ExoPlayer chooses the media source from the URL’s type, so adding the exoplayer-hls and exoplayer-dash modules is enough to play .m3u8 and .mpd. The player implements adaptive bitrate selection and switches rendition by segment.

import androidx.media3.common.MediaItem
import androidx.media3.exoplayer.ExoPlayer

class PlayerHolder(context: Context) {
    val player: ExoPlayer = ExoPlayer.Builder(context).build().apply {
        setMediaItem(MediaItem.fromUri("https://cdn.example.com/videos/42/master.m3u8"))
        prepare()
        playWhenReady = false
    }
    fun release() = player.release()
}

Release the player in onStop or when the composable leaves the composition, and create one player per screen, not per list item. In a list, show the poster image (see Coil and Glide) and create the player when the user taps play. For private video, add the signed-cookie or token header with a custom DataSource.Factory, see video streaming. Reference: ExoPlayer hello world and HLS support.

Caching segments with SimpleCache

SimpleCache stores downloaded media on disk, so replays and short seeks do not hit the network again.

import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory

object MediaCache {
    private var cache: SimpleCache? = null
    fun get(context: Context): SimpleCache = cache ?: SimpleCache(
        File(context.cacheDir, "media"),
        LeastRecentlyUsedCacheEvictor(200L * 1024 * 1024),     // 200 MB
        StandaloneDatabaseProvider(context),
    ).also { cache = it }
}

fun buildPlayer(context: Context): ExoPlayer {
    val dataSourceFactory = CacheDataSource.Factory()
        .setCache(MediaCache.get(context))
        .setUpstreamDataSourceFactory(DefaultHttpDataSource.Factory())
        .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
    return ExoPlayer.Builder(context)
        .setMediaSourceFactory(DefaultMediaSourceFactory(context).setDataSourceFactory(dataSourceFactory))
        .build()
}

Use a single SimpleCache instance per cache directory for the whole process: a second instance on the same folder throws. Reference: downloading and caching media.

Compressing before upload with Transformer

Media3 Transformer re-encodes a video on the device with the hardware codecs, so the upload is smaller and faster, see device-side compression and uploads for when this is worth doing.

import androidx.media3.common.MimeTypes
import androidx.media3.effect.Presentation
import androidx.media3.transformer.*

fun compress(context: Context, input: Uri, outputPath: String, onDone: (Boolean) -> Unit) {
    val edited = EditedMediaItem.Builder(MediaItem.fromUri(input))
        .setEffects(Effects(emptyList(), listOf(Presentation.createForHeight(720))))   // scale to 720p
        .build()
    val transformer = Transformer.Builder(context)
        .setVideoMimeType(MimeTypes.VIDEO_H264)
        .setAudioMimeType(MimeTypes.AUDIO_AAC)
        .addListener(object : Transformer.Listener {
            override fun onCompleted(composition: Composition, result: ExportResult) = onDone(true)
            override fun onError(composition: Composition, result: ExportResult, exception: ExportException) = onDone(false)
        })
        .build()
    transformer.start(edited, outputPath)      // call from the main thread, runs in the background
}

Transformer can trim, rotate and change frame rate and mime type. It uses the device’s encoders, so output size and quality vary between phones. Keep the server-side transcode as the authority, see video encoding fundamentals. Reference: Media3 Transformer.

Cover frames with MediaMetadataRetriever

fun coverFrame(context: Context, uri: Uri, timeMs: Long): Bitmap? {
    // MediaMetadataRetriever is AutoCloseable (so .use {} works) only from API 29; release() works on every level.
    val retriever = MediaMetadataRetriever()
    try {
        retriever.setDataSource(context, uri)
        return retriever.getFrameAtTime(timeMs * 1000, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
    } finally {
        retriever.release()
    }
}

OPTION_CLOSEST_SYNC returns the nearest keyframe, which is fast. Scale the bitmap, compress it to JPEG or WebP, and upload it as the poster, or leave the cover to the backend, see video covers and thumbnails. Reference: MediaMetadataRetriever.