Reproducción en segundo plano
Una aplicación de audio que deja de sonar al bloquear la pantalla está rota, y sin embargo mantenerla sonando exige negociar con tres subsistemas a la vez: el servicio en primer plano que sostiene el proceso, la notificación que el sistema convierte en controles de pantalla de bloqueo, y el foco de audio que reparte el altavoz entre aplicaciones. Esta lección monta `MediaSessionService` de principio a fin y explica por qué el foco de audio es un contrato social.
Cuando el usuario bloquea la pantalla mientras escucha un pódcast, espera exactamente dos cosas: que siga sonando y que los controles aparezcan donde siempre. Nada de eso es gratuito en un sistema operativo cuyo objetivo declarado es matar procesos que no están en pantalla. Sostener la reproducción exige convencer al sistema de que ese trabajo es visible y consentido, lo que en la práctica significa un servicio en primer plano con su notificación permanente; exige publicar el estado en un formato que el sistema entienda para pintar los controles en la pantalla de bloqueo, en el panel de ajustes rápidos y en el reloj; y exige participar en el reparto del altavoz con el resto de aplicaciones del dispositivo, porque el audio es un recurso compartido y ninguna aplicación es dueña de él. Media3 empaqueta las tres piezas en un componente único, MediaSessionService, y esa unificación es reciente: durante años cada equipo cableaba a mano la misma coreografía de servicio, notificación y receptores, y cada equipo la cableaba ligeramente mal.
- Construir un
MediaSessionServiceque sostenga la reproducción y sobreviva a la salida de la aplicación. - Explicar cómo la sesión alimenta los controles de la pantalla de bloqueo sin construir la notificación a mano.
- Solicitar, ceder y reaccionar al foco de audio, incluido el atenuado y la interrupción transitoria.
- Reconectar la interfaz al reproductor remoto mediante un controlador y evitar los estados inconsistentes.
El servicio que sostiene el sonido
El componente central es un servicio que aloja la sesión y el reproductor, y cuya única responsabilidad es existir mientras haya algo que reproducir. Se declara en el manifiesto con el tipo de servicio en primer plano correspondiente a la reproducción de medios, se le añade un filtro de intención que el sistema usa para descubrirlo, y se implementan dos métodos: la creación del reproductor y la entrega de la sesión a quien la solicite.
class ServicioDeReproduccion : MediaSessionService() {
private lateinit var sesion: MediaSession
override fun onCreate() {
super.onCreate()
val reproductor = ExoPlayer.Builder(this)
.setAudioAttributes(AudioAttributes.DEFAULT, true)
.setHandleAudioBecomingNoisy(true)
.build()
sesion = MediaSession.Builder(this, reproductor).build()
}
override fun onGetSession(info: MediaSession.ControllerInfo) = sesion
override fun onDestroy() {
sesion.player.release()
sesion.release()
super.onDestroy()
}
}
Los dos argumentos de la construcción del reproductor son más importantes de lo que aparentan. El primero declara los atributos de audio y delega en la biblioteca la gestión completa del foco, que veremos en detalle. El segundo hace que el reproductor pause automáticamente cuando la salida de audio deja de ser adecuada, que es lo que ocurre al desconectar unos auriculares: sin él, el pódcast pasa al altavoz del teléfono y suena a todo volumen en un vagón lleno de gente.
Un servicio de medios que nunca se detiene mantiene el proceso vivo y la notificación presente aunque la reproducción haya terminado hace media hora, y el sistema penaliza ese comportamiento tanto en batería como en la percepción del usuario. La regla es que el servicio debe detenerse cuando la reproducción acaba y no hay intención de continuar, y que la liberación del reproductor y de la sesión debe ocurrir siempre en la destrucción. Olvidar la liberación deja decodificadores de hardware retenidos, y esos son un recurso finito del dispositivo que otras aplicaciones necesitan.
Controles del sistema sin construir la notificación
La notificación de medios ya no se construye a mano. El servicio genera una notificación por defecto a partir de la sesión, y el sistema la reconoce como reproducción activa: extrae la carátula, el título, el artista y la posición desde los metadatos y el estado del reproductor, y con eso pinta los controles en la pantalla de bloqueo, en la zona superior del panel de ajustes rápidos y en cualquier superficie conectada.
Esto invierte el modelo mental habitual. No publicas controles, publicas estado; las superficies son consumidoras de ese estado y no las controlas tú. Si el título no aparece en la pantalla de bloqueo, el problema no está en la notificación sino en los metadatos del elemento en curso. Si un botón no funciona desde el reloj, el problema está en las órdenes disponibles que la sesión declara.
val elemento = MediaItem.Builder()
.setUri(direccionDelAudio)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle("Episodio 42")
.setArtist("Mi Pódcast")
.setArtworkUri(direccionDeLaCaratula)
.build(),
)
.build()
Los metadatos deben estar completos antes de que empiece la reproducción, no después de que llegue una descarga. Una carátula que se resuelve tarde produce una notificación que aparece vacía y se rellena un segundo más tarde, y ese parpadeo es visible en la pantalla de bloqueo. Si los datos vienen de red, conviene resolverlos al construir la lista y no al reproducir cada elemento.
override fun onConnect(
sesion: MediaSession,
controlador: MediaSession.ControllerInfo,
): MediaSession.ConnectionResult {
val ordenes = MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS
.buildUpon()
.add(SessionCommand(ORDEN_SALTAR_ADELANTE, Bundle.EMPTY))
.build()
return MediaSession.ConnectionResult.AcceptedResultBuilder(sesion)
.setAvailableSessionCommands(ordenes)
.build()
}
Personalizar los botones adicionales, como saltar treinta segundos o marcar como favorito, se hace declarando órdenes propias en la disposición personalizada de la sesión y atendiéndolas en la devolución de llamada. Ese mecanismo mantiene la coherencia entre todas las superficies: el mismo botón aparece en la notificación, en el reloj y en el coche, porque todos leen la misma declaración.
Pantalla de bloqueo
Se alimenta de los metadatos y del estado de la sesión. No se dibuja, se declara.
Superficies remotas
Reloj, coche y asistente se conectan como controladores. Lo que no esté en las órdenes disponibles, para ellos no existe.
La interfaz de tu aplicación se conecta al mismo sitio: construye un controlador apuntando al servicio y lo trata como si fuera el reproductor, porque implementa la misma interfaz. Ese detalle elimina la clase entera de errores en que la pantalla y la notificación muestran estados distintos, algo endémico en las implementaciones antiguas donde ambas mantenían copias separadas del estado.
val futuro = MediaController.Builder(
contexto,
SessionToken(contexto, ComponentName(contexto, ServicioDeReproduccion::class.java)),
).buildAsync()
futuro.addListener({ enlazarInterfaz(futuro.get()) }, MoreExecutors.directExecutor())
El foco de audio como contrato social
El altavoz es único y las aplicaciones que quieren usarlo son muchas. El foco de audio es el protocolo por el que se reparten: se solicita antes de sonar, se recibe una respuesta que puede ser afirmativa, negativa o diferida, y se escuchan los cambios porque otra aplicación puede pedirlo después. Lo que hace que el sistema funcione es que las pérdidas de foco tienen matices y cada uno exige una reacción distinta.
Una pérdida definitiva significa que otra aplicación ha tomado el control para un uso prolongado, y lo correcto es pausar y abandonar el foco sin intentar recuperarlo. Una pérdida transitoria corresponde a algo breve, como una llamada, y lo correcto es pausar y reanudar cuando se devuelva. Una pérdida transitoria con permiso para atenuar corresponde a una notificación o una indicación de navegación, y lo correcto es bajar el volumen sin pausar, lo que se conoce como atenuado.
stateDiagram-v2 [*] --> SinFoco SinFoco --> ConFoco: solicitud aceptada ConFoco --> Pausado: perdida definitiva ConFoco --> PausaBreve: perdida transitoria PausaBreve --> ConFoco: foco recuperado ConFoco --> Atenuado: perdida con permiso de atenuar Atenuado --> ConFoco: foco recuperado Pausado --> [*]
Los atributos de audio no son burocracia: el tipo de uso y el tipo de contenido que declaras determinan por qué canal de volumen sale el sonido, cómo interactúa con el modo de no molestar y qué prioridad se te asigna frente a otras aplicaciones. Un reproductor de música declarado como sonido de interfaz saldrá por el canal equivocado y el usuario descubrirá que el botón de volumen no controla lo que está oyendo. Es un campo de dos líneas que decide comportamientos que después se depuran durante días.
La buena noticia es que declarar los atributos de audio con la gestión automática activada delega toda esa coreografía en la biblioteca, que solicita el foco al empezar, atenúa cuando corresponde y pausa cuando debe. La sutileza que sigue siendo tuya es la reanudación: tras una pausa por foco, la biblioteca reanuda sola, pero tras una pausa del usuario no debe reanudar nunca. Distinguir ambas es exactamente lo que hace la razón de supresión del estado del reproductor, y esa es la señal que debe gobernar tu interfaz.
El foco de audio es probablemente el mejor sitio del sistema para observar una idea que atraviesa el diseño entero de una plataforma móvil, y que casi nunca se enuncia de forma directa: buena parte de lo que llamamos sistema operativo no es gestión de recursos en el sentido clásico, sino arbitraje entre programas que no se conocen, no se coordinan y no tienen ningún incentivo individual para ceder. La gestión de memoria o de ciclos de procesador es un problema de asignación con una métrica clara y un árbitro con autoridad absoluta: el planificador reparte y nadie discute. El altavoz no funciona así, porque el recurso en disputa no es divisible ni tiene una medida objetiva de utilidad. Dos aplicaciones sonando a la vez no producen media experiencia cada una: producen ruido, un resultado estrictamente peor que cualquiera de las dos en solitario. Y sin embargo el sistema no puede decidir por sí solo quién tiene razón, porque solo la aplicación sabe si lo que va a emitir es una canción, una indicación de giro, un timbre de llamada o una alarma. Lo que hace la plataforma es lo único sensato en esa situación: define un vocabulario compartido de intenciones, un protocolo de solicitud y cesión, y un conjunto de reacciones esperadas ante cada tipo de pérdida. Después confía en que las aplicaciones lo cumplan, porque no puede hacer cumplir gran cosa: una aplicación puede ignorar la pérdida de foco y seguir sonando, y el sistema no la silenciará. Es un régimen de cooperación voluntaria, con la reputación y la desinstalación como únicos mecanismos de sanción. Lo interesante es que el mismo patrón reaparece por todas partes en cuanto uno lo busca: el consumo en segundo plano, la frecuencia de notificaciones, la fidelidad al ciclo de vida, la disciplina en el uso de permisos. En todos esos frentes existe una norma cuya observancia depende de la voluntad del desarrollador, y el equilibrio se sostiene únicamente porque la mayoría coopera. La conclusión práctica es menos técnica de lo que parece pero más exigente: escribir para una plataforma compartida es aceptar restricciones que nadie te obliga a cumplir, porque la alternativa es un dispositivo donde la aplicación más agresiva gana y todas las demás, incluida la tuya, pierden.
- Monta un
MediaSessionServicey comprueba que la reproducción continúa al salir de la aplicación con el botón de inicio. - Verifica que los controles aparecen en la pantalla de bloqueo con carátula, título y artista correctos.
- Desconecta unos auriculares durante la reproducción y confirma que el audio se pausa en lugar de saltar al altavoz.
- Provoca una notificación con sonido y observa el atenuado; después una llamada, y observa la pausa transitoria.
- Conecta la interfaz mediante un controlador y comprueba que pausar desde la notificación actualiza la pantalla al instante.