wandres.dev
IMÁGENES Y MULTIMEDIA · fotos, audio, vídeo

Audio y vídeo: AVPlayer, sesión y controles del sistema

Las cuatro capas de AVFoundation, la sesión de audio como contrato con el resto del dispositivo, reproducción en segundo plano con pantalla de bloqueo y mandos, y cómo observar el reproductor sin dejar fugas ni crashes.

⏱ 18 min

Reproducir un vídeo en SwiftUI son dos líneas. Reproducirlo bien —que siga sonando con la pantalla apagada, que aparezca en la pantalla de bloqueo, que se pause cuando entra una llamada y se reanude después, que no compita groseramente con la música del usuario— exige entender que el audio de iOS no es una función de tu app, sino un recurso compartido de todo el dispositivo del que tú solo pides un turno.

🎯 Al terminar esta lección sabrás
  • Distinguir las capas de asset, item, player y presentación.
  • Elegir la categoría y el modo de sesión de audio correctos.
  • Sonar en segundo plano con controles del sistema.
  • Observar tiempo y estado sin fugas ni crashes.

Las cuatro capas

VideoPlayer de AVKit es la capa más fina posible, y suele bastar:

import AVKit

struct Reproductor: View {
    @State private var reproductor = AVPlayer(url: URL(string: "https://ejemplo.com/v.m3u8")!)

    var body: some View {
        VideoPlayer(player: reproductor) {
            Text("EN DIRECTO")
                .font(.caption.bold())
                .padding(6)
                .background(.red, in: Capsule())
                .padding()
        }
        .aspectRatio(16/9, contentMode: .fit)
        .onDisappear { reproductor.pause() }
    }
}

El cierre opcional es una superposición que se dibuja encima del vídeo, no un contenido alternativo. Debajo de esa comodidad hay cuatro objetos con responsabilidades separadas que conviene no confundir:

🎞️

AVAsset

El medio en sí: pistas, duración, metadatos. Es inmutable y puede compartirse. Cargar sus propiedades es asíncrono, con load(.duration).

▶️

AVPlayerItem

Una sesión de reproducción sobre un asset. Aquí viven status, el buffer, los errores y los rangos cargados. Un asset puede tener varios items.

🎚️

AVPlayer

El transporte: play, pause, rate, volume, item actual. No sabe nada de píxeles. AVQueuePlayer añade cola.

🖼️

Presentación

VideoPlayer en SwiftUI, AVPlayerViewController con controles completos, o AVPlayerLayer si quieres dibujar tú.

La confusión clásica es buscar el error de reproducción en el AVPlayer. No está ahí: player.currentItem?.status vale .failed y player.currentItem?.error tiene el motivo. El player está perfectamente sano reproduciendo algo que falló.

La sesión de audio es un contrato

Antes de que suene el primer byte, tu app tiene que declararle al sistema qué clase de audio es. Eso es AVAudioSession:

import AVFAudio

func configurarSesion() throws {
    let sesion = AVAudioSession.sharedInstance()
    try sesion.setCategory(.playback, mode: .moviePlayback)
    try sesion.setActive(true)
}

Las categorías no son ajustes de calidad: son declaraciones de intención con consecuencias visibles.

  • .ambient — tu audio es accesorio. Se mezcla con el de otras apps y se calla con el interruptor de silencio. Es lo correcto para efectos de sonido de una interfaz.
  • .soloAmbient — el valor por defecto. Se calla con el interruptor y además interrumpe a los demás. Casi nunca es lo que quieres conscientemente.
  • .playback — tu audio es el contenido principal. Sigue sonando con el interruptor de silencio y con la pantalla apagada, e interrumpe a los demás salvo que añadas la opción mixWithOthers. Es la de un reproductor de música, pódcast o vídeo.
  • .playAndRecord — para grabar y reproducir a la vez. Exige permiso de micrófono y por defecto saca el sonido por el auricular, no por el altavoz: de ahí la opción defaultToSpeaker.

Y los modos afinan el comportamiento dentro de la categoría: .moviePlayback aplica el procesado de cine, .spokenAudio optimiza voz y habilita saltos de audio, .voiceChat activa cancelación de eco.

Estado global mutable compartido entre todas las apps del dispositivo

La sesión de audio es una de las poquísimas piezas de iOS donde tu app manipula estado que no le pertenece. AVAudioSession.sharedInstance() no es un singleton de tu proceso: es tu representante en una negociación con el sistema, con las otras apps que estén sonando, con el hardware conectado y con las decisiones del usuario. Cuando llamas a setActive(true) con categoría .playback estás, literalmente, callando a Spotify.

Esto invierte la intuición del programador de apps. Estás acostumbrado a que tu proceso sea un mundo cerrado donde tus decisiones solo te afectan a ti. Aquí no: cada elección de categoría es una afirmación sobre tu importancia relativa frente a todo lo demás, y el sistema la va a hacer cumplir. Elegir .playback porque “así funciona el vídeo” en una app cuyo audio es un efecto de botón significa que cada pulsación mata la música del usuario. Elegir .ambient en un reproductor de pódcast significa que el audio muere en cuanto se bloquea la pantalla.

