wandres.dev
CÁMARA Y MULTIMEDIA · CameraX y Media3

Media3 y ExoPlayer

Reproducir un vídeo es descargar trozos, decidir qué calidad pedir a continuación, decodificar en hardware, sincronizar audio y vídeo, y hacerlo todo mientras la red cambia bajo los pies. Esta lección desmonta la arquitectura de `Media3`: la interfaz de reproductor, los componentes internos de `ExoPlayer`, cómo funciona el streaming adaptativo con `DASH` y `HLS`, y por qué la sesión de medios convierte a tu aplicación en un ciudadano del sistema en lugar de una isla.

⏱ 26 min

La reproducción de medios es uno de esos dominios donde la distancia entre la demostración y el producto es enorme. Un reproductor que funciona con un fichero local en el emulador se escribe en veinte líneas; un reproductor que funciona en un tren, con una conexión que oscila entre cuatro y cuarenta megabits, sobre un catálogo con cifrado de contenidos, subtítulos en cinco idiomas y anuncios insertados, es un sistema distribuido en miniatura corriendo dentro de un teléfono. Media3 es la consolidación de ese sistema: unifica bajo una única interfaz de reproductor lo que antes eran tres bibliotecas con contratos incompatibles, y expone ExoPlayer como implementación de referencia con sus piezas internas sustituibles una a una. Entender esa arquitectura importa porque casi todos los problemas serios de reproducción —el arranque lento, el corte en mitad de la frase, la calidad que no sube nunca, la batería que se evapora— no se resuelven en la capa de la interfaz sino eligiendo bien un componente interno.

🎯 Al terminar esta lección sabrás
  • Describir la arquitectura de Media3 y el papel de la interfaz común de reproductor.
  • Identificar los componentes internos de ExoPlayer y saber cuál tocar ante cada síntoma.
  • Explicar el streaming adaptativo y la lógica de selección de pista según ancho de banda y buffer.
  • Publicar una sesión de medios para integrarse con los controles del sistema y otras superficies.

Una interfaz, varias implementaciones

La pieza central de Media3 es la interfaz de reproductor: una superficie común con métodos de preparación, reproducción, pausa, búsqueda y navegación por la lista, más un conjunto de eventos que describen el estado. Esa interfaz la implementa ExoPlayer, pero también la implementan los controladores remotos que hablan con un servicio de otro proceso o con un dispositivo de reproducción externo. La consecuencia práctica es que la interfaz de usuario se escribe una vez contra la abstracción y funciona igual tanto si el reproductor está en la misma pantalla como si está en un servicio en segundo plano.

El estado se compone de tres señales que conviene no confundir. El estado de reproducción indica si el reproductor está inactivo, cargando, listo o ha terminado. La intención de reproducir es un valor independiente que expresa si el usuario quiere que suene. Y la razón de supresión explica por qué, aun queriendo reproducir y estando listo, no suena, típicamente por pérdida del foco de audio. Un indicador de carga correcto necesita las tres: mostrar una rueda cuando el estado es de carga y la intención es reproducir, y no mostrarla cuando el usuario simplemente ha pausado.

val reproductor = ExoPlayer.Builder(contexto).build().apply {
    setMediaItems(listaDeElementos)
    prepare()
    playWhenReady = true
}

reproductor.addListener(object : Player.Listener {
    override fun onPlaybackStateChanged(estado: Int) = actualizarCarga(estado)
    override fun onIsPlayingChanged(sonando: Boolean) = actualizarBoton(sonando)
    override fun onPlayerError(error: PlaybackException) = tratarError(error)
})

El elemento de medios es más que una dirección: transporta metadatos, información de subtítulos, configuración de gestión de derechos digitales y puntos de recorte. Esa riqueza es la que permite que la misma lista alimente la interfaz, la notificación del sistema y el asistente de voz sin que tú copies datos a tres sitios.

Las piezas internas y qué síntoma resuelve cada una

ExoPlayer no es un bloque opaco sino un ensamblaje de cuatro colaboradores. La fuente de medios obtiene y analiza el contenido según su formato. La fábrica de renderizadores decide qué decodificadores se usan para cada tipo de pista. El selector de pistas elige, en cada instante, qué representación de audio, vídeo y texto se consume. Y el control de carga decide cuánto contenido se almacena por delante antes de empezar y antes de reanudar tras un vaciado.

🧩

Selector de pistas

Responsable de qué calidad se pide. Ahí se limitan resoluciones, se fija un tope de ancho de banda o se prefiere un idioma concreto.

🪣

Control de carga

Responsable de cuánto se guarda por delante. Ahí se ajusta el compromiso entre arranque rápido, resistencia a cortes y consumo de datos.

