Resposta rápida: em 2026, o caminho oficial para reproduzir vídeo e áudio no Android com Kotlin é AndroidX Media3, a evolução do ExoPlayer. Use o artefato media3-exoplayer para o motor, media3-ui (ou UI Compose quando o projeto já adotar o módulo estável do time) para a superfície de vídeo, e amarre o ciclo de vida da Activity/Composable para liberar o player. O padrão prático com Jetpack Compose é: criar o ExoPlayer fora da recomposição, embutir PlayerView/PlayerView via AndroidView, observar estados com Player.Listener ou wrappers, e liberar com player.release() no onDispose/onCleared. Media3 não é “só um VideoView moderno”: ele cobre adaptive streaming (HLS/DASH), playlists, cache, faixas de legenda, áudio em background e integração com MediaSession.

Apps de curso, fintech com vídeo KYC, marketplaces com demo de produto, podcasts e redes sociais internas pedem player confiável. O erro clássico em Compose é recriar o ExoPlayer a cada recomposição, vazar superfície ao rotacionar a tela ou esquecer release() — e o app vira um sumidouro de memória e bateria. Este guia mostra a base de produção em Kotlin: dependências, player único, UI Compose, lifecycle, playlist, cache, background e checklist de QA. Conecte com permissões no Android, WorkManager para pré-download, Coil no Compose para thumbnails e offline-first quando o conteúdo precisa sobreviver sem rede.

Media3 ou “ainda ExoPlayer”?

Media3 é o pacote AndroidX que unifica o antigo ExoPlayer, as UIs de mídia e as APIs de sessão. O motor de playback continua sendo o ExoPlayer, agora no namespace androidx.media3.exoplayer. Em código e vagas no Brasil, as pessoas ainda dizem “ExoPlayer”; em dependências Gradle, o correto é androidx.media3:*.

NecessidadeCaminho em 2026
Vídeo/áudio local ou remotomedia3-exoplayer
Controles e superfície de vídeomedia3-ui (+ AndroidView no Compose)
Notificação / fones / Android Automedia3-session
Transformações, concat, clippingmedia3-transformer / editores de MediaItem
Apenas escolher arquivo de mídia do usuárioPhoto Picker / OpenDocument — sem player embutido

Se o produto só precisa “abrir um MP4 externo”, um intent ou o player do sistema pode bastar. Media3 vale a pena quando o app controla buffer, qualidade, legenda, analytics de playback ou UX própria.

Dependências base

No módulo Android, com Version Catalog ou coordenadas explícitas:

// build.gradle.kts (módulo app)
dependencies {
    val media3 = "1.5.1" // confira a estável do seu BOM/AGP

    implementation("androidx.media3:media3-exoplayer:$media3")
    implementation("androidx.media3:media3-ui:$media3")
    implementation("androidx.media3:media3-session:$media3") // se for background / notificação
    implementation("androidx.media3:media3-exoplayer-hls:$media3") // se usar HLS
    implementation("androidx.media3:media3-datasource-okhttp:$media3") // opcional, HTTP custom
}

Alinhe a versão ao compileSdk e ao restante do AndroidX. Em times com vários módulos, centralize no Gradle Version Catalog e no Compose BOM para a UI.

No AndroidManifest.xml, declare só o que o fluxo usa:

<uses-permission android:name="android.permission.INTERNET" />
<!-- Só se for tocar com tela desligada / serviço em foreground -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />

Não peça armazenamento amplo se o conteúdo vem de URL HTTPS ou de um URI já concedido pelo sistema de arquivos.

Criando o ExoPlayer uma vez

O player é um objeto caro e com estado. Trate-o como dependência de ViewModel ou de um holder com escopo de tela — nunca como remember { ExoPlayer.Builder(...).build() } recriado sem controle.

class VideoPlayerViewModel(
    application: Application,
) : AndroidViewModel(application) {

    val player: ExoPlayer = ExoPlayer.Builder(application).build().apply {
        playWhenReady = true
        repeatMode = Player.REPEAT_MODE_OFF
    }

    fun setMedia(url: String) {
        val item = MediaItem.fromUri(url)
        player.setMediaItem(item)
        player.prepare()
    }

    fun setPlaylist(urls: List<String>, startIndex: Int = 0) {
        player.setMediaItems(urls.map(MediaItem::fromUri), startIndex, /* startPositionMs */ 0L)
        player.prepare()
    }

    override fun onCleared() {
        player.release()
        super.onCleared()
    }
}

Regras que evitam 80% dos bugs:

  1. Um player por fluxo de UI (ou um player de app com fila única, se o produto for “um áudio por vez”).
  2. prepare() depois de configurar MediaItems, não o contrário em ordem confusa.
  3. release() exatamente uma vez no fim do escopo — onCleared do ViewModel ou DisposableEffect se o player for local à tela.
  4. Não chame player.release() e continue usando a referência em listeners.

