From c0ae5fdebb0b4a551fe3dc4eb15ec1cad4253925 Mon Sep 17 00:00:00 2001 From: codey Date: Sun, 22 Feb 2026 15:28:55 -0500 Subject: [PATCH] bugfix: audio playback on chromium now correctly utilizes hls.js --- src/components/Radio.tsx | 179 ++++++++++++++++++++++----------------- src/layouts/Nav.astro | 2 +- 2 files changed, 102 insertions(+), 79 deletions(-) diff --git a/src/components/Radio.tsx b/src/components/Radio.tsx index de323f9..9f0a0ec 100644 --- a/src/components/Radio.tsx +++ b/src/components/Radio.tsx @@ -208,6 +208,11 @@ export default function Player({ user }: PlayerProps) { const [liveDownloadRate, setLiveDownloadRate] = useState(null); // Actual download speed const [actualPlayingLevel, setActualPlayingLevel] = useState(-1); // Actual HLS.js level being played + // Basic mount diagnostics so we can see logs even if HLS never initializes + useEffect(() => { + return () => {}; + }, []); + const formatTime = (seconds: number): string => { if (!seconds || isNaN(seconds) || seconds < 0) return "00:00"; const mins = String(Math.floor(seconds / 60)).padStart(2, "0"); @@ -238,12 +243,23 @@ export default function Player({ user }: PlayerProps) { document.title = `${metaData.title} - Radio - ${artist} - ${song} [${activeStationRef.current}]`; }; + const isSafari = useMemo(() => { + if (typeof navigator === 'undefined') return false; + const ua = navigator.userAgent; + return /Safari/.test(ua) && !/Chrome|Chromium|CriOS|Edg/.test(ua); + }, []); + // Initialize or switch HLS stream const initializeStream = (station) => { setIsStreamReady(false); const audio = audioElement.current; - if (!audio) return; + if (!audio) { + return; + } + + // Ensure CORS is set before HLS attaches (Chromium is stricter than Firefox) + audio.crossOrigin = 'anonymous'; // Clean up previous HLS instance first (synchronous but quick) if (hlsInstance.current) { @@ -266,16 +282,23 @@ export default function Player({ user }: PlayerProps) { setIsStreamReady(false); }; + // Chromium: ensure CORS + inline playback set before attaching + audio.crossOrigin = 'anonymous'; + audio.setAttribute('playsinline', 'true'); + audio.setAttribute('webkit-playsinline', 'true'); + const streamUrl = `https://stream.codey.horse/hls/${station}/${station}.m3u8`; - // Check for native HLS support (Safari) - if (audio.canPlayType("application/vnd.apple.mpegurl")) { + // Check for native HLS support (Safari only). Force HLS.js for Chromium so we always get quality levels. + if (isSafari && audio.canPlayType("application/vnd.apple.mpegurl")) { // Use setTimeout to defer the blocking operations setTimeout(() => { audio.src = streamUrl; audio.load(); setIsStreamReady(true); - audio.play().then(() => setIsPlaying(true)).catch(() => { + audio.play().then(() => { + setIsPlaying(true); + }).catch(() => { setTrackTitle("Offline"); setIsPlaying(false); }); @@ -301,80 +324,71 @@ export default function Player({ user }: PlayerProps) { hlsInstance.current = hls; hls.attachMedia(audio); - hls.on(Hls.Events.MEDIA_ATTACHED, () => hls.loadSource(streamUrl)); + hls.on(Hls.Events.MEDIA_ATTACHED, () => { + hls.loadSource(streamUrl); + }); hls.on(Hls.Events.MANIFEST_PARSED, (_event, data) => { + const levelsRaw = data?.levels ?? []; + if (!levelsRaw.length) { + // Show at least Auto so users know why the selector is missing + setQualityLevels([{ index: -1, bitrate: 0, name: 'Auto' }]); + setSelectedQuality(-1); + setIsStreamReady(true); + audio.play().then(() => setIsPlaying(true)).catch(() => setIsPlaying(false)); + return; + } + // Capture available quality levels (use friendly bitrate names) const standards = [64, 96, 128, 160, 192, 256, 320]; - const levels = data.levels.map((level, index) => { + const levels = levelsRaw.map((level, index) => { const kbps = (level.bitrate || 0) / 1000; const codec = level.audioCodec || level.codecs || ''; - - // Check if this is a lossless codec - const isLossless = codec.toLowerCase().includes('flac') || - codec.toLowerCase().includes('alac') || - kbps > 500; // Likely lossless if >500kbps - + const isLossless = codec.toLowerCase().includes('flac') || codec.toLowerCase().includes('alac') || kbps > 500; + let name: string; if (isLossless) { name = 'Lossless'; } else if (level.bitrate) { - const friendly = standards.reduce((prev, curr) => - Math.abs(curr - kbps) < Math.abs(prev - kbps) ? curr : prev - ); + const friendly = standards.reduce((prev, curr) => Math.abs(curr - kbps) < Math.abs(prev - kbps) ? curr : prev); name = `${friendly} kbps`; } else { name = `Level ${index + 1}`; } - - return { - index, - bitrate: level.bitrate || 0, - name, - isLossless, - }; + + return { index, bitrate: level.bitrate || 0, name, isLossless }; }); - - // Sort: lossless first, then by bitrate descending + levels.sort((a, b) => { if (a.isLossless && !b.isLossless) return -1; if (!a.isLossless && b.isLossless) return 1; return b.bitrate - a.bitrate; }); - + setQualityLevels(levels); - console.log('HLS quality levels:', levels, data.levels); // Debug log - - // Apply saved quality preference, or default to Lossless if network supports it + const savedQuality = localStorage.getItem('radioQuality'); if (savedQuality !== null) { const qualityIndex = parseInt(savedQuality, 10); hls.currentLevel = qualityIndex; setSelectedQuality(qualityIndex); } else { - // Check if network can support lossless (~2 Mbps needed) const connection = (navigator as any).connection; - const downlink = connection?.downlink; // Mbps - const effectiveType = connection?.effectiveType; // '4g', '3g', etc. - - // Default to Lossless only if: - // - downlink >= 2 Mbps, OR - // - effectiveType is '4g' and no downlink info, OR - // - no connection info available (assume good connection) - const canSupportLossless = !connection || - (downlink && downlink >= 2) || - (!downlink && effectiveType === '4g'); - + const downlink = connection?.downlink; + const effectiveType = connection?.effectiveType; + const canSupportLossless = !connection || (downlink && downlink >= 2) || (!downlink && effectiveType === '4g'); const losslessLevel = levels.find(l => l.isLossless); if (losslessLevel && canSupportLossless) { hls.currentLevel = losslessLevel.index; setSelectedQuality(losslessLevel.index); } else { - setSelectedQuality(-1); // Auto - let HLS.js pick based on bandwidth + setSelectedQuality(-1); } } setIsStreamReady(true); - audio.play().then(() => setIsPlaying(true)).catch(() => { + audio.play().then(() => { + setIsPlaying(true); + }).catch(() => { setIsPlaying(false); }); }); @@ -401,46 +415,52 @@ export default function Player({ user }: PlayerProps) { }); let mediaRecoveryAttempts = 0; + let networkRecoveryAttempts = 0; const MAX_RECOVERY_ATTEMPTS = 3; + let networkRecoveryTimeout: ReturnType | null = null; - hls.on(Hls.Events.ERROR, (event, data) => { - console.warn("HLS error:", data); - if (data.fatal) { - switch (data.type) { - case Hls.ErrorTypes.MEDIA_ERROR: - // bufferAppendError and other media errors are recoverable - if (mediaRecoveryAttempts < MAX_RECOVERY_ATTEMPTS) { - mediaRecoveryAttempts++; - console.log(`Attempting media error recovery (attempt ${mediaRecoveryAttempts})`); - if (mediaRecoveryAttempts === 1) { - hls.recoverMediaError(); - } else { - // On subsequent attempts, try swapping audio codec - hls.swapAudioCodec(); - hls.recoverMediaError(); - } - } else { - console.error("Media error recovery failed after max attempts"); - hls.destroy(); - hlsInstance.current = null; - setTrackTitle("Offline"); - setIsPlaying(false); - setIsStreamReady(false); - } - break; - case Hls.ErrorTypes.NETWORK_ERROR: - // Network errors - try to recover by restarting the load - console.log("Network error, attempting to recover..."); - hls.startLoad(); - break; - default: - // Unrecoverable error + hls.on(Hls.Events.ERROR, (_event, data) => { + // Chromium sometimes stutters then fires a fatal error; recover aggressively + const details = data?.details || 'unknown'; + console.warn('[Radio] HLS error', data?.type, details, 'fatal:', data?.fatal); + + if (!data.fatal) { + // Try to keep playback alive on buffer stalls + if (details === Hls.ErrorDetails.BUFFER_STALLED_ERROR) { + try { hls.startLoad(); } catch (_) { /* ignore */ } + } + return; + } + + switch (data.type) { + case Hls.ErrorTypes.NETWORK_ERROR: { + console.warn('[Radio] network error, reinitializing stream'); + hls.destroy(); + hlsInstance.current = null; + setIsPlaying(false); + setIsStreamReady(false); + initializeStream(activeStationRef.current); + break; + } + case Hls.ErrorTypes.MEDIA_ERROR: { + console.warn('[Radio] media error, attempting recovery'); + try { + hls.recoverMediaError(); + } catch (err) { + console.error('[Radio] media recovery failed, restarting', err); hls.destroy(); hlsInstance.current = null; - setTrackTitle("Offline"); - setIsPlaying(false); - setIsStreamReady(false); - break; + initializeStream(activeStationRef.current); + } + break; + } + default: { + console.error('[Radio] unrecoverable error, restarting'); + hls.destroy(); + hlsInstance.current = null; + setIsPlaying(false); + setIsStreamReady(false); + initializeStream(activeStationRef.current); } } }); @@ -516,6 +536,7 @@ export default function Player({ user }: PlayerProps) { // Handle station changes: reset and start new stream useEffect(() => { + console.info('[Radio] station effect', activeStation); // Batch all state resets to minimize re-renders // Use startTransition to mark this as a non-urgent update const resetState = () => { @@ -541,6 +562,7 @@ export default function Player({ user }: PlayerProps) { resetState(); // Defer stream initialization to next frame to keep UI responsive requestAnimationFrame(() => { + console.info('[Radio] initializeStream from station effect', activeStation); initializeStream(activeStation); }); }); @@ -1495,6 +1517,7 @@ export default function Player({ user }: PlayerProps) { {item.artist} - {item.song}
+ {item.trackNo && #{item.trackNo}} {item.album} {item.genre && item.genre !== 'N/A' && ( diff --git a/src/layouts/Nav.astro b/src/layouts/Nav.astro index fac1bdb..df37026 100644 --- a/src/layouts/Nav.astro +++ b/src/layouts/Nav.astro @@ -37,6 +37,7 @@ const baseNavItems: NavItem[] = [ { label: "Manage Requests", href: "/TRip/requests", auth: true }, ], }, + { label: "Discord Logs", href: "/discord-logs", auth: true }, { label: "Admin", href: "javascript:void(0)", @@ -44,7 +45,6 @@ const baseNavItems: NavItem[] = [ adminOnly: true, children: [ { label: "Lighting", href: "/lighting", auth: true, adminOnly: true }, - { label: "Discord Logs", href: "/discord-logs", auth: true, adminOnly: true }, { label: "Glances", href: "https://_gl.codey.horse", auth: true, icon: "external", adminOnly: true }, { label: "PSQL", href: "https://_pg.codey.horse", auth: true, icon: "external", adminOnly: true }, { label: "SQLite", href: "https://_sqlite.codey.horse", auth: true, icon: "external", adminOnly: true },