mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
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:
co-authored by
Claude Opus 4.8
parent
7a6517bfae
commit
6888b83abe
@@ -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'));
|
||||
|
||||
Reference in New Issue
Block a user