Construyendo una app de radios LATAM (5): reproducir streams en Android sin que se corte al bloquear la pantalla

Publicado el 28/05/2026

El problema

El núcleo de la app es uno: pulsas play y suena. Si eso no funciona, todo lo demás sobra.

Lo que parecía un <audio src={url} /> resultó ser una sucesión de pequeños problemas concretos:

  • Streams que se cortan cuando bloqueas la pantalla.
  • Streams que nunca empiezan porque la URL es http:// plano y Android los bloquea por defecto.
  • Streams que cargan eternamente sin error claro.
  • Cambios rápidos de emisora que dejan dos audios sonando a la vez (race condition).
  • El usuario pulsa el botón de Inicio y vuelve: la app debe seguir sonando.

Vamos por partes.

El stack de audio: expo-av (con asterisco)

Uso expo-av con Audio.Sound. Es la librería oficial de Expo para audio. Tiene API estable y soporte amplio de formatos (MP3, AAC, HLS en algunos contextos).

El asterisco: Expo está migrando a expo-audio (módulo nuevo). Para v1 elegí expo-av porque la documentación todavía es más rica y la mayoría de ejemplos lo usan. Es deuda técnica conocida y aceptada — cuando expo-audio esté maduro migro.

Permisos del AndroidManifest.xml

Para que el audio sobreviva al bloqueo de pantalla, no basta con configurar expo-av. Hay que pedir los permisos correctos a Android. Mi android/app/src/main/AndroidManifest.xml:

<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>

Lo que cada uno hace y por qué lo tengo:

  • INTERNET — obvio, pero hay que declararlo igual.
  • MODIFY_AUDIO_SETTINGS — necesario para ajustar el modo de audio (ducking cuando llega una notificación, por ejemplo).
  • VIBRATE — lo uso en expo-haptics para el feedback al pulsar play/favorito.
  • WAKE_LOCK — para que el procesador no se duerma mientras hay audio descargándose.
  • FOREGROUND_SERVICE — necesario en Android moderno para tareas de larga duración.
  • FOREGROUND_SERVICE_MEDIA_PLAYBACK — desde Android 14 (API 34), si tu foreground service es de audio, hay que declarar el tipo explícitamente. Sin esto, en dispositivos nuevos el sistema mata el servicio.

Si te falta solo el último, en un Pixel reciente vas a ver el audio cortarse a los 30 segundos de bloquear la pantalla y no vas a entender por qué hasta que mires adb logcat.

El modo de audio: hay que pedirlo explícitamente

Solo configurar el manifest no basta. Hay que decirle a expo-av: "este audio debe seguir sonando aunque la app vaya a background". Eso se hace una sola vez, perezosamente:

let audioModeReady = false;
async function ensureAudioMode() {
  if (Platform.OS === 'web' || audioModeReady) return;
  try {
    await Audio.setAudioModeAsync({
      staysActiveInBackground: true,
      shouldDuckAndroid: true,
      interruptionModeAndroid: InterruptionModeAndroid.DuckOthers,
      interruptionModeIOS: InterruptionModeIOS.DuckOthers,
      playThroughEarpieceAndroid: false,
      playsInSilentModeIOS: true,
      allowsRecordingIOS: false,
    });
    audioModeReady = true;
  } catch (err) {
    if (__DEV__) console.warn('[player] audio mode', err);
  }
}

Decisiones explícitas:

  • staysActiveInBackground: true — la línea crítica. Sin esto, bloquear la pantalla pausa el audio.
  • shouldDuckAndroid: true + DuckOthers — si llega una notificación o el usuario abre el navegador con un vídeo, mi audio baja de volumen en vez de pelearse. Civilizado.
  • playsInSilentModeIOS: true — en iOS por defecto, si tienes el switch en silencio, no suena nada. Para una app de radio eso es absurdo: si has pulsado play, quieres oír música.
  • allowsRecordingIOS: false — explícito porque no grabo, y así no se pide permiso de micrófono que no necesito (también filtrado en app.json: "expo-av": { "microphonePermission": false }).
  • Lazy. Solo lo configuro la primera vez que de verdad voy a reproducir. Si el usuario abre la app y nunca pulsa play, no toco el modo de audio del sistema.

El infierno de los streams http://

Y aquí viene el rastrillo que más tiempo me robó.

Una parte considerable de las emisoras de Radio Browser usan streams en http:// plano, no https. Tienen sus razones: encoder antiguos, certificados que cuestan, configuraciones legacy. El problema es que:

  • Android desde 9 (API 28) bloquea por defecto el tráfico HTTP en cleartext. Si intentas cargar un http://... desde expo-av, falla silenciosamente o lanza un error genérico de "no se puede reproducir".
  • El navegador en https bloquea contenido mixto. Si mi PWA está en https://... y el stream es http://..., el navegador rechaza la petición.

Mis opciones eran:

  1. Habilitar cleartext globalmente en network_security_config.xml. Fácil pero abre la puerta a cualquier tráfico inseguro en toda la app. Me incomodaba.
  2. Habilitar cleartext solo para ciertos dominios. Las emisoras son miles y rotan. Inviable.
  3. Detectar el problema antes de intentar reproducir, y avisar al usuario. La opción "honesta".

Elegí la 3. Antes de mandar la URL a expo-av, paso por un resolver:

