Construyendo una app de radios LATAM (6): un player web propio para HLS sin perder el código nativo

Publicado el 29/05/2026

El problema

expo-av reproduce en Android sin pestañear. En web también dice que funciona... pero la realidad es que en web no hay un módulo nativo: usa el <audio> del navegador con menos garantías. Y crucialmente, muchas radios online sirven HLS (.m3u8), y el <audio> nativo del navegador solo lo soporta en Safari. En Chrome, Firefox o Edge, hay que cargar HLS manualmente con hls.js.

A esto se suman cosas específicas de web:

  • Autoplay policy. Los navegadores modernos no te dejan reproducir audio sin una interacción del usuario. Mensaje de error feo si no manejas el caso.
  • Mixed content. PWA en https intentando cargar stream en http → bloqueado por el navegador.
  • CORS. Si la radio no devuelve cabeceras CORS adecuadas, no puedes leer su <audio> desde un dominio diferente.

Conclusión: el reproductor nativo y el reproductor web tienen suficiente personalidad propia como para que vivan en módulos separados. Lo que tienen que compartir es la interfaz, no la implementación.

La decisión: adaptador, no librería universal

Considé react-native-track-player, expo-audio y un par de wrappers cross-platform. Los descarté para v1 porque:

  • Sobredimensionados. Yo solo quiero play, pause, unload y un evento de estado. No necesito playlist, scrubbing ni controles de notificación todavía.
  • API compartida no te ahorra los problemas reales. Si la librería dice "soporta HLS en web", lo que hace por debajo es exactamente cargar hls.js. Yo prefiero saber qué pasa.
  • Bundle. Cargar 200kb de librería para un módulo que controlo en 130 líneas no me parecía justo.

La pieza clave que me dejó hacerlo así es un tipo común en PlayerContext:

interface PlaybackHandle {
  play: () => Promise<void>;
  pause: () => Promise<void>;
  unload: () => Promise<void>;
  onStatus: (cb: (s: 'loading' | 'playing' | 'paused' | { error: string }) => void) => void;
}

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

A partir de aquí, el PlayerContext no sabe ni le importa qué backend está usando. Es el patrón strategy: una interfaz, dos implementaciones.

webAudio.ts: el adaptador web

Vive en players/webAudio.ts. Su único trabajo: tomar una URL y devolver un WebSound que cumple la interfaz.

Setup básico de <audio>

export async function createWebSound(url: string): Promise<WebSound> {
  const audio = new Audio();
  audio.preload = 'none';
  audio.crossOrigin = 'anonymous';
  audio.autoplay = false;

  let hls: import('hls.js').default | null = null;
  let listener: ((s: Status) => void) | null = null;
  let destroyed = false;

  const emit = (s: Status) => listener?.(s);

  audio.addEventListener('playing', () => emit({ kind: 'playing' }));
  audio.addEventListener('pause', () => {
    if (!audio.ended) emit({ kind: 'paused' });
  });
  audio.addEventListener('waiting', () => emit({ kind: 'loading' }));
  audio.addEventListener('error', () => {
    if (destroyed) return;
    emit({ kind: 'error', message: 'No se pudo reproducir el stream.' });
  });
  // ...
}

Decisiones explícitas:

  • preload = 'none' — no quiero que el navegador empiece a descargar el stream solo al crear el <audio>. Solo cuando llame a play().
  • crossOrigin = 'anonymous' — fuerza al navegador a pedir CORS. Si el servidor responde, puedo manipular el audio (futuro: visualizadores). Si no responde, el stream falla limpiamente con un evento de error en vez de quedarse colgado.
  • autoplay = false — explícito para no caer en política de autoplay sin querer.
  • destroyed flag — si el usuario para la reproducción mientras el evento error se está disparando, no quiero emitir un error fantasma. Patrón "ignora eventos después de destruido".

Eventos del <audio>: un mapa minado

