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>
</div> </div>
<!-- Live Stream Player (shown during rendering) --> <!-- Unified HLS Player (shown during rendering OR for completed HLS streams) -->
{% if run.status == 'rendering' %} {% if run.status == 'rendering' or run.ipfs_playlist_cid %}
<div id="live-stream-container" class="mb-6 bg-gray-800 rounded-lg p-4"> <div id="hls-player-container" class="mb-6 bg-gray-800 rounded-lg p-4">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold flex items-center"> <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> {% if run.status == 'rendering' %}
Live Preview <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> </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>
<div class="relative bg-black rounded-lg overflow-hidden" style="aspect-ratio: 16/9;"> <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 id="stream-loading" class="absolute inset-0 flex items-center justify-center bg-gray-900/80">
<div class="text-center"> <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> <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>
</div> </div>
<div class="mt-2 text-xs text-gray-500"> <div class="mt-2 flex items-center justify-between text-xs text-gray-500">
Stream URL: <code class="bg-gray-900 px-1 rounded">/runs/{{ run.run_id }}/hls/stream.m3u8</code> <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>
</div> </div>
<script> <script>
(function() { (function() {
const video = document.getElementById('live-video'); const video = document.getElementById('hls-video');
const statusEl = document.getElementById('stream-status'); const statusEl = document.getElementById('stream-status');
const loadingEl = document.getElementById('stream-loading'); 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 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() { function getHlsUrl() {
return baseUrl + '?_t=' + Date.now(); 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 // Custom playlist loader that adds cache-busting to every request
class CacheBustingPlaylistLoader extends Hls.DefaultConfig.loader { class CacheBustingPlaylistLoader extends Hls.DefaultConfig.loader {
@@ -139,60 +167,113 @@
} }
} }
function initHls() { function getHlsConfig(mode) {
if (Hls.isSupported()) { const baseConfig = {
hls = new Hls({ maxBufferLength: 120,
// Custom loader to bust cache on playlist requests 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, 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) function updateModeUI(mode) {
// 10 segments = 40s of buffer before catching up currentMode = mode;
liveSyncDurationCount: 10, // Stay 10 segments behind live edge if (mode === 'live') {
liveMaxLatencyDurationCount: 20, // Allow up to 20 segments behind modeLiveBtn.classList.add('bg-blue-600', 'text-white');
liveDurationInfinity: true, // Treat as infinite live stream 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 window.setPlayerMode = function(mode) {
maxBufferLength: 120, // Buffer up to 120s ahead if (mode === currentMode) return;
maxMaxBufferLength: 180, // Allow even more if needed
maxBufferSize: 100 * 1024 * 1024, // 100MB buffer
maxBufferHole: 0.5, // Tolerate small gaps
// Back buffer for smooth seeking const currentTime = video.currentTime;
backBufferLength: 60, const wasPlaying = !video.paused;
// Playlist reload settings - check frequently for new segments // Destroy current HLS instance
manifestLoadingTimeOut: 10000, if (hls) {
manifestLoadingMaxRetry: 4, hls.destroy();
levelLoadingTimeOut: 10000, hls = null;
levelLoadingMaxRetry: 4, }
fragLoadingTimeOut: 20000,
fragLoadingMaxRetry: 6,
// Start at lowest quality for faster start updateModeUI(mode);
startLevel: 0, segmentsLoaded = 0;
retryCount = 0;
// Enable smooth level switching // Reinitialize with new config
abrEwmaDefaultEstimate: 500000, 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) { hls.on(Hls.Events.MANIFEST_PARSED, function(event, data) {
loadingEl.classList.add('hidden'); loadingEl.classList.add('hidden');
statusEl.textContent = 'Buffering...'; statusEl.textContent = 'Buffering...';
statusEl.classList.remove('text-gray-400'); statusEl.classList.remove('text-gray-400');
statusEl.classList.add('text-yellow-400'); statusEl.classList.add('text-yellow-400');
streamInfoEl.textContent = `${data.levels.length} quality level(s)`;
video.play().catch(() => {}); video.play().catch(() => {});
}); });
hls.on(Hls.Events.FRAG_LOADED, function(event, data) { hls.on(Hls.Events.FRAG_LOADED, function(event, data) {
retryCount = 0; retryCount = 0;
segmentsLoaded++; 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.remove('text-yellow-400', 'text-gray-400');
statusEl.classList.add('text-green-400'); statusEl.classList.add('text-green-400');
}); });
hls.on(Hls.Events.BUFFER_APPENDED, function() { hls.on(Hls.Events.BUFFER_APPENDED, function() {
// Hide loading once we have buffered content
loadingEl.classList.add('hidden'); loadingEl.classList.add('hidden');
}); });
@@ -207,7 +288,6 @@
statusEl.textContent = `Waiting for stream... (${retryCount})`; statusEl.textContent = `Waiting for stream... (${retryCount})`;
statusEl.classList.remove('text-green-400'); statusEl.classList.remove('text-green-400');
statusEl.classList.add('text-yellow-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); const delay = Math.min(1000 * Math.pow(1.5, Math.min(retryCount, 6)), 10000);
setTimeout(() => { setTimeout(() => {
hls.loadSource(getHlsUrl()); hls.loadSource(getHlsUrl());
@@ -227,7 +307,6 @@
break; break;
} }
} else { } else {
// Non-fatal error - just log it
if (data.details === 'bufferStalledError') { if (data.details === 'bufferStalledError') {
statusEl.textContent = 'Buffering...'; statusEl.textContent = 'Buffering...';
statusEl.classList.remove('text-green-400'); 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() { video.addEventListener('waiting', function() {
// Check if we're near the live edge (within 2 segments) if (currentMode === 'live' && hls && hls.liveSyncPosition) {
if (hls && hls.liveSyncPosition) {
const liveEdge = hls.liveSyncPosition; const liveEdge = hls.liveSyncPosition;
const currentTime = video.currentTime; const behindLive = liveEdge - video.currentTime;
const behindLive = liveEdge - currentTime; if (behindLive < 8) {
if (behindLive < 8) { // Less than 2 segments behind
statusEl.textContent = 'Waiting for rendering...'; statusEl.textContent = 'Waiting for rendering...';
} else { } else {
statusEl.textContent = 'Buffering...'; statusEl.textContent = 'Buffering...';
@@ -256,33 +332,33 @@
}); });
video.addEventListener('playing', function() { 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.remove('text-yellow-400');
statusEl.classList.add('text-green-400'); statusEl.classList.add('text-green-400');
}); });
// Periodic check for catching up to live edge // Live mode: periodic check for catching up to live edge
setInterval(function() { if (currentMode === 'live') {
if (hls && !video.paused && hls.levels && hls.levels.length > 0) { setInterval(function() {
const buffered = video.buffered; if (hls && !video.paused && hls.levels && hls.levels.length > 0) {
if (buffered.length > 0) { const buffered = video.buffered;
const bufferEnd = buffered.end(buffered.length - 1); if (buffered.length > 0) {
const currentTime = video.currentTime; const bufferEnd = buffered.end(buffered.length - 1);
const bufferAhead = bufferEnd - currentTime; const bufferAhead = bufferEnd - video.currentTime;
// If less than 4 seconds buffered, show warning if (bufferAhead < 4) {
if (bufferAhead < 4) { statusEl.textContent = 'Waiting for rendering...';
statusEl.textContent = 'Waiting for rendering...'; statusEl.classList.remove('text-green-400');
statusEl.classList.remove('text-green-400'); statusEl.classList.add('text-yellow-400');
statusEl.classList.add('text-yellow-400'); }
} }
} }
} }, 1000);
}, 1000); }
hls.loadSource(getHlsUrl()); hls.loadSource(getHlsUrl());
hls.attachMedia(video); hls.attachMedia(video);
} else if (video.canPlayType('application/vnd.apple.mpegurl')) { } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
// Native HLS support (Safari)
video.src = getHlsUrl(); video.src = getHlsUrl();
video.addEventListener('loadedmetadata', function() { video.addEventListener('loadedmetadata', function() {
loadingEl.classList.add('hidden'); loadingEl.classList.add('hidden');
@@ -295,10 +371,10 @@
} }
} }
// Start trying to connect // Initialize with appropriate mode
initHls(); updateModeUI(currentMode);
initHls(currentMode);
// Cleanup on page unload
window.addEventListener('beforeunload', function() { window.addEventListener('beforeunload', function() {
if (hls) hls.destroy(); if (hls) hls.destroy();
}); });
@@ -644,8 +720,16 @@
class="max-w-full max-h-96 rounded-lg mx-auto"> class="max-w-full max-h-96 rounded-lg mx-auto">
</a> </a>
{% elif output_media_type and output_media_type.startswith('video/') %} {% 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 <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> class="max-w-full max-h-96 rounded-lg mx-auto"></video>
{% endif %}
{% elif output_media_type and output_media_type.startswith('audio/') %} {% 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> <audio src="{% if run.ipfs_cid %}/ipfs/{{ run.ipfs_cid }}{% else %}/cache/{{ run.output_cid }}/raw{% endif %}" controls class="w-full"></audio>
{% else %} {% else %}
@@ -661,13 +745,21 @@
class="font-mono text-sm text-blue-400 hover:text-blue-300"> class="font-mono text-sm text-blue-400 hover:text-blue-300">
{% if run.ipfs_cid %}{{ run.ipfs_cid }}{% else %}{{ run.output_cid }}{% endif %} {% if run.ipfs_cid %}{{ run.ipfs_cid }}{% else %}{{ run.output_cid }}{% endif %}
</a> </a>
{% if run.ipfs_cid %} <div class="flex items-center space-x-4">
<a href="https://ipfs.io/ipfs/{{ run.ipfs_cid }}" {% if run.ipfs_playlist_cid %}
target="_blank" <a href="/ipfs/{{ run.ipfs_playlist_cid }}"
class="text-gray-400 hover:text-white text-sm"> class="text-gray-400 hover:text-white text-sm">
View on IPFS Gateway HLS Playlist
</a> </a>
{% endif %} {% 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>
</div> </div>
{% endif %} {% endif %}