function resolveStreamUrl(station: Station): StreamResolution {
  const candidate = station.url_resolved || station.url;
  if (!candidate || !/^https?:\/\//i.test(candidate)) {
    return { url: null, reason: 'Esta emisora no tiene un stream válido.' };
  }
  if (/^http:\/\//i.test(candidate)) {
    if (Platform.OS === 'web' && pageIsHttps()) {
      return {
        url: null,
        reason: 'Esta emisora usa http y tu navegador lo bloquea en sitios https.',
      };
    }
    if (Platform.OS === 'android') {
      return {
        url: null,
        reason: 'Stream http no soportado en Android. Esta emisora no se puede reproducir.',
      };
    }
  }
  return { url: candidate };
}

Y la UI muestra el mensaje real en error:

Stream http no soportado en Android. Esta emisora no se puede reproducir.

¿Es ideal? No. ¿Es honesto? Sí. El usuario sabe por qué no funciona y puede elegir otra emisora.

¿Por qué no habilitar cleartext? Porque mañana podría querer publicar en Play Store, y Google es cada vez más estricto. Prefiero que el coste se pague una vez (informar al usuario) y no arrastrarme con configuración insegura.

Crear, escuchar y limpiar el Sound

Una vez resuelta la URL, la mecánica con expo-av:

async function createNativeHandle(url: string): Promise<PlaybackHandle> {
  const { sound } = await Audio.Sound.createAsync(
    { uri: url },
    { shouldPlay: false, volume: 1.0 },
  );
  let listener = null;
  sound.setOnPlaybackStatusUpdate((s) => {
    if (!s.isLoaded) {
      if ('error' in s && s.error) listener?.({ error: 'No se pudo reproducir la emisora.' });
      return;
    }
    if (s.didJustFinish) {
      listener?.('paused');
      return;
    }
    listener?.(s.isPlaying ? 'playing' : 'paused');
  });
  return {
    play: () => sound.playAsync().then(() => undefined),
    pause: () => sound.pauseAsync().then(() => undefined),
    unload: async () => {
      try { sound.setOnPlaybackStatusUpdate(null); } catch { /* ignore */ }
      try { await sound.unloadAsync(); } catch { /* ignore */ }
    },
    onStatus: (cb) => { listener = cb; },
  };
}

Detalles que aprendí a las malas:

  • shouldPlay: false al crear, y luego playAsync() explícito. Si pones shouldPlay: true y luego haces pauseAsync() rápido, te encuentras con estados raros donde el sound dice "playing" y no oyes nada.
  • setOnPlaybackStatusUpdate(null) antes de unloadAsync(). Si no quitas el listener antes de descargar, te pueden llegar eventos de un sound ya muerto y meter el state en un limbo. Lo envuelvo en try/catch porque ni siquiera me importa si falla — solo quiero asegurarme de no recibir más eventos.
  • unloadAsync() siempre dentro de try/catch. Si la red se cayó y el sound se quedó en estado raro, descargar puede lanzar. No me importa el error — solo quiero soltar memoria.

La integración con PlayerContext

El PlayerContext (capítulo 4) consume este PlaybackHandle con la abstracción createHandle que devuelve un nativo o uno web según la plataforma:

async function createHandle(url: string): Promise<PlaybackHandle> {
  return Platform.OS === 'web' ? createWebHandle(url) : createNativeHandle(url);
}

La belleza: el resto del contexto no sabe si está hablando con expo-av o con un HTMLAudioElement. Ambos implementan la misma interfaz PlaybackHandle. Eso me dejó iterar en web y nativo por separado sin tocarse mutuamente.

Lo que aún no resolví y reconozco

  • Controles del lockscreen y notificación rica. Hoy si bloqueas el teléfono, sigue sonando pero no aparece una notificación con play/pause y el nombre de la emisora. Eso requiere integrarse con la MediaSession de Android, que expo-av no expone directamente. La solución limpia es react-native-track-player o un módulo nativo propio. En la lista de v2.
  • Reanudación inteligente al perder red. Si te metes en el metro y vuelve el wifi, el stream no se reconecta solo. Tendría que detectar el evento error post-arranque y reintentar con backoff.
  • Soporte de metadatos ICY. Algunas radios mandan el título de la canción actual por ICY metadata. Sería precioso mostrarlo en el MiniPlayer. No es trivial con expo-av.

La conclusión que me llevo

Reproducir audio en Android no es difícil. Reproducir audio en Android moderno con todas las restricciones que ha ido metiendo Google es un juego de detalles: permisos del manifest, tipo del foreground service, política de cleartext, modo de audio, gestión del ciclo de vida del Sound. Cualquiera de estos detalles te tumba el feature, y los mensajes de error son normalmente poco claros.

Mi regla de oro al añadir audio: antes de escribir el primer playAsync, ten claro qué permisos pides, qué modo de audio configuras y qué pasa con los http:// planos. Si los tres están resueltos antes de empezar, el resto es ingeniería normal. Si no, vas a perder dos tardes preguntándote por qué se corta al bloquear el móvil.

En el próximo capítulo: cómo hice que la misma app reproduzca en web con HTMLAudioElement y hls.js, y por qué partí el reproductor en dos backends en lugar de buscar una librería unificada.