bugfix: audio playback on chromium now correctly utilizes hls.js
This commit is contained in:
@@ -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,40 +324,40 @@ 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;
|
||||||
@@ -342,39 +365,30 @@ export default function Player({ user }: PlayerProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
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) => {
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
hls.on(Hls.Events.ERROR, (event, data) => {
|
|
||||||
console.warn("HLS error:", data);
|
|
||||||
if (data.fatal) {
|
|
||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
case Hls.ErrorTypes.NETWORK_ERROR: {
|
||||||
// bufferAppendError and other media errors are recoverable
|
console.warn('[Radio] network error, reinitializing stream');
|
||||||
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();
|
hls.destroy();
|
||||||
hlsInstance.current = null;
|
hlsInstance.current = null;
|
||||||
setTrackTitle("Offline");
|
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
setIsStreamReady(false);
|
setIsStreamReady(false);
|
||||||
|
initializeStream(activeStationRef.current);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
break;
|
case Hls.ErrorTypes.MEDIA_ERROR: {
|
||||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
console.warn('[Radio] media error, attempting recovery');
|
||||||
// Network errors - try to recover by restarting the load
|
try {
|
||||||
console.log("Network error, attempting to recover...");
|
hls.recoverMediaError();
|
||||||
hls.startLoad();
|
} catch (err) {
|
||||||
break;
|
console.error('[Radio] media recovery failed, restarting', err);
|
||||||
default:
|
hls.destroy();
|
||||||
// Unrecoverable error
|
hlsInstance.current = null;
|
||||||
|
initializeStream(activeStationRef.current);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
console.error('[Radio] unrecoverable error, restarting');
|
||||||
hls.destroy();
|
hls.destroy();
|
||||||
hlsInstance.current = null;
|
hlsInstance.current = null;
|
||||||
setTrackTitle("Offline");
|
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
setIsStreamReady(false);
|
setIsStreamReady(false);
|
||||||
break;
|
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]">
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
Reference in New Issue
Block a user