Unify HLS players with Live/Replay mode toggle

- Single player for both live rendering and completed HLS streams
- "From Start" mode plays from beginning (replay/VOD style)
- "Live Edge" mode follows rendering progress
- Uses dynamic playlist endpoint for both modes
- Removes duplicate VOD player code

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-04 20:22:54 +00:00
parent 81dc40534c
commit e2761798a8

View File

@@ -88,18 +88,36 @@
</div>
</div>
<!-- Live Stream Player (shown during rendering) -->
{% if run.status == 'rendering' %}
<div id="live-stream-container" class="mb-6 bg-gray-800 rounded-lg p-4">
<!-- Unified HLS Player (shown during rendering OR for completed HLS streams) -->
{% if run.status == 'rendering' or run.ipfs_playlist_cid %}
<div id="hls-player-container" class="mb-6 bg-gray-800 rounded-lg p-4">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold flex items-center">
<span class="w-3 h-3 bg-red-500 rounded-full mr-2 animate-pulse"></span>
Live Preview
{% if run.status == 'rendering' %}
<span id="live-indicator" class="w-3 h-3 bg-red-500 rounded-full mr-2 animate-pulse"></span>
<span id="player-title">Live Preview</span>
{% else %}
<span id="live-indicator" class="w-3 h-3 bg-green-500 rounded-full mr-2 hidden"></span>
<span id="player-title">Video</span>
{% endif %}
</h3>
<div id="stream-status" class="text-sm text-gray-400">Connecting...</div>
<div class="flex items-center space-x-4">
<!-- Mode toggle -->
<div class="flex items-center space-x-2 text-sm">
<button id="mode-replay" onclick="setPlayerMode('replay')"
class="px-2 py-1 rounded {% if run.status != 'rendering' %}bg-blue-600 text-white{% else %}bg-gray-700 text-gray-400 hover:bg-gray-600{% endif %}">
From Start
</button>
<button id="mode-live" onclick="setPlayerMode('live')"
class="px-2 py-1 rounded {% if run.status == 'rendering' %}bg-blue-600 text-white{% else %}bg-gray-700 text-gray-400 hover:bg-gray-600{% endif %}">
Live Edge
</button>
</div>
<div id="stream-status" class="text-sm text-gray-400">Connecting...</div>
</div>
</div>
<div class="relative bg-black rounded-lg overflow-hidden" style="aspect-ratio: 16/9;">
<video id="live-video" class="w-full h-full" controls autoplay muted playsinline></video>
<video id="hls-video" class="w-full h-full" controls autoplay muted playsinline></video>
<div id="stream-loading" class="absolute inset-0 flex items-center justify-center bg-gray-900/80">
<div class="text-center">
<div class="animate-spin w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full mx-auto mb-2"></div>
@@ -107,25 +125,35 @@
</div>
</div>
</div>
<div class="mt-2 text-xs text-gray-500">
Stream URL: <code class="bg-gray-900 px-1 rounded">/runs/{{ run.run_id }}/hls/stream.m3u8</code>
<div class="mt-2 flex items-center justify-between text-xs text-gray-500">
<span>Stream: <code class="bg-gray-900 px-1 rounded">/runs/{{ run.run_id }}/playlist.m3u8</code></span>
<span id="stream-info"></span>
</div>
</div>
<script>
(function() {
const video = document.getElementById('live-video');
const video = document.getElementById('hls-video');
const statusEl = document.getElementById('stream-status');
const loadingEl = document.getElementById('stream-loading');
// Use dynamic playlist endpoint with cache busting
const streamInfoEl = document.getElementById('stream-info');
const liveIndicator = document.getElementById('live-indicator');
const playerTitle = document.getElementById('player-title');
const modeReplayBtn = document.getElementById('mode-replay');
const modeLiveBtn = document.getElementById('mode-live');
const baseUrl = '/runs/{{ run.run_id }}/playlist.m3u8';
const isRendering = {{ 'true' if run.status == 'rendering' else 'false' }};
let hls = null;
let retryCount = 0;
const maxRetries = 120;
let segmentsLoaded = 0;
let currentMode = isRendering ? 'live' : 'replay'; // Default based on status
function getHlsUrl() {
return baseUrl + '?_t=' + Date.now();
}
let hls = null;
let retryCount = 0;
const maxRetries = 120; // Try for up to 4 minutes
let segmentsLoaded = 0;
// Custom playlist loader that adds cache-busting to every request
class CacheBustingPlaylistLoader extends Hls.DefaultConfig.loader {
@@ -139,60 +167,113 @@
}
}
function initHls() {
if (Hls.isSupported()) {
hls = new Hls({
// Custom loader to bust cache on playlist requests
function getHlsConfig(mode) {
const baseConfig = {
maxBufferLength: 120,
maxMaxBufferLength: 180,
maxBufferSize: 100 * 1024 * 1024,
maxBufferHole: 0.5,
backBufferLength: 60,
manifestLoadingTimeOut: 10000,
manifestLoadingMaxRetry: 4,
levelLoadingTimeOut: 10000,
levelLoadingMaxRetry: 4,
fragLoadingTimeOut: 20000,
fragLoadingMaxRetry: 6,
startLevel: 0,
abrEwmaDefaultEstimate: 500000,
};
if (mode === 'live') {
// Live mode: follow the edge, cache-bust playlists
return {
...baseConfig,
pLoader: CacheBustingPlaylistLoader,
liveSyncDurationCount: 10,
liveMaxLatencyDurationCount: 20,
liveDurationInfinity: true,
};
} else {
// Replay mode: start from beginning, no live sync
return {
...baseConfig,
pLoader: CacheBustingPlaylistLoader, // Still bust cache for fresh playlist
startPosition: 0,
liveDurationInfinity: false,
};
}
}
// Stay far behind live edge - rendering is slow (~0.1x speed)
// 10 segments = 40s of buffer before catching up
liveSyncDurationCount: 10, // Stay 10 segments behind live edge
liveMaxLatencyDurationCount: 20, // Allow up to 20 segments behind
liveDurationInfinity: true, // Treat as infinite live stream
function updateModeUI(mode) {
currentMode = mode;
if (mode === 'live') {
modeLiveBtn.classList.add('bg-blue-600', 'text-white');
modeLiveBtn.classList.remove('bg-gray-700', 'text-gray-400');
modeReplayBtn.classList.remove('bg-blue-600', 'text-white');
modeReplayBtn.classList.add('bg-gray-700', 'text-gray-400');
liveIndicator.classList.remove('hidden', 'bg-green-500');
liveIndicator.classList.add('bg-red-500', 'animate-pulse');
playerTitle.textContent = isRendering ? 'Live Preview' : 'Live Edge';
} else {
modeReplayBtn.classList.add('bg-blue-600', 'text-white');
modeReplayBtn.classList.remove('bg-gray-700', 'text-gray-400');
modeLiveBtn.classList.remove('bg-blue-600', 'text-white');
modeLiveBtn.classList.add('bg-gray-700', 'text-gray-400');
liveIndicator.classList.add('hidden');
liveIndicator.classList.remove('animate-pulse');
playerTitle.textContent = 'Replay';
}
}
// Large buffers to absorb rendering speed variations
maxBufferLength: 120, // Buffer up to 120s ahead
maxMaxBufferLength: 180, // Allow even more if needed
maxBufferSize: 100 * 1024 * 1024, // 100MB buffer
maxBufferHole: 0.5, // Tolerate small gaps
window.setPlayerMode = function(mode) {
if (mode === currentMode) return;
// Back buffer for smooth seeking
backBufferLength: 60,
const currentTime = video.currentTime;
const wasPlaying = !video.paused;
// Playlist reload settings - check frequently for new segments
manifestLoadingTimeOut: 10000,
manifestLoadingMaxRetry: 4,
levelLoadingTimeOut: 10000,
levelLoadingMaxRetry: 4,
fragLoadingTimeOut: 20000,
fragLoadingMaxRetry: 6,
// Destroy current HLS instance
if (hls) {
hls.destroy();
hls = null;
}
// Start at lowest quality for faster start
startLevel: 0,
updateModeUI(mode);
segmentsLoaded = 0;
retryCount = 0;
// Enable smooth level switching
abrEwmaDefaultEstimate: 500000,
});
// Reinitialize with new config
initHls(mode, mode === 'replay' ? 0 : null); // Start from 0 in replay, live edge in live
};
function initHls(mode, startPosition) {
mode = mode || currentMode;
if (Hls.isSupported()) {
const config = getHlsConfig(mode);
if (startPosition !== null && startPosition !== undefined) {
config.startPosition = startPosition;
}
hls = new Hls(config);
hls.on(Hls.Events.MANIFEST_PARSED, function(event, data) {
loadingEl.classList.add('hidden');
statusEl.textContent = 'Buffering...';
statusEl.classList.remove('text-gray-400');
statusEl.classList.add('text-yellow-400');
streamInfoEl.textContent = `${data.levels.length} quality level(s)`;
video.play().catch(() => {});
});
hls.on(Hls.Events.FRAG_LOADED, function(event, data) {
retryCount = 0;
segmentsLoaded++;
statusEl.textContent = `Playing (${segmentsLoaded} segments)`;
const modeLabel = currentMode === 'live' ? 'Live' : 'Replay';
statusEl.textContent = `${modeLabel} (${segmentsLoaded} segments)`;
statusEl.classList.remove('text-yellow-400', 'text-gray-400');
statusEl.classList.add('text-green-400');
});
hls.on(Hls.Events.BUFFER_APPENDED, function() {
// Hide loading once we have buffered content
loadingEl.classList.add('hidden');
});
@@ -207,7 +288,6 @@
statusEl.textContent = `Waiting for stream... (${retryCount})`;
statusEl.classList.remove('text-green-400');
statusEl.classList.add('text-yellow-400');
// Exponential backoff with jitter
const delay = Math.min(1000 * Math.pow(1.5, Math.min(retryCount, 6)), 10000);
setTimeout(() => {
hls.loadSource(getHlsUrl());
@@ -227,7 +307,6 @@
break;
}
} else {
// Non-fatal error - just log it
if (data.details === 'bufferStalledError') {
statusEl.textContent = 'Buffering...';
statusEl.classList.remove('text-green-400');
@@ -236,14 +315,11 @@
}
});
// Handle video stalls - check if we've caught up to live edge
video.addEventListener('waiting', function() {
// Check if we're near the live edge (within 2 segments)
if (hls && hls.liveSyncPosition) {
if (currentMode === 'live' && hls && hls.liveSyncPosition) {
const liveEdge = hls.liveSyncPosition;
const currentTime = video.currentTime;
const behindLive = liveEdge - currentTime;
if (behindLive < 8) { // Less than 2 segments behind
const behindLive = liveEdge - video.currentTime;
if (behindLive < 8) {
statusEl.textContent = 'Waiting for rendering...';
} else {
statusEl.textContent = 'Buffering...';
@@ -256,33 +332,33 @@
});
video.addEventListener('playing', function() {
statusEl.textContent = `Playing (${segmentsLoaded} segments)`;
const modeLabel = currentMode === 'live' ? 'Live' : 'Replay';
statusEl.textContent = `${modeLabel} (${segmentsLoaded} segments)`;
statusEl.classList.remove('text-yellow-400');
statusEl.classList.add('text-green-400');
});
// Periodic check for catching up to live edge
setInterval(function() {
if (hls && !video.paused && hls.levels && hls.levels.length > 0) {
const buffered = video.buffered;
if (buffered.length > 0) {
const bufferEnd = buffered.end(buffered.length - 1);
const currentTime = video.currentTime;
const bufferAhead = bufferEnd - currentTime;
// If less than 4 seconds buffered, show warning
if (bufferAhead < 4) {
statusEl.textContent = 'Waiting for rendering...';
statusEl.classList.remove('text-green-400');
statusEl.classList.add('text-yellow-400');
// Live mode: periodic check for catching up to live edge
if (currentMode === 'live') {
setInterval(function() {
if (hls && !video.paused && hls.levels && hls.levels.length > 0) {
const buffered = video.buffered;
if (buffered.length > 0) {
const bufferEnd = buffered.end(buffered.length - 1);
const bufferAhead = bufferEnd - video.currentTime;
if (bufferAhead < 4) {
statusEl.textContent = 'Waiting for rendering...';
statusEl.classList.remove('text-green-400');
statusEl.classList.add('text-yellow-400');
}
}
}
}
}, 1000);
}, 1000);
}
hls.loadSource(getHlsUrl());
hls.attachMedia(video);
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
// Native HLS support (Safari)
video.src = getHlsUrl();
video.addEventListener('loadedmetadata', function() {
loadingEl.classList.add('hidden');
@@ -295,10 +371,10 @@
}
}
// Start trying to connect
initHls();
// Initialize with appropriate mode
updateModeUI(currentMode);
initHls(currentMode);
// Cleanup on page unload
window.addEventListener('beforeunload', function() {
if (hls) hls.destroy();
});
@@ -644,8 +720,16 @@
class="max-w-full max-h-96 rounded-lg mx-auto">
</a>
{% elif output_media_type and output_media_type.startswith('video/') %}
{# HLS streams use the unified player above; show direct video for non-HLS #}
{% if run.ipfs_playlist_cid %}
<div class="text-gray-400 text-sm py-4">
HLS stream available in player above. Use "From Start" to watch from beginning or "Live Edge" to follow rendering progress.
</div>
{% else %}
{# Direct video file #}
<video src="{% if run.ipfs_cid %}/ipfs/{{ run.ipfs_cid }}{% else %}/cache/{{ run.output_cid }}/raw{% endif %}" controls
class="max-w-full max-h-96 rounded-lg mx-auto"></video>
{% endif %}
{% elif output_media_type and output_media_type.startswith('audio/') %}
<audio src="{% if run.ipfs_cid %}/ipfs/{{ run.ipfs_cid }}{% else %}/cache/{{ run.output_cid }}/raw{% endif %}" controls class="w-full"></audio>
{% else %}
@@ -661,13 +745,21 @@
class="font-mono text-sm text-blue-400 hover:text-blue-300">
{% if run.ipfs_cid %}{{ run.ipfs_cid }}{% else %}{{ run.output_cid }}{% endif %}
</a>
{% if run.ipfs_cid %}
<a href="https://ipfs.io/ipfs/{{ run.ipfs_cid }}"
target="_blank"
class="text-gray-400 hover:text-white text-sm">
View on IPFS Gateway
</a>
{% endif %}
<div class="flex items-center space-x-4">
{% if run.ipfs_playlist_cid %}
<a href="/ipfs/{{ run.ipfs_playlist_cid }}"
class="text-gray-400 hover:text-white text-sm">
HLS Playlist
</a>
{% endif %}
{% if run.ipfs_cid %}
<a href="https://ipfs.io/ipfs/{{ run.ipfs_cid }}"
target="_blank"
class="text-gray-400 hover:text-white text-sm">
View on IPFS Gateway
</a>
{% endif %}
</div>
</div>
</div>
{% endif %}