bugfix: audio playback on chromium now correctly utilizes hls.js

This commit is contained in:
2026-02-22 15:28:55 -05:00
parent ef15b646cc
commit c0ae5fdebb
2 changed files with 102 additions and 79 deletions

View File

@@ -208,6 +208,11 @@ export default function Player({ user }: PlayerProps) {
const [liveDownloadRate, setLiveDownloadRate] = useState<number | null>(null); // Actual download speed const [liveDownloadRate, setLiveDownloadRate] = useState<number | null>(null); // Actual download speed
const [actualPlayingLevel, setActualPlayingLevel] = useState<number>(-1); // Actual HLS.js level being played const [actualPlayingLevel, setActualPlayingLevel] = useState<number>(-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 => { const formatTime = (seconds: number): string => {
if (!seconds || isNaN(seconds) || seconds < 0) return "00:00"; if (!seconds || isNaN(seconds) || seconds < 0) return "00:00";
const mins = String(Math.floor(seconds / 60)).padStart(2, "0"); 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}]`; 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 // Initialize or switch HLS stream
const initializeStream = (station) => { const initializeStream = (station) => {
setIsStreamReady(false); setIsStreamReady(false);
const audio = audioElement.current; 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) // Clean up previous HLS instance first (synchronous but quick)
if (hlsInstance.current) { if (hlsInstance.current) {
@@ -266,16 +282,23 @@ export default function Player({ user }: PlayerProps) {
setIsStreamReady(false); 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`; const streamUrl = `https://stream.codey.horse/hls/${station}/${station}.m3u8`;
// Check for native HLS support (Safari) // Check for native HLS support (Safari only). Force HLS.js for Chromium so we always get quality levels.
if (audio.canPlayType("application/vnd.apple.mpegurl")) { if (isSafari && audio.canPlayType("application/vnd.apple.mpegurl")) {
// Use setTimeout to defer the blocking operations // Use setTimeout to defer the blocking operations
setTimeout(() => { setTimeout(() => {
audio.src = streamUrl; audio.src = streamUrl;
audio.load(); audio.load();
setIsStreamReady(true); setIsStreamReady(true);
audio.play().then(() => setIsPlaying(true)).catch(() => { audio.play().then(() => {
setIsPlaying(true);
}).catch(() => {
setTrackTitle("Offline"); setTrackTitle("Offline");
setIsPlaying(false); setIsPlaying(false);
}); });
@@ -301,80 +324,71 @@ export default function Player({ user }: PlayerProps) {
hlsInstance.current = hls; hlsInstance.current = hls;
hls.attachMedia(audio); 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) => { 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) // Capture available quality levels (use friendly bitrate names)
const standards = [64, 96, 128, 160, 192, 256, 320]; 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 kbps = (level.bitrate || 0) / 1000;
const codec = level.audioCodec || level.codecs || ''; const codec = level.audioCodec || level.codecs || '';
const isLossless = codec.toLowerCase().includes('flac') || codec.toLowerCase().includes('alac') || kbps > 500;
// Check if this is a lossless codec
const isLossless = codec.toLowerCase().includes('flac') ||
codec.toLowerCase().includes('alac') ||
kbps > 500; // Likely lossless if >500kbps
let name: string; let name: string;
if (isLossless) { if (isLossless) {
name = 'Lossless'; name = 'Lossless';
} else if (level.bitrate) { } else if (level.bitrate) {
const friendly = standards.reduce((prev, curr) => const friendly = standards.reduce((prev, curr) => Math.abs(curr - kbps) < Math.abs(prev - kbps) ? curr : prev);
Math.abs(curr - kbps) < Math.abs(prev - kbps) ? curr : prev
);
name = `${friendly} kbps`; name = `${friendly} kbps`;
} else { } else {
name = `Level ${index + 1}`; name = `Level ${index + 1}`;
} }
return { return { index, bitrate: level.bitrate || 0, name, isLossless };
index,
bitrate: level.bitrate || 0,
name,
isLossless,
};
}); });
// Sort: lossless first, then by bitrate descending
levels.sort((a, b) => { levels.sort((a, b) => {
if (a.isLossless && !b.isLossless) return -1; if (a.isLossless && !b.isLossless) return -1;
if (!a.isLossless && b.isLossless) return 1; if (!a.isLossless && b.isLossless) return 1;
return b.bitrate - a.bitrate; return b.bitrate - a.bitrate;
}); });
setQualityLevels(levels); 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'); const savedQuality = localStorage.getItem('radioQuality');
if (savedQuality !== null) { if (savedQuality !== null) {
const qualityIndex = parseInt(savedQuality, 10); const qualityIndex = parseInt(savedQuality, 10);
hls.currentLevel = qualityIndex; hls.currentLevel = qualityIndex;
setSelectedQuality(qualityIndex); setSelectedQuality(qualityIndex);
} else { } else {
// Check if network can support lossless (~2 Mbps needed)
const connection = (navigator as any).connection; const connection = (navigator as any).connection;
const downlink = connection?.downlink; // Mbps const downlink = connection?.downlink;
const effectiveType = connection?.effectiveType; // '4g', '3g', etc. const effectiveType = connection?.effectiveType;
const canSupportLossless = !connection || (downlink && downlink >= 2) || (!downlink && effectiveType === '4g');
// 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 losslessLevel = levels.find(l => l.isLossless); const losslessLevel = levels.find(l => l.isLossless);
if (losslessLevel && canSupportLossless) { if (losslessLevel && canSupportLossless) {
hls.currentLevel = losslessLevel.index; hls.currentLevel = losslessLevel.index;
setSelectedQuality(losslessLevel.index); setSelectedQuality(losslessLevel.index);
} else { } else {
setSelectedQuality(-1); // Auto - let HLS.js pick based on bandwidth setSelectedQuality(-1);
} }
} }
setIsStreamReady(true); setIsStreamReady(true);
audio.play().then(() => setIsPlaying(true)).catch(() => { audio.play().then(() => {
setIsPlaying(true);
}).catch(() => {
setIsPlaying(false); setIsPlaying(false);
}); });
}); });
@@ -401,46 +415,52 @@ export default function Player({ user }: PlayerProps) {
}); });
let mediaRecoveryAttempts = 0; let mediaRecoveryAttempts = 0;
let networkRecoveryAttempts = 0;
const MAX_RECOVERY_ATTEMPTS = 3; const MAX_RECOVERY_ATTEMPTS = 3;
let networkRecoveryTimeout: ReturnType<typeof setTimeout> | null = null;
hls.on(Hls.Events.ERROR, (event, data) => { hls.on(Hls.Events.ERROR, (_event, data) => {
console.warn("HLS error:", data); // Chromium sometimes stutters then fires a fatal error; recover aggressively
if (data.fatal) { const details = data?.details || 'unknown';
switch (data.type) { console.warn('[Radio] HLS error', data?.type, details, 'fatal:', data?.fatal);
case Hls.ErrorTypes.MEDIA_ERROR:
// bufferAppendError and other media errors are recoverable if (!data.fatal) {
if (mediaRecoveryAttempts < MAX_RECOVERY_ATTEMPTS) { // Try to keep playback alive on buffer stalls
mediaRecoveryAttempts++; if (details === Hls.ErrorDetails.BUFFER_STALLED_ERROR) {
console.log(`Attempting media error recovery (attempt ${mediaRecoveryAttempts})`); try { hls.startLoad(); } catch (_) { /* ignore */ }
if (mediaRecoveryAttempts === 1) { }
hls.recoverMediaError(); return;
} else { }
// On subsequent attempts, try swapping audio codec
hls.swapAudioCodec(); switch (data.type) {
hls.recoverMediaError(); case Hls.ErrorTypes.NETWORK_ERROR: {
} console.warn('[Radio] network error, reinitializing stream');
} else { hls.destroy();
console.error("Media error recovery failed after max attempts"); hlsInstance.current = null;
hls.destroy(); setIsPlaying(false);
hlsInstance.current = null; setIsStreamReady(false);
setTrackTitle("Offline"); initializeStream(activeStationRef.current);
setIsPlaying(false); break;
setIsStreamReady(false); }
} case Hls.ErrorTypes.MEDIA_ERROR: {
break; console.warn('[Radio] media error, attempting recovery');
case Hls.ErrorTypes.NETWORK_ERROR: try {
// Network errors - try to recover by restarting the load hls.recoverMediaError();
console.log("Network error, attempting to recover..."); } catch (err) {
hls.startLoad(); console.error('[Radio] media recovery failed, restarting', err);
break;
default:
// Unrecoverable error
hls.destroy(); hls.destroy();
hlsInstance.current = null; hlsInstance.current = null;
setTrackTitle("Offline"); initializeStream(activeStationRef.current);
setIsPlaying(false); }
setIsStreamReady(false); break;
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 // Handle station changes: reset and start new stream
useEffect(() => { useEffect(() => {
console.info('[Radio] station effect', activeStation);
// Batch all state resets to minimize re-renders // Batch all state resets to minimize re-renders
// Use startTransition to mark this as a non-urgent update // Use startTransition to mark this as a non-urgent update
const resetState = () => { const resetState = () => {
@@ -541,6 +562,7 @@ export default function Player({ user }: PlayerProps) {
resetState(); resetState();
// Defer stream initialization to next frame to keep UI responsive // Defer stream initialization to next frame to keep UI responsive
requestAnimationFrame(() => { requestAnimationFrame(() => {
console.info('[Radio] initializeStream from station effect', activeStation);
initializeStream(activeStation); initializeStream(activeStation);
}); });
}); });
@@ -1495,6 +1517,7 @@ export default function Player({ user }: PlayerProps) {
{item.artist} - {item.song} {item.artist} - {item.song}
</div> </div>
<div className="text-xs text-neutral-500 dark:text-neutral-400 truncate" title={item.album}> <div className="text-xs text-neutral-500 dark:text-neutral-400 truncate" title={item.album}>
{item.trackNo && <span className="text-neutral-400 dark:text-neutral-500 mr-1">#{item.trackNo}</span>}
{item.album} {item.album}
{item.genre && item.genre !== 'N/A' && ( {item.genre && item.genre !== 'N/A' && (
<span className="ml-2 px-1.5 py-0.5 bg-neutral-200 dark:bg-neutral-700 rounded text-[10px]"> <span className="ml-2 px-1.5 py-0.5 bg-neutral-200 dark:bg-neutral-700 rounded text-[10px]">

View File

@@ -37,6 +37,7 @@ const baseNavItems: NavItem[] = [
{ label: "Manage Requests", href: "/TRip/requests", auth: true }, { label: "Manage Requests", href: "/TRip/requests", auth: true },
], ],
}, },
{ label: "Discord Logs", href: "/discord-logs", auth: true },
{ {
label: "Admin", label: "Admin",
href: "javascript:void(0)", href: "javascript:void(0)",
@@ -44,7 +45,6 @@ const baseNavItems: NavItem[] = [
adminOnly: true, adminOnly: true,
children: [ children: [
{ label: "Lighting", href: "/lighting", auth: true, adminOnly: true }, { 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: "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: "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 }, { label: "SQLite", href: "https://_sqlite.codey.horse", auth: true, icon: "external", adminOnly: true },