Si no has trabajado mucho con HTMLAudioElement, los eventos son muchos y confusos. Los que de verdad importan para streaming:

  • playing — está sonando ahora. ¡No es lo mismo que play! play se dispara cuando llamas a audio.play(), antes de saber si va a funcionar. playing se dispara cuando el primer frame de audio se decodifica.
  • pause — se pausó. Cuidado: también se dispara al final del stream. Por eso compruebo if (!audio.ended).
  • waiting — está esperando datos (buffer underrun). Esto es mi "loading".
  • error — algo falló. Pero el MediaError que te da es críptico ("4 - MEDIA_ERR_SRC_NOT_SUPPORTED"). No vale la pena traducirlo; mejor un mensaje genérico.

Otros eventos como loadstart, loadeddata, canplay, canplaythrough... los probé y no aportan información útil para mi caso. Streams en vivo (no archivos) hacen que muchos de ellos no se disparen nunca.

HLS: detección y carga dinámica

let hlsModule: typeof import('hls.js') | null | undefined;
async function loadHls() {
  if (hlsModule !== undefined) return hlsModule;
  try {
    hlsModule = (await import('hls.js')).default as unknown as typeof import('hls.js');
  } catch {
    hlsModule = null;
  }
  return hlsModule;
}

function isHls(url: string): boolean {
  return /\.m3u8(\?|#|$)/i.test(url);
}

function canPlayHlsNatively(audio: HTMLAudioElement): boolean {
  return (
    audio.canPlayType('application/vnd.apple.mpegurl') !== '' ||
    audio.canPlayType('application/x-mpegURL') !== ''
  );
}

Tres detalles que merecen explicación:

  1. hls.js se carga dinámicamente. Es una librería pesada (~120kb minificada). Si el usuario solo escucha streams MP3/AAC, no le hago descargarla. import('hls.js') provoca un chunk separado en el bundle. Web es donde esto importa más: en móvil nativo nunca cargo hls.js (porque el Platform.OS === 'web' no se cumple).
  2. Detección por extensión. .m3u8 en la URL = es HLS. Sí, hay falsos negativos (URLs sin extensión que sirven HLS), pero para una primera versión es lo bastante bueno.
  3. canPlayType antes de cargar hls.js. Si el navegador es Safari y soporta HLS nativo, no necesito hls.js — sería desperdicio. Solo lo cargo si hace falta.

El setup de la fuente

const setupSource = async () => {
  if (isHls(url) && !canPlayHlsNatively(audio)) {
    const HlsCtor = await loadHls();
    if (!HlsCtor) {
      emit({ kind: 'error', message: 'HLS no disponible en este navegador.' });
      return;
    }
    const Hls = HlsCtor as unknown as typeof import('hls.js').default;
    const HlsAny = Hls as unknown as { isSupported: () => boolean };
    if (!HlsAny.isSupported()) {
      emit({ kind: 'error', message: 'HLS no soportado en este navegador.' });
      return;
    }
    hls = new (Hls as any)({ enableWorker: true, lowLatencyMode: false });
    hls!.loadSource(url);
    hls!.attachMedia(audio);
    hls!.on((Hls as any).Events.ERROR, (_evt: unknown, data: any) => {
      if (data?.fatal) {
        emit({ kind: 'error', message: 'Error de stream HLS.' });
      }
    });
  } else {
    audio.src = url;
  }
};

Lo importante:

  • Hls.isSupported() — incluso si hls.js está cargado, puede no funcionar (navegadores muy viejos, MSE deshabilitado). Comprobar antes de instanciar.
  • enableWorker: true — corre el demuxer en un Web Worker. La UI no se bloquea aunque haya pico de procesamiento.
  • lowLatencyMode: false — radios en vivo no son streams de baja latencia con LL-HLS. Activarlo causa más problemas que beneficios.
  • Errores fatales solo. hls.js emite muchos errores no-fatales (un segmento que tardó más, un cambio de calidad). Solo me importan los fatales — el resto los maneja la librería sola.

Autoplay policy: el cliffhanger del primer play

El error más común que vas a ver en web:

NotAllowedError: play() failed because the user didn't interact with the document first.

El navegador exige que audio.play() se llame dentro del callback de una interacción del usuario (click, tap). Y aquí está el truco sutil: si tu flujo es usuario click → setState → useEffect → fetch → play(), esa cadena rompe la herencia de gesto porque cruza un await o un setState async. El play() ya no se considera "iniciado por el usuario".

Mi solución es práctica más que elegante:

async play() {
  try {
    emit({ kind: 'loading' });
    await audio.play();
  } catch (err) {
    emit({
      kind: 'error',
      message:
        err instanceof Error && /not allowed|autoplay/i.test(err.message)
          ? 'El navegador requiere interacción del usuario para reproducir.'
          : 'No se pudo reproducir el stream.',
    });
    throw err;
  }
}

Detecto el mensaje específico del error y muestro un texto humano: "el navegador requiere que pulses play otra vez". Sí, es un workaround, pero el bug está más en la asincronía de la app que en el reproductor: si el primer click el usuario ya pulsó "play", el <audio> consume el gesto incluso aunque el play() se llame medio segundo después, siempre que no haya un setState/useEffect intermedio que rompa la cadena.

Una mejora futura sería pre-crear el <audio> en silencio en el primer tap del usuario en la app, para garantizar el gesto. No lo he hecho aún.

Mixed content y http://: el problema reaparece en web

Igual que en Android (capítulo 5), las URLs en http:// son un problema, esta vez por la mixed content policy del navegador. Si la app vive en https:// (que es lo normal para una PWA decente), el navegador rechaza cualquier recurso http://.

Lo detecto antes de ni intentar reproducir:

function pageIsHttps(): boolean {
  if (Platform.OS !== 'web') return false;
  if (typeof window === 'undefined') return false;
  return window.location?.protocol === 'https:';
}

// En PlayerContext:
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.',
    };
  }
  // ...
}

