feat(sandbox): add live/DVR diagnostics + seek controls to spf-segment-loading

Adds a Live/DVR panel (stream type, window classification, live gap,
DVR window length, buffer-ahead, presentation anchor + wall-clock) and
seek controls (seek-to-live, -10/-30s, jump to window start) to the SPF
segment-loading harness. All derived sandbox-side from presentation
streamType + the native video; degrades cleanly to on-demand.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2026-06-25 10:55:15 -07:00
co-authored by Claude Opus 4.8
parent 7a6517bfae
commit 6888b83abe
2 changed files with 155 additions and 1 deletions
@@ -168,12 +168,27 @@
#resolution-list { display: flex; flex-wrap: wrap; gap: 8px; }
.resolution-item {
padding: 4px 10px;
padding: 4px 10px;
font-family: monospace; font-size: 11px;border: 1px solid #ccc; border-radius: 4px;
}
.resolution-item.resolved { color: green; border-color: green; }
.resolution-item.unresolved { color: #888; border-color: #aaa; }
/* ── Live / DVR ── */
#live-controls { margin-bottom: 10px; }
#live-controls button:disabled { color: #aaa; cursor: not-allowed; background: #f7f7f7; border-color: #ccc; }
#live-panel {
padding: 12px 15px; margin: 16px 0; font-size: 12px;
background: #f5f5f5; border: 1px solid #ddd; border-radius: 4px;
}
#live-panel h2 { margin-top: 0; font-size: 14px; }
#live-info { display: grid; grid-template-columns: max-content 1fr; gap: 2px 16px; }
#live-info .k { color: #777; }
#live-info .v { color: #0070f3; }
#live-info .edge { font-weight: bold; color: #16a34a; }
#live-info .warn { font-weight: bold; color: #d97706; }
#live-info .muted { color: #888; }
/* ── Logs ── */
#logs-section { margin: 16px 0; }
#logs-section h2 { margin-bottom: 6px; font-size: 14px; }
@@ -236,11 +251,24 @@
<button type="button" id="clearLogs">Clear Logs</button>
</div>
<!-- Live/DVR seek controls — disabled on non-live sources (see renderLivePanel). -->
<div id="live-controls">
<button type="button" id="seek-live">⏭ Seek to live</button>
<button type="button" id="seek-back-10">10s</button>
<button type="button" id="seek-back-30">30s</button>
<button type="button" id="seek-window-start">⏮ Window start</button>
</div>
<!-- biome-ignore lint/a11y/useMediaCaption: test harness, captions managed by SPF -->
<video id="video" controls preload="none"></video>
<div id="now-playing-quality"></div>
<div id="throughput-display"></div>
<div id="live-panel">
<h2>Live / DVR</h2>
<div id="live-info">Waiting for presentation…</div>
</div>
<div id="rendition-picker">
<h2>Video Renditions</h2>
<div id="rendition-buttons">Waiting for presentation…</div>
@@ -28,6 +28,12 @@ const mutedToggle = document.getElementById('muted-toggle') as HTMLInputElement;
const autoplayToggle = document.getElementById('autoplay-toggle') as HTMLInputElement;
const preloadSelect = document.getElementById('preload-select') as HTMLSelectElement;
const shareLink = document.getElementById('share-link') as HTMLAnchorElement;
const liveInfoDiv = document.getElementById('live-info') as HTMLDivElement;
const seekLiveBtn = document.getElementById('seek-live') as HTMLButtonElement;
const seekBack10Btn = document.getElementById('seek-back-10') as HTMLButtonElement;
const seekBack30Btn = document.getElementById('seek-back-30') as HTMLButtonElement;
const seekWindowStartBtn = document.getElementById('seek-window-start') as HTMLButtonElement;
const liveSeekButtons = [seekLiveBtn, seekBack10Btn, seekBack30Btn, seekWindowStartBtn];
// ── Query params ──────────────────────────────────────────────────────────────
const DEFAULT_STREAM = 'https://stream.mux.com/JX01bG8eB4uaoV3OpDuK602rBfvdSgrMObjwuUOBn4JrQ.m3u8';
@@ -142,6 +148,100 @@ function updateThroughputDisplay() {
throughputDiv.className = 'has-data';
}
// ── Live / DVR panel ────────────────────────────────────────────────────────
// All values are derived sandbox-side from the resolved presentation's
// streamType plus the native <video> (seekable / currentTime / buffered). The
// engine mirrors the live window onto video.seekable via setLiveSeekableRange,
// so the seekable range IS the DVR window — no SPF-specific surface needed.
const AT_EDGE_THRESHOLD = 3; // seconds behind live edge still counts as "at edge"
const BEHIND_START_THRESHOLD = 5; // seconds from window start → DVR-exhaustion warning
const DVR_WINDOW_THRESHOLD = 60; // seekable span beyond this reads as DVR vs sliding live
const WINDOW_START_EPSILON = 0.5; // nudge off the exact start so we don't clamp/stall
function seekableEnd(): number {
return video.seekable.length ? video.seekable.end(video.seekable.length - 1) : NaN;
}
function seekableStart(): number {
return video.seekable.length ? video.seekable.start(0) : NaN;
}
function bufferedAhead(): number {
if (!video.buffered.length) return NaN;
return video.buffered.end(video.buffered.length - 1) - video.currentTime;
}
function wallClock(anchor: number, mediaTime: number): string {
return new Date((anchor + mediaTime) * 1000).toLocaleTimeString();
}
function renderLivePanel() {
if (!engine) return;
const presentation = engine.state.presentation.get();
const streamType = presentation?.streamType;
const isLive = streamType === 'live';
// Seek controls only make sense on a live/DVR source.
for (const btn of liveSeekButtons) btn.disabled = !isLive;
if (!presentation) {
liveInfoDiv.textContent = 'Waiting for presentation…';
return;
}
if (!streamType) {
liveInfoDiv.textContent = 'Resolving stream type…';
return;
}
if (!isLive) {
liveInfoDiv.innerHTML =
`<div class="k">stream type</div><div class="v">on-demand</div>` +
`<div class="k">duration</div><div class="v">${video.duration.toFixed(2)}s</div>`;
return;
}
const end = seekableEnd();
const start = seekableStart();
const t = video.currentTime;
const windowLen = end - start;
const liveGap = end - t;
const fromStart = t - start;
const anchor = engine.state.presentationAnchor.get();
const windowKind = !Number.isFinite(windowLen)
? 'unknown'
: windowLen >= DVR_WINDOW_THRESHOLD
? `DVR (${windowLen.toFixed(0)}s seekable)`
: 'sliding live';
const rows: [string, string, string?][] = [
['stream type', 'live'],
['window', windowKind],
['seekable', Number.isFinite(start) ? `${start.toFixed(2)} ${end.toFixed(2)}` : 'none'],
['window length', Number.isFinite(windowLen) ? `${windowLen.toFixed(2)}s` : '—'],
['currentTime', t.toFixed(2)],
[
'live gap',
Number.isFinite(liveGap) ? `${liveGap.toFixed(2)}s ${liveGap <= AT_EDGE_THRESHOLD ? '(at edge)' : ''}` : '—',
liveGap <= AT_EDGE_THRESHOLD ? 'edge' : undefined,
],
[
'from window start',
Number.isFinite(fromStart) ? `${fromStart.toFixed(2)}s` : '—',
Number.isFinite(fromStart) && fromStart < BEHIND_START_THRESHOLD ? 'warn' : undefined,
],
['buffer ahead', Number.isFinite(bufferedAhead()) ? `${bufferedAhead().toFixed(2)}s` : '—'],
['duration', String(video.duration)],
[
'anchor (epoch s)',
anchor === undefined ? 'not yet established' : anchor.toFixed(2),
anchor === undefined ? 'muted' : undefined,
],
['playhead clock', anchor === undefined ? '—' : wallClock(anchor, t)],
['live edge clock', anchor === undefined || !Number.isFinite(end) ? '—' : wallClock(anchor, end)],
];
liveInfoDiv.innerHTML = rows
.map(([k, v, cls]) => `<div class="k">${k}</div><div class="v${cls ? ` ${cls}` : ''}">${v}</div>`)
.join('');
}
// Signature of the currently-rendered video rendition set. Lets the picker
// rebuild its buttons only when the set actually changes — selection and ABR/
// manual changes update existing buttons in place (see renderRenditionPicker),
@@ -813,6 +913,32 @@ document.getElementById('open-new-tab')!.addEventListener('click', () => {
window.open(shareLink.href, '_blank', 'noopener');
});
// ── Live / DVR seek controls ────────────────────────────────────────────────
// Pure DOM seeks against the native seekable range. The browser clamps to the
// window, and the engine's window-exit guard repositions only if we land before
// the start — so back-seeks that stay inside [start, end] hold.
function seekTo(target: number, label: string) {
if (!Number.isFinite(target)) {
log(`${label}: seekable range not available yet`, 'warning');
return;
}
video.currentTime = target;
log(`${label}${target.toFixed(2)}s`, 'info');
renderLivePanel();
}
seekLiveBtn.addEventListener('click', () => seekTo(seekableEnd(), 'Seek to live'));
seekBack10Btn.addEventListener('click', () => seekTo(Math.max(seekableStart(), video.currentTime - 10), 'Seek 10s'));
seekBack30Btn.addEventListener('click', () => seekTo(Math.max(seekableStart(), video.currentTime - 30), 'Seek 30s'));
seekWindowStartBtn.addEventListener('click', () =>
seekTo(seekableStart() + WINDOW_START_EPSILON, 'Seek to window start')
);
// Live values change every frame — poll, and refresh immediately on seek/range
// changes so the panel doesn't lag a tick behind a button press.
setInterval(renderLivePanel, 250);
video.addEventListener('seeked', renderLivePanel);
video.addEventListener('durationchange', renderLivePanel);
// ── Video element events ──────────────────────────────────────────────────────
video.addEventListener('timeupdate', updateNowPlayingQuality);
video.addEventListener('loadstart', () => log('📺 Video: loadstart'));