Compose: PlayerView com AndroidView

Compose ainda se apoia na PlayerView (View system) para a superfície de vídeo na maioria dos apps de produção. O padrão estável:

@Composable
fun Media3Player(
    player: ExoPlayer,
    modifier: Modifier = Modifier,
    useController: Boolean = true,
) {
    AndroidView(
        modifier = modifier.fillMaxWidth().aspectRatio(16f / 9f),
        factory = { context ->
            PlayerView(context).apply {
                this.player = player
                this.useController = useController
                // layoutParams padrão do PlayerView já cobre a superfície
            }
        },
        update = { playerView ->
            if (playerView.player != player) {
                playerView.player = player
            }
        },
    )
}

Pontos de atenção:

  • No update, não recrie a PlayerView — só reatribua o player se mudar.
  • Em navegação Compose, ao sair da tela, ou você mantém o player no ViewModel (continua bufferizando) ou pausa explicitamente (player.pause()) no DisposableEffect de saída.
  • Para tela cheia, combine com edge-to-edge e orientação; veja o guia de Android 16 edge-to-edge e o de Predictive Back se a tela de player interceptar o gesto de voltar.
@Composable
fun AulaVideoScreen(
    viewModel: VideoPlayerViewModel = viewModel(),
    url: String,
) {
    LaunchedEffect(url) {
        viewModel.setMedia(url)
    }

    DisposableEffect(Unit) {
        onDispose {
            // Se o ViewModel sobrevive à tela, só pause:
            viewModel.player.pause()
        }
    }

    Column {
        Media3Player(player = viewModel.player)
        // UI de título, capítulos, velocidade etc. em Compose puro
    }
}

Controles customizados (botões Compose em cima do vídeo) funcionam bem: deixe useController = false e chame player.play(), pause(), seekTo, setPlaybackSpeed a partir da UI.

Observando estado sem recomposição selvagem

Evite ler player.isPlaying direto no corpo do @Composable sem assinatura — a UI não recompõe sozinha. Use um listener ou um State derivado:

@Composable
fun rememberIsPlaying(player: Player): State<Boolean> {
    return produceState(initialValue = player.isPlaying, player) {
        val listener = object : Player.Listener {
            override fun onIsPlayingChanged(isPlaying: Boolean) {
                value = isPlaying
            }
        }
        player.addListener(listener)
        awaitDispose { player.removeListener(listener) }
    }
}

O mesmo padrão serve para playbackState, mediaItemIndex, erros e faixas de legenda. Em telas ricas, prefira expor um StateFlow no ViewModel atualizado pelo listener — fica testável e alinhado a MVVM.

Adaptive streaming, legendas e erros

Para HLS:

val item = MediaItem.Builder()
    .setUri("https://cdn.exemplo.com/aula/master.m3u8")
    .setMimeType(MimeTypes.APPLICATION_M3U8)
    .build()
player.setMediaItem(item)
player.prepare()

Para legenda lateral (WebVTT/TTML), use MediaItem.SubtitleConfiguration no builder do item. Sempre trate PlaybackException no listener: rede instável, 403 de CDN, certificado, formato não suportado e DRM têm mensagens e UX diferentes. Mostre retry com backoff e, se o conteúdo for pago, diferencie “sem internet” de “sem permissão”.

SintomaCausa comumAção
Tela preta, áudio okSuperfície não anexada / player trocado tardeReatribuir PlayerView.player no update
Travou ao rotacionarPlayer recriado na recomposiçãoMover player para ViewModel
Estoura memóriaVários ExoPlayers vivosUm por fluxo + release()
403 no CDNHeader/token ausenteDataSource.Factory com OkHttp + interceptor
Não pausa em ligaçãoFalta AudioAttributes / focusConfigurar foco de áudio no builder

Cache e pré-download

Para cursos e catálogos, cache HTTP do Media3 reduz dados móveis:

val cache = SimpleCache(
    File(context.cacheDir, "media3"),
    LeastRecentlyUsedCacheEvictor(200L * 1024 * 1024),
    StandaloneDatabaseProvider(context),
)

val cacheDataSourceFactory = CacheDataSource.Factory()
    .setCache(cache)
    .setUpstreamDataSourceFactory(DefaultHttpDataSource.Factory())

val player = ExoPlayer.Builder(context)
    .setMediaSourceFactory(DefaultMediaSourceFactory(cacheDataSourceFactory))
    .build()

Pré-download grande (pacote offline de aula) costuma combinar Media3 DownloadManager com WorkManager e restrição de Wi-Fi. Não misture “cache oportunista” com “download explícito do usuário” na mesma UX — o usuário precisa saber o que ocupa armazenamento.

Áudio em background e MediaSession