Mensaje claro, sin trampas. Si quieres oír esa emisora desde la web, te tocaría usar la app nativa.

El unload(): limpiar es la mitad del trabajo

Streams largos y cambios de emisora rápidos te hacen ver lo importante que es liberar bien:

async unload() {
  destroyed = true;
  try {
    audio.pause();
    audio.removeAttribute('src');
    audio.load();
  } catch { /* ignore */ }
  if (hls) {
    try { hls.destroy(); } catch { /* ignore */ }
    hls = null;
  }
}

Lo crítico:

  • removeAttribute('src') + load() — si solo paro el <audio> y lo abandono, el navegador puede seguir descargando bytes "por si acaso". load() con src vacío es el equivalente a un unload real.
  • hls.destroy() — libera el worker, los buffers, los listeners. Sin esto, cada cambio de emisora deja basura colgando y la memoria crece.
  • Try/catch en todo. Estoy llamando a métodos sobre un recurso que puede estar en cualquier estado. No quiero que un fallo de cleanup tumbe el contexto.

Lo que mejoraría

  • Detección de HLS más robusta. No por extensión, sino mirando Content-Type cuando sea posible.
  • Reintentos con backoff ante errores transitorios de red. Hoy un error es terminal.
  • Pool de <audio> precreados para resolver autoplay en flows con asincronía intermedia.
  • Visualizador opcional. Con crossOrigin = 'anonymous' ya puedo enchufar un AnalyserNode y dibujar una onda. Es puro azúcar pero quedaría bien.

La conclusión que me llevo

La idea grande: una interfaz, dos implementaciones. El PlayerContext no sabe que existen dos backends. Platform.OS === 'web' aparece una sola vez, en el sitio donde de verdad importa (la factory createHandle). Todo lo demás del código es ciego a la plataforma.

Y la idea pequeña pero útil: no metas una librería gigante para resolver un problema que tienes claro. webAudio.ts son 130 líneas, las controlo enteras y entiendo qué pasa cuando falla. La próxima vez que llegue un bug raro de audio en web, ya sé exactamente dónde mirar.

En el próximo capítulo cuento cómo monté el "design system" en componentes —StationCard, MiniPlayer, Selector, Header— y qué patrones repetí para que la app se sintiera coherente sin escribir mil estilos.