Y como el estado es compartido y externo, puede cambiar sin tu permiso en cualquier momento: una llamada entrante, una alarma, Siri, unos auriculares desconectados. De ahí que el modelo correcto no sea “configuro la sesión al arrancar y me olvido”, sino el de una máquina de estados que reacciona a interrupciones y cambios de ruta. Toda la fragilidad de las apps de audio mal escritas —la que se queda muda tras una llamada, la que reanuda sola cuando no debe, la que grita por el altavoz al desenchufar los auriculares en el metro— nace de tratar como configuración lo que en realidad es un protocolo.

stateDiagram-v2
[*] --> Reproduciendo
Reproduciendo --> Interrumpido: interruption began
Interrumpido --> Reproduciendo: interruption ended con shouldResume
Interrumpido --> Pausado: interruption ended sin shouldResume
Reproduciendo --> Pausado: oldDeviceUnavailable
Pausado --> Reproduciendo: el usuario pulsa play

Las interrupciones llegan por notificación y hay que atenderlas explícitamente. La regla que más se incumple es la de la ruta: cuando el usuario desconecta los auriculares, el sistema te avisa con AVAudioSession.routeChangeNotification y razón oldDeviceUnavailable, y debes pausar. Si no lo haces, el pódcast sale a todo volumen por el altavoz en un vagón lleno.

Segundo plano y controles del sistema

Sonar con la app en segundo plano exige dos cosas simultáneas, y fallar una sola deja de funcionar todo: la categoría .playback activa, y la capacidad de fondo declarada en el proyecto, que añade audio a UIBackgroundModes en Info.plist.

Con eso el audio sigue. Pero la pantalla de bloqueo, el centro de control, los auriculares y CarPlay siguen mudos hasta que publiques información y aceptes mandos:

import MediaPlayer

func publicarAhoraSuena(titulo: String, duracion: TimeInterval,
                        posicion: TimeInterval, ritmo: Float) {
    MPNowPlayingInfoCenter.default().nowPlayingInfo = [
        MPMediaItemPropertyTitle: titulo,
        MPMediaItemPropertyPlaybackDuration: duracion,
        MPNowPlayingInfoPropertyElapsedPlaybackTime: posicion,
        MPNowPlayingInfoPropertyPlaybackRate: ritmo
    ]
}

func aceptarMandos(_ reproductor: AVPlayer) {
    let centro = MPRemoteCommandCenter.shared()
    centro.playCommand.addTarget { _ in reproductor.play();  return .success }
    centro.pauseCommand.addTarget { _ in reproductor.pause(); return .success }
    centro.skipForwardCommand.preferredIntervals = [30]
    centro.skipForwardCommand.addTarget { _ in
        reproductor.seek(to: reproductor.currentTime() + CMTime(seconds: 30, preferredTimescale: 600))
        return .success
    }
    centro.nextTrackCommand.isEnabled = false   // desactiva lo que no soportas
}

El detalle que casi todo el mundo omite: MPNowPlayingInfoPropertyPlaybackRate es lo que hace que la barra de progreso de la pantalla de bloqueo avance sola. No hace falta actualizar la posición cada segundo; el sistema extrapola desde la posición y el ritmo que le diste. Si el ritmo es cero, la barra se queda congelada aunque el audio suene.

Observar sin dejar fugas

Para pintar tu propia barra de progreso necesitas un observador periódico, y ahí hay una trampa con nombre propio:

let intervalo = CMTime(seconds: 0.5, preferredTimescale: 600)
let testigo = reproductor.addPeriodicTimeObserver(forInterval: intervalo, queue: .main) { tiempo in
    posicion = tiempo.seconds
}
// obligatorio antes de soltar el reproductor
reproductor.removeTimeObserver(testigo)

Si el AVPlayer se libera con un observador de tiempo aún registrado, la app revienta con una excepción explícita de AVFoundation. No es una fuga silenciosa: es un crash. El observador retiene el cierre, el cierre suele retener tu modelo, y tu modelo retiene el reproductor: un ciclo perfecto. Guarda el testigo y quítalo en el mismo sitio donde paras la reproducción.

💡
El estado vive en el item, y llega tarde

AVPlayerItem.status empieza en .unknown y solo pasa a .readyToPlay o .failed cuando el asset se ha inspeccionado. Llamar a play() antes de eso no falla, simplemente no hace nada visible, y produce el clásico informe de “el vídeo no se reproduce a veces”. Observa el estado del item y arranca cuando esté listo, en vez de suponer que la construcción es síncrona.

⚔️ Un reproductor que se porta bien
  1. Monta un VideoPlayer con una superposición propia y comprueba que .onDisappear pausa de verdad.
  2. Configura la sesión como .playback y observa qué le ocurre a la música que estuviera sonando. Repite con la opción mixWithOthers.
  3. Activa el modo de fondo de audio y verifica que el sonido continúa con la pantalla bloqueada.
  4. Publica en MPNowPlayingInfoCenter con y sin PlaybackRate y compara el comportamiento de la barra en la pantalla de bloqueo.
  5. Suscríbete a routeChangeNotification y pausa al desconectar los auriculares. Pruébalo de verdad, desenchufando.
  6. Registra un observador de tiempo y libera el reproductor sin quitarlo. Lee el mensaje del crash y arréglalo.