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:*.
| Necessidade | Caminho em 2026 |
|---|---|
| Vídeo/áudio local ou remoto | media3-exoplayer |
| Controles e superfície de vídeo | media3-ui (+ AndroidView no Compose) |
| Notificação / fones / Android Auto | media3-session |
| Transformações, concat, clipping | media3-transformer / editores de MediaItem |
| Apenas escolher arquivo de mídia do usuário | Photo 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:
- Um player por fluxo de UI (ou um player de app com fila única, se o produto for “um áudio por vez”).
prepare()depois de configurarMediaItems, não o contrário em ordem confusa.release()exatamente uma vez no fim do escopo —onCleareddo ViewModel ouDisposableEffectse o player for local à tela.- 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 aPlayerView— 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()) noDisposableEffectde 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”.
| Sintoma | Causa comum | Ação |
|---|---|---|
| Tela preta, áudio ok | Superfície não anexada / player trocado tarde | Reatribuir PlayerView.player no update |
| Travou ao rotacionar | Player recriado na recomposição | Mover player para ViewModel |
| Estoura memória | Vários ExoPlayers vivos | Um por fluxo + release() |
| 403 no CDN | Header/token ausente | DataSource.Factory com OkHttp + interceptor |
| Não pausa em ligação | Falta AudioAttributes / focus | Configurar 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:
ExoPlayervive noService(ou em um holder process-scoped gerenciado pelo serviço).MediaSessionexpõe play/pause/seek para fones, notificação e assistentes.- Foreground service com tipo
mediaPlaybackno manifesto (Android 14+). - Trate
AUDIOFOCUS_LOSSe 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
Playerpara 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
- Dependências
androidx.media3:*alinhadas — zerocom.google.android.exoplayer2legado no mesmo app sem ponte clara. - Player criado uma vez;
release()no fim do escopo. - Compose via
AndroidView+PlayerView; UI chrome em Compose. - Listener para estado, erro e troca de item.
AudioAttributese foco de áudio configurados.- Cache ou download com política de tamanho e limpeza.
- HTTPS, tokens de CDN e tratamento de 4xx/5xx.
- Manifesto de foreground service só se houver background real.
- Métricas: tempo até first frame, rebuffer count, taxa de erro (útil em observabilidade se o backend correlacionar sessões).
- 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.