Ese mapa convierte el diagnóstico en algo sistemático. Si el vídeo arranca tarde, el sospechoso es el buffer mínimo de reproducción del control de carga. Si se corta en redes irregulares, el sospechoso es el buffer objetivo. Si la calidad nunca sube pese a haber ancho de banda, el sospechoso es el estimador y los parámetros del selector adaptativo. Y si un dispositivo concreto falla al decodificar, el sospechoso es la fábrica de renderizadores y su política de respaldo a decodificación por software.

val cargador = DefaultLoadControl.Builder()
    .setBufferDurationsMs(30_000, 90_000, 1_500, 3_000)
    .setPrioritizeTimeOverSizeThresholds(true)
    .build()

val selector = DefaultTrackSelector(contexto).apply {
    parameters = buildUponParameters()
        .setMaxVideoSizeSd()
        .setPreferredAudioLanguage("es")
        .build()
}

Los cuatro valores de duración del control de carga son, en orden, el mínimo que se mantiene, el máximo que se acumula, lo que hace falta para empezar y lo que hace falta para reanudar tras quedarse sin datos. Bajar los dos últimos acelera el arranque a costa de cortarse antes; subirlos hace lo contrario. No hay un ajuste universalmente correcto, pero sí uno correcto para cada tipo de contenido: un vídeo corto en una lista tolera un buffer pequeño porque el usuario abandona si tarda, y una película tolera esperar tres segundos si a cambio no se interrumpe.

La fuente de medios se construye mediante una fábrica que resuelve, a partir del tipo de contenido, qué extractor y qué cliente de red usar. Ahí es donde se inserta la configuración de red común a toda la aplicación —tiempos de espera, cabeceras de autenticación, política de reintentos— y donde se enchufa la caché, de modo que ninguna de esas decisiones se repite por cada elemento reproducido.

val fuentes = DefaultMediaSourceFactory(contexto)
    .setDataSourceFactory(
        DefaultDataSource.Factory(contexto, fabricaHttpConCabeceras),
    )

val reproductorAjustado = ExoPlayer.Builder(contexto)
    .setMediaSourceFactory(fuentes)
    .setLoadControl(cargador)
    .setTrackSelector(selector)
    .build()

Streaming adaptativo y la sesión de medios

El streaming adaptativo consiste en publicar el mismo contenido en varias calidades, troceado en segmentos, con un manifiesto que describe el catálogo. El reproductor descarga el manifiesto, estima el ancho de banda disponible a partir de las descargas anteriores, y elige para cada segmento la representación más alta que cree poder sostener. DASH y HLS difieren en formato y en detalles de empaquetado, pero comparten esa mecánica y ExoPlayer los trata con la misma arquitectura.

La consecuencia de diseño más importante es que la calidad no es una propiedad del contenido sino una decisión continua, y por tanto se puede influir. Un tope de resolución en pantallas pequeñas ahorra datos sin diferencia visible. Un tope de ancho de banda en redes móviles evita facturas desagradables. Y la carga anticipada del primer segmento del siguiente elemento de la lista elimina el silencio entre pistas, que es la diferencia perceptible entre un reproductor bueno y uno mediocre.

💡
Cachear es más barato que volver a pedir

La caché de medios simple permite conservar segmentos ya descargados en disco y reutilizarlos, lo cual es decisivo en dos escenarios muy comunes: el usuario que retrocede unos segundos para volver a oír algo y el usuario que reabre el mismo contenido más tarde. Sin caché, ambos casos vuelven a pagar red y latencia. Con ella, la respuesta es inmediata y el consumo de datos cae de forma medible. Conviene fijarle un límite por tamaño con expulsión del menos usado recientemente, y no confundirla con la descarga para consumo fuera de línea, que es un mecanismo distinto y con gestión propia.

El vídeo necesita además una superficie donde dibujarse, y ese punto de contacto tiene su propia disciplina. La vista de reproductor que ofrece la biblioteca resuelve el escalado, los subtítulos y los controles táctiles; en Compose existe un envoltorio equivalente que expone la superficie sin obligarte a mantener una vista clásica. Lo que no cambia en ninguno de los dos mundos es la obligación de soltar la superficie cuando la pantalla se va, porque un reproductor que sigue decodificando contra una superficie destruida es una fuga silenciosa de un recurso escaso.

ℹ️
Errores que no son errores del reproductor