Vídeo com tela ligada é uma Activity. Podcast/música com tela desligada precisa de serviço de playback + MediaSession (Media3 Session) + notificação de mídia + foco de áudio. O esqueleto mental:

  1. ExoPlayer vive no Service (ou em um holder process-scoped gerenciado pelo serviço).
  2. MediaSession expõe play/pause/seek para fones, notificação e assistentes.
  3. Foreground service com tipo mediaPlayback no manifesto (Android 14+).
  4. Trate AUDIOFOCUS_LOSS e fones desconectados.

Se o produto é “vídeo em tela cheia e pronto”, não carregue session/serviço só por hábito — complexidade sobe rápido e vagas de Android seniores cobram exatamente essa distinção.

Testes e QA

  • Robolectric/instrumented: prefira fakes de Player para ViewModel; teste de buffer real é flaky em CI.
  • Dispositivo físico: valide HLS, rotação, picture-in-picture (se houver), gestos de brightness/seek e multitasking.
  • Rede: throttle 3G, airplane mode no meio do play, troca Wi-Fi → 4G.
  • Acessibilidade: controles com content description, legendas legíveis, não dependa só de cor no estado “ao vivo”.
  • Bateria: deixe o app em background com áudio; confira se o serviço para quando o usuário manda stop.

Para fluxos de UI Compose em geral, o guia de testes Android com Compose e Maestro complementa o que o Media3 sozinho não cobre.

Checklist de produção

  1. Dependências androidx.media3:* alinhadas — zero com.google.android.exoplayer2 legado no mesmo app sem ponte clara.
  2. Player criado uma vez; release() no fim do escopo.
  3. Compose via AndroidView + PlayerView; UI chrome em Compose.
  4. Listener para estado, erro e troca de item.
  5. AudioAttributes e foco de áudio configurados.
  6. Cache ou download com política de tamanho e limpeza.
  7. HTTPS, tokens de CDN e tratamento de 4xx/5xx.
  8. Manifesto de foreground service só se houver background real.
  9. Métricas: tempo até first frame, rebuffer count, taxa de erro (útil em observabilidade se o backend correlacionar sessões).
  10. Documente no README do módulo qual formato (MP4/HLS/DASH) o app suporta de verdade.

Quando não usar Media3

  • Preview de câmera ao vivo → CameraX.
  • Só escolher foto/vídeo da galeria → Photo Picker / PickVisualMedia.
  • Animação de UI, não mídia → animações no Compose.
  • Upload de vídeo em background sem playback → WorkManager + API de storage.

Carreira: por que isso aparece em vagas

Descrições de vagas Android no Brasil ainda listam “ExoPlayer” ao lado de Kotlin, Coroutines e Compose — especialmente em edtech, streaming, fintech e marketplaces. Saber Media3 na prática (lifecycle, HLS, session) diferencia de quem só colou um snippet. Explore vagas Kotlin e o hub de carreira para cruzar stack com salário e senioridade.

Perguntas frequentes

Media3 substitui ExoPlayer?

O motor continua sendo ExoPlayer, agora distribuído como AndroidX Media3. Novos projetos devem usar androidx.media3, não o pacote antigo com.google.android.exoplayer2.

Dá para usar Media3 100% em Compose sem PlayerView?

Há experimentos e módulos de UI Compose no ecossistema, mas o padrão maduro de produção em 2026 ainda embute a superfície View (PlayerView) e compõe o restante da interface em Compose. Avalie estabilidade da lib Compose de mídia do seu time antes de abandonar AndroidView.

Como pausar quando o usuário sai da tela?

Se o ViewModel sobrevive (Navigation com back stack), chame player.pause() no DisposableEffect de saída ou em onStop da Activity hospedeira. Se a tela “é” o player e não volta, release() no onCleared.

Preciso de coroutines dentro do ExoPlayer?

O player tem callbacks próprios. Use coroutines no ViewModel para carregar a URL (API, Room, DataStore) e então chamar setMediaItem. Não envolva player.play() em withContext(Dispatchers.IO) — o ExoPlayer já gerencia threads internas.

E DRM / Widevine?

Conteúdo premium com DRM exige MediaItem com configuração DRM, licenças e, em geral, suporte do provedor de streaming. Só adicione essa complexidade quando o catálogo exigir; para MP4/HLS abertos, o caminho simples acima basta.

Conclusão

Media3 (ExoPlayer) com Kotlin e Jetpack Compose é o stack padrão de playback no Android moderno: um player de longa vida, UI híbrida Compose + PlayerView, listeners de estado, cache consciente e session só quando há background de verdade. Domine lifecycle e release(), e o restante — playlist, HLS, legendas, métricas — encaixa em cima dessa base.

Continue com Jetpack Compose, Navigation Compose, permissões e modularização Android se o player for um feature module. Para oportunidades que pedem essa stack, veja as vagas Kotlin e Android atualizadas no Kotlin Brasil.