import '@app/styles.css'; // SPF Segment Loading POC Test // http://localhost:5173/spf-segment-loading/ // // Supported query params: // src= Stream URL (overrides TEST_STREAM default) // muted=true Start muted // autoplay=true Start with autoplay enabled // preload=auto|metadata|none Initial preload mode import { effect, snapshot } from '@videojs/spf'; import type { SimpleHlsEngineSignals, SimpleHlsEngineState } from '@videojs/spf/hls'; import { createSimpleHlsEngine, getMediaPlaylistMetadata } from '@videojs/spf/hls'; // ── DOM refs ────────────────────────────────────────────────────────────────── const video = document.getElementById('video') as HTMLVideoElement; const logsDiv = document.getElementById('logs') as HTMLDivElement; const stateDiv = document.getElementById('state') as HTMLDivElement; const renditionButtonsDiv = document.getElementById('rendition-buttons') as HTMLDivElement; const audioTrackButtonsDiv = document.getElementById('audio-track-buttons') as HTMLDivElement; const textTrackButtonsDiv = document.getElementById('text-track-buttons') as HTMLDivElement; const resolutionListDiv = document.getElementById('resolution-list') as HTMLDivElement; const nowPlayingQualityDiv = document.getElementById('now-playing-quality') as HTMLDivElement; const throughputDiv = document.getElementById('throughput-display') as HTMLDivElement; const srcInput = document.getElementById('src-input') as HTMLInputElement; const setSrcBtn = document.getElementById('set-src') as HTMLButtonElement; 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'; const params = new URLSearchParams(window.location.search); const INITIAL_SRC = params.get('src') ?? DEFAULT_STREAM; const INITIAL_MUTED = params.get('muted') === 'true'; const INITIAL_AUTOPLAY = params.get('autoplay') === 'true'; const INITIAL_PRELOAD = (params.get('preload') as 'auto' | 'metadata' | 'none') ?? 'none'; // Apply initial query-param values to UI srcInput.value = INITIAL_SRC; mutedToggle.checked = INITIAL_MUTED; autoplayToggle.checked = INITIAL_AUTOPLAY; preloadSelect.value = INITIAL_PRELOAD; updateShareUrl(); // ── Helpers ─────────────────────────────────────────────────────────────────── function log(msg: string, type: 'info' | 'success' | 'error' | 'warning' = 'info') { const timestamp = new Date().toLocaleTimeString(); console.log(`[${timestamp}] ${msg}`); const div = document.createElement('div'); div.className = type; div.textContent = `[${timestamp}] ${msg}`; logsDiv.appendChild(div); logsDiv.scrollTop = logsDiv.scrollHeight; } function formatBandwidth(bps: number): string { if (bps >= 1_000_000) return `${(bps / 1_000_000).toFixed(1)} Mbps`; return `${Math.round(bps / 1000)} Kbps`; } function getVideoTracks(presentation: SimpleHlsEngineState['presentation']) { return presentation?.selectionSets?.find((s) => s.type === 'video')?.switchingSets[0]?.tracks ?? []; } function getAudioTracks(presentation: SimpleHlsEngineState['presentation']) { return presentation?.selectionSets?.find((s) => s.type === 'audio')?.switchingSets[0]?.tracks ?? []; } function getTextTracks(presentation: SimpleHlsEngineState['presentation']) { return presentation?.selectionSets?.find((s) => s.type === 'text')?.switchingSets[0]?.tracks ?? []; } // Drive the *native* TextTrack modes — what a captions button / browser UI // touches — so a user selection flows through the syncTextTracks DOM→intent // bridge (change event → userTextTrackSelection) rather than writing the SPF // signal directly. `showId === undefined` disables all (Off). function setNativeTextMode(showId: string | undefined) { const tt = video.textTracks; for (let i = 0; i < tt.length; i++) { const track = tt[i]; if (!track || (track.kind !== 'subtitles' && track.kind !== 'captions')) continue; track.mode = track.id === showId ? 'showing' : 'disabled'; } } // ── Display functions ───────────────────────────────────────────────────────── function updateShareUrl() { const p = new URLSearchParams(); const src = srcInput.value.trim(); if (src && src !== DEFAULT_STREAM) p.set('src', src); if (mutedToggle.checked) p.set('muted', 'true'); if (autoplayToggle.checked) p.set('autoplay', 'true'); if (preloadSelect.value !== 'none') p.set('preload', preloadSelect.value); const url = `${window.location.origin}${window.location.pathname}${p.size > 0 ? `?${p}` : ''}`; shareLink.href = url; shareLink.textContent = url; } function updateNowPlayingQuality() { if (!engine) return; const segments = engine.context.videoBufferActor.get()?.snapshot.get().context.segments ?? []; const t = video.currentTime; const current = segments.find((s) => t >= s.startTime && t < s.startTime + s.duration); if (current?.trackBandwidth) { nowPlayingQualityDiv.textContent = `▶ Now playing: ${formatBandwidth(current.trackBandwidth)}`; nowPlayingQualityDiv.className = 'has-quality'; } else { nowPlayingQualityDiv.textContent = ''; nowPlayingQualityDiv.className = ''; } } // Mirrors getBandwidthEstimate logic from bandwidth-estimator.ts. // Raw fastEstimate/slowEstimate start near-zero because EWMA initialises at 0; // zero-factor correction scales them back to the true estimate. function correctedEstimate(estimate: number, totalWeight: number, halfLife: number): number { if (totalWeight === 0) return 0; const alpha = Math.exp(Math.log(0.5) / halfLife); return estimate / (1 - alpha ** totalWeight); } function updateThroughputDisplay() { if (!engine) return; const bs = engine.state.bandwidthState.get(); if (!bs || bs.bytesSampled === 0) { throughputDiv.textContent = '📶 Throughput: no samples yet'; throughputDiv.className = ''; return; } const minBytes = 128_000; if (bs.bytesSampled < minBytes) { throughputDiv.textContent = `📶 Warming up: ${(bs.bytesSampled / 1000).toFixed(0)} KB sampled`; throughputDiv.className = 'warming'; return; } const fast = correctedEstimate(bs.fastEstimate, bs.fastTotalWeight, 2); const slow = correctedEstimate(bs.slowEstimate, bs.slowTotalWeight, 5); const est = Math.min(fast, slow); throughputDiv.textContent = `📶 Est: ${formatBandwidth(est)} (fast: ${formatBandwidth(fast)}, slow: ${formatBandwidth(slow)})`; throughputDiv.className = 'has-data'; } // ── Live / DVR panel ──────────────────────────────────────────────────────── // All values are derived sandbox-side from the resolved presentation's // streamType plus the native