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,unloady 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 aplay().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.destroyedflag — si el usuario para la reproducción mientras el eventoerrorse 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 queplay!playse dispara cuando llamas aaudio.play(), antes de saber si va a funcionar.playingse dispara cuando el primer frame de audio se decodifica.pause— se pausó. Cuidado: también se dispara al final del stream. Por eso comprueboif (!audio.ended).waiting— está esperando datos (buffer underrun). Esto es mi "loading".error— algo falló. Pero elMediaErrorque 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:
hls.jsse 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 cargohls.js(porque elPlatform.OS === 'web'no se cumple).- Detección por extensión.
.m3u8en 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. canPlayTypeantes de cargarhls.js. Si el navegador es Safari y soporta HLS nativo, no necesitohls.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 sihls.jsestá 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.jsemite 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()consrcvací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-Typecuando 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 unAnalyserNodey 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.