La excepción de reproducción trae un código que distingue familias muy distintas: fallo de red, fallo del contenedor, fallo del decodificador, fallo de licencia. Tratarlas todas igual y mostrar el mismo mensaje genérico desperdicia la única información útil que tienes. Un fallo de red merece reintento automático con retroceso exponencial; uno de decodificador merece un respaldo a decodificación por software; y uno de licencia no merece reintento ninguno, porque volverá a fallar exactamente igual las veinte veces siguientes.

La lista de reproducción merece tratarse como una estructura de primera clase y no como una sucesión de preparaciones sueltas. Entregar varios elementos al reproductor le permite anticipar el siguiente mientras termina el actual, reutilizar conexiones y decidir la calidad inicial de la próxima pista con la estimación ya acumulada. Reconstruir el reproductor entre elementos tira todo ese contexto a la basura y devuelve al usuario la pausa incómoda que precisamente se quería evitar.

reproductor.setMediaItems(episodios, indiceInicial, posicionInicial)
reproductor.repeatMode = Player.REPEAT_MODE_OFF
reproductor.prepare()

La sesión de medios es la otra mitad del sistema y a menudo se posterga hasta que alguien pregunta por qué los auriculares no pausan la reproducción. Publicar una sesión significa declarar al sistema que tu aplicación está reproduciendo algo, con qué metadatos y qué acciones admite. A partir de ahí, los controles del sistema, el asistente de voz, el reloj conectado y el coche pueden manejarla sin conocer nada de tu aplicación.

val sesion = MediaSession.Builder(contexto, reproductor)
    .setCallback(object : MediaSession.Callback {
        override fun onConnect(
            sesion: MediaSession,
            controlador: MediaSession.ControllerInfo,
        ): MediaSession.ConnectionResult = aceptarSegunOrigen(controlador)
    })
    .build()
flowchart TD
A[Manifiesto DASH o HLS] --> B[Estimador de ancho de banda]
B --> C[Selector adaptativo elige representacion]
C --> D[Descarga de segmento]
D --> E[Buffer controlado por el load control]
E --> F[Decodificacion en renderizadores]
F --> G[Salida de audio y video sincronizada]
E --> B
El reproductor como bucle de control, no como tubería

La forma más productiva de entender un reproductor adaptativo moderno es abandonar la metáfora de la tubería y adoptar la del sistema de control realimentado, porque explica de golpe casi todo su comportamiento aparentemente caprichoso. Una tubería es unidireccional: entra contenido por un extremo, sale imagen por el otro, y si algo falla es porque el caudal no llega. Bajo esa lectura, los cortes se atribuyen a la red y no hay mucho más que decir. Pero lo que realmente ocurre dentro es un lazo cerrado: el nivel del buffer es la variable de estado, la elección de calidad es la señal de control, el ancho de banda medido es una perturbación no observable directamente y la política adaptativa es el regulador que intenta mantener el sistema en régimen. Desde ahí, los comportamientos raros dejan de ser misteriosos y pasan a ser patologías conocidas de cualquier lazo mal ajustado. La calidad que sube y baja constantemente es oscilación por ganancia excesiva, y se corrige con histéresis y con umbrales asimétricos que exigen más evidencia para subir que para bajar. La calidad que se queda anclada abajo tras un bache momentáneo es un estimador con demasiada memoria, un integrador que arrastra el pasado. El vídeo que se corta pese a tener red de sobra es un retardo de lazo: la decisión sobre el segmento que se está descargando se tomó con información de hace varios segundos, y en un lazo con retardo el margen de estabilidad depende de que la constante de tiempo del buffer sea mayor que ese retardo. Esta lectura tiene una consecuencia práctica que justifica el esfuerzo conceptual: sugiere qué se puede ajustar y qué no. Los parámetros del buffer y los de la política adaptativa no son diales independientes que se optimicen por separado, sino los dos términos de un mismo compromiso entre estabilidad y capacidad de respuesta, exactamente igual que en cualquier regulador. Y explica por qué copiar la configuración de otra aplicación rara vez funciona: el ajuste correcto depende de la planta, y la planta aquí es la combinación de duración de segmento, perfil de red típico de tus usuarios y tolerancia real de tu contenido a la espera.

⚔️ Afina tu reproductor
  1. Instrumenta el tiempo desde la preparación hasta el primer cuadro y mídelo con distintos valores de buffer de arranque.
  2. Simula una red irregular con limitación de ancho de banda y observa cómo cambia la representación seleccionada.
  3. Fija un tope de resolución en función del tamaño real de la superficie y mide el ahorro de datos.
  4. Añade una caché de segmentos con límite por tamaño y compara el retroceso de diez segundos con y sin ella.
  5. Publica una sesión de medios y comprueba que el botón de pausa de unos auriculares detiene la reproducción.