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 } 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 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; // ── 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 ?? []; } // ── 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'; } // 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), // so a click or hover isn't interrupted by a full DOM teardown on every ABR // switch (selectedVideoTrackId changes frequently during playback). let videoTrackSetKey = ''; function renderRenditionPicker() { if (!engine || !signals) return; const presentation = engine.state.presentation.get(); const selectedVideoTrackId = engine.state.selectedVideoTrackId.get(); const userFilter = engine.state.userVideoTrackSelection.get(); const tracks = getVideoTracks(presentation); if (tracks.length === 0) { renditionButtonsDiv.textContent = presentation ? 'No video tracks found' : 'Waiting for presentation…'; videoTrackSetKey = ''; return; } // Selection pins by bitrate + resolution (see the click handler), not track // id, so redundant-stream renditions duplicated across CDNs share one // selection identity and are NOT independently selectable. Collapse to one // button per identity so the UI matches what selection actually guarantees. const groups = getVideoSelectionGroups(tracks); const setKey = groups.map((group) => group.key).join('|'); if (setKey !== videoTrackSetKey) { videoTrackSetKey = setKey; buildVideoTrackButtons(groups); } updateVideoTrackSelection(tracks, selectedVideoTrackId, userFilter); } // One selectable video identity: a bitrate + resolution. `key` is exactly what // the click handler pins on (as a partial-track filter), so highlighting and // dedupe share one notion of identity. interface VideoSelectionGroup { key: string; label: string; filter: { bandwidth: number; width?: number; height?: number }; members: string[]; } /** Stable bitrate+resolution identity for a video track (matches the pin filter). */ function videoSelectionKey(track: ReturnType[number]): string { const width = 'width' in track ? track.width : undefined; const height = 'height' in track ? track.height : undefined; return `${track.bandwidth}|${width ?? ''}×${height ?? ''}`; } /** Collapse video tracks to one entry per selection identity (bitrate + resolution). */ function getVideoSelectionGroups(tracks: ReturnType): VideoSelectionGroup[] { const groups = new Map(); for (const track of tracks) { const key = videoSelectionKey(track); let group = groups.get(key); if (!group) { const width = 'width' in track ? track.width : undefined; const height = 'height' in track ? track.height : undefined; const res = width && height ? `${width}×${height} @ ` : ''; const filter: VideoSelectionGroup['filter'] = { bandwidth: track.bandwidth }; if (width) filter.width = width; if (height) filter.height = height; group = { key, label: `${res}${formatBandwidth(track.bandwidth)}`, filter, members: [] }; groups.set(key, group); } // Member ids (one per CDN for redundant streams) are surfaced in the // tooltip so the collapsed renditions are still inspectable. group.members.push(track.id); } return [...groups.values()]; } /** (Re)build the static button list — one per selection group, tagged with its key. */ function buildVideoTrackButtons(groups: VideoSelectionGroup[]) { renditionButtonsDiv.innerHTML = ''; const statusRow = document.createElement('div'); statusRow.id = 'video-status-row'; statusRow.className = 'abr-status'; renditionButtonsDiv.appendChild(statusRow); for (const group of groups) { const btn = document.createElement('button'); btn.type = 'button'; btn.dataset.selectionKey = group.key; // Base label minus the selection badge; the badge is toggled in place. btn.dataset.label = group.label; btn.title = group.members.length > 1 ? `${group.members.length} rendition(s) across CDNs: ${group.members.join(', ')}` : (group.members[0] ?? group.key); btn.addEventListener('click', () => { log(`Manual rendition select: ${JSON.stringify(group.filter)} (ABR disabled)`, 'warning'); signals.state.userVideoTrackSelection.set(group.filter); }); renditionButtonsDiv.appendChild(btn); } } /** Update the status row and per-button selected state without tearing down. */ function updateVideoTrackSelection( tracks: ReturnType, selectedVideoTrackId: string | undefined, userFilter: SimpleHlsEngineState['userVideoTrackSelection'] ) { const isManual = userFilter !== undefined; const statusRow = document.getElementById('video-status-row'); if (statusRow) { statusRow.innerHTML = ''; const modeLabel = document.createElement('span'); modeLabel.className = isManual ? 'mode-manual' : 'mode-abr'; modeLabel.textContent = isManual ? `🔒 Manual: ${JSON.stringify(userFilter)}` : '⟳ ABR'; statusRow.appendChild(modeLabel); if (isManual) { const enableBtn = document.createElement('button'); enableBtn.type = 'button'; enableBtn.className = 'enable-abr-btn'; enableBtn.textContent = 'Enable ABR'; enableBtn.addEventListener('click', () => { log('ABR re-enabled', 'success'); signals.state.userVideoTrackSelection.set(undefined); }); statusRow.appendChild(enableBtn); } } // The selected track belongs to a group keyed by its bitrate + resolution; // highlight that group's button. const selectedTrack = tracks.find((track) => track.id === selectedVideoTrackId); const selectedKey = selectedTrack ? videoSelectionKey(selectedTrack) : undefined; for (const btn of renditionButtonsDiv.querySelectorAll('button[data-selection-key]')) { const isSelected = btn.dataset.selectionKey === selectedKey; btn.className = `rendition-btn${isSelected ? (isManual ? ' selected-manual' : ' selected-abr') : ''}`; const badge = isSelected ? (isManual ? ' 🔒' : ' ⟳') : ''; btn.textContent = `${btn.dataset.label ?? ''}${badge}`; } } // Signature of the currently-rendered audio track set. Lets the picker rebuild // its buttons only when the track set actually changes — selection and pin // changes update existing buttons in place (see renderAudioTrackPicker) so a // click or hover isn't interrupted by a full DOM teardown. let audioTrackSetKey = ''; function renderAudioTrackPicker() { if (!engine || !signals) return; const presentation = engine.state.presentation.get(); const selectedAudioTrackId = engine.state.selectedAudioTrackId.get(); const userFilter = engine.state.userAudioTrackSelection.get(); const tracks = getAudioTracks(presentation); if (tracks.length === 0) { audioTrackButtonsDiv.textContent = presentation ? 'No audio tracks found' : 'Waiting for presentation…'; audioTrackSetKey = ''; return; } // The picker pins by language (see the click handler / track-switching // behavior), so multiple same-language renditions are NOT independently // selectable — clicking the "med" variant still just pins the language. // Collapse to one button per selection identity so the UI matches what // selection actually guarantees. const groups = getAudioSelectionGroups(tracks); const setKey = groups.map((group) => group.key).join('|'); if (setKey !== audioTrackSetKey) { audioTrackSetKey = setKey; buildAudioTrackButtons(groups); } updateAudioTrackSelection(tracks, selectedAudioTrackId, userFilter); } // One selectable audio identity: a language (when present) or a single track id. // `key` is exactly what the click handler pins on, so highlighting and dedupe // share one notion of identity. interface AudioSelectionGroup { key: string; byLanguage: boolean; language?: string | undefined; label: string; members: string[]; } /** Collapse audio tracks to one entry per selection identity (language, else id). */ function getAudioSelectionGroups(tracks: ReturnType): AudioSelectionGroup[] { const groups = new Map(); for (const track of tracks) { const language = track.language || undefined; const key = language ?? track.id; let group = groups.get(key); if (!group) { const name = 'name' in track && track.name ? track.name : track.id; group = { key, byLanguage: !!language, language, label: `${language ?? '—'} · ${name}`, members: [] }; groups.set(key, group); } // Member labels (bitrate, else groupId tier) are surfaced in the tooltip so // the collapsed renditions are still inspectable. const groupId = 'groupId' in track ? track.groupId : undefined; const tier = track.bandwidth ? formatBandwidth(track.bandwidth) : groupId; group.members.push(tier ?? track.id); } return [...groups.values()]; } /** (Re)build the static button list — one per selection group, tagged with its key. */ function buildAudioTrackButtons(groups: AudioSelectionGroup[]) { audioTrackButtonsDiv.innerHTML = ''; const statusRow = document.createElement('div'); statusRow.id = 'audio-status-row'; statusRow.className = 'audio-status'; audioTrackButtonsDiv.appendChild(statusRow); for (const group of groups) { const btn = document.createElement('button'); btn.type = 'button'; btn.dataset.selectionKey = group.key; // Base label minus the selection badge; the badge is toggled in place. btn.dataset.label = group.label; btn.title = group.byLanguage ? `language: ${group.language} · ${group.members.length} rendition(s): ${group.members.join(', ')}` : `id: ${group.key}`; btn.addEventListener('click', () => { const filter = group.byLanguage ? { language: group.language } : { id: group.key }; log( `Audio track filter: ${JSON.stringify(filter)} — mid-stream flush will fire if language differs from buffered`, 'warning' ); signals.state.userAudioTrackSelection.set(filter); }); audioTrackButtonsDiv.appendChild(btn); } } /** Update the status row and per-button selected state without tearing down. */ function updateAudioTrackSelection( tracks: ReturnType, selectedAudioTrackId: string | undefined, userFilter: SimpleHlsEngineState['userAudioTrackSelection'] ) { const isPinned = userFilter !== undefined; const statusRow = document.getElementById('audio-status-row'); if (statusRow) { statusRow.innerHTML = ''; const modeLabel = document.createElement('span'); modeLabel.className = isPinned ? 'mode-pinned' : 'mode-default'; modeLabel.textContent = isPinned ? `🔒 Pinned: ${JSON.stringify(userFilter)}` : '🌐 Default pick'; statusRow.appendChild(modeLabel); if (isPinned) { const clearBtn = document.createElement('button'); clearBtn.type = 'button'; clearBtn.className = 'clear-filter-btn'; clearBtn.textContent = 'Clear filter'; clearBtn.addEventListener('click', () => { log('Cleared userAudioTrackSelection (back to default picker)', 'success'); signals!.state.userAudioTrackSelection.set(undefined); }); statusRow.appendChild(clearBtn); } } // The selected track belongs to a group keyed by its language (else its id); // highlight that group's button. const selectedTrack = tracks.find((track) => track.id === selectedAudioTrackId); const selectedKey = selectedTrack ? selectedTrack.language || selectedTrack.id : undefined; for (const btn of audioTrackButtonsDiv.querySelectorAll('button[data-selection-key]')) { const isSelected = btn.dataset.selectionKey === selectedKey; btn.className = `audio-track-btn${isSelected ? (isPinned ? ' selected-pinned' : ' selected-default') : ''}`; const badge = isSelected ? (isPinned ? ' 🔒' : ' 🌐') : ''; btn.textContent = `${btn.dataset.label ?? ''}${badge}`; } } function renderResolutionStatus() { if (!engine) return; const presentation = engine.state.presentation.get(); const tracks = getVideoTracks(presentation); if (tracks.length === 0) { resolutionListDiv.textContent = presentation ? 'No video tracks found' : 'Waiting for presentation…'; return; } resolutionListDiv.innerHTML = ''; for (const track of tracks) { const isResolved = 'segments' in track; const item = document.createElement('div'); item.className = `resolution-item ${isResolved ? 'resolved' : 'unresolved'}`; const res = 'width' in track && track.width && track.height ? `${track.width}×${track.height} ` : ''; item.textContent = `${isResolved ? '✓' : '○'} ${res}${formatBandwidth(track.bandwidth)}`; item.title = track.id; resolutionListDiv.appendChild(item); } } function inspectState() { if (!engine) { stateDiv.innerHTML = '

State Inspector

Engine not initialized
'; return; } const state = snapshot(engine.state); const ctx = snapshot(engine.context); const videoBufferRanges = ctx.videoBuffer ? Array.from( { length: ctx.videoBuffer.buffered.length }, (_, i) => `Range ${i}: ${ctx.videoBuffer!.buffered.start(i).toFixed(2)}s - ${ctx.videoBuffer!.buffered.end(i).toFixed(2)}s` ).join('\n ') : 'N/A'; const audioBufferRanges = ctx.audioBuffer ? Array.from( { length: ctx.audioBuffer.buffered.length }, (_, i) => `Range ${i}: ${ctx.audioBuffer!.buffered.start(i).toFixed(2)}s - ${ctx.audioBuffer!.buffered.end(i).toFixed(2)}s` ).join('\n ') : 'N/A'; stateDiv.innerHTML = `

State Inspector

Playback State

${JSON.stringify(state, null, 2)}

Context (SourceBuffers)

Video Buffer: ${ctx.videoBuffer ? '✓ Created' : '✗ Not created'}
Audio Buffer: ${ctx.audioBuffer ? '✓ Created' : '✗ Not created'}
${ ctx.videoBuffer ? `

Video Buffer State

Buffered ranges: ${ctx.videoBuffer.buffered.length}
${videoBufferRanges}
` : '' } ${ ctx.audioBuffer ? `

Audio Buffer State

Buffered ranges: ${ctx.audioBuffer.buffered.length}
${audioBufferRanges}
` : '' }

MediaSource State

readyState: ${ctx.mediaSource?.readyState ?? 'N/A'}

Buffer Model (actor context)

Video segments loaded: ${ctx.videoBufferActor?.snapshot.get().context.segments.length ?? 0}
Audio segments loaded: ${ctx.audioBufferActor?.snapshot.get().context.segments.length ?? 0}

Video Element State

readyState: ${video.readyState}
networkState: ${video.networkState}
currentTime: ${video.currentTime.toFixed(2)}s
duration: ${video.duration}s
paused: ${video.paused}
ended: ${video.ended}
`; } // ── Engine lifecycle ─────────────────────────────────────────────────────────── log('=== SPF Segment Loading POC Test ==='); log(`Stream: ${INITIAL_SRC}`); let engine: ReturnType; let signals: SimpleHlsEngineSignals; let cleanupEffects: () => void = () => {}; function startEngine(src: string) { cleanupEffects(); if (engine) engine.destroy(); engine = createSimpleHlsEngine({ initialBandwidth: 1_000_000, onSignalsReady: (refs) => { signals = refs; }, }); (window as any).engine = engine; (window as any).signals = signals; (window as any).state = () => snapshot(engine.state); (window as any).context = () => snapshot(engine.context); // ── Reactive effects ─────────────────────────────────────────────────────── // prev/prevContext track one-time transitions for logging purposes. // They are reset on each startEngine call so a new source logs correctly. const prev = { hasPresentation: false, selectedVideoTrackId: undefined as string | undefined, selectedAudioTrackId: undefined as string | undefined, selectedTextTrackId: undefined as string | undefined, }; const prevContext = { hasMediaSource: false, hasVideoBuffer: false, hasAudioBuffer: false }; // State logger + auto-select first text track const stopStateLogger = effect(() => { const state = snapshot(engine.state); if (state.presentation && !prev.hasPresentation) { log('Presentation resolved'); prev.hasPresentation = true; } // Auto-select first text track when presentation arrives if (state.presentation && !state.selectedTextTrackId && state.presentation.selectionSets) { const textSet = state.presentation.selectionSets.find((s) => s.type === 'text'); const firstText = textSet?.switchingSets?.[0]?.tracks?.[0]; if (firstText) { log(`Auto-selecting text track: ${firstText.id}`); // TODO(stage-d): selectedTextTrackId is the deferred reconciler case — // direct write into composition state until intent/state split lands. engine.state.selectedTextTrackId.set(firstText.id); } } if (state.selectedVideoTrackId && state.selectedVideoTrackId !== prev.selectedVideoTrackId) { const mode = state.userVideoTrackSelection ? '(manual)' : '(ABR)'; log(`Video track selected ${mode}: ${state.selectedVideoTrackId}`); prev.selectedVideoTrackId = state.selectedVideoTrackId; } if (state.selectedAudioTrackId && state.selectedAudioTrackId !== prev.selectedAudioTrackId) { log(`Audio track selected: ${state.selectedAudioTrackId}`); prev.selectedAudioTrackId = state.selectedAudioTrackId; } if (state.selectedTextTrackId && state.selectedTextTrackId !== prev.selectedTextTrackId) { log(`Text track selected: ${state.selectedTextTrackId}`, 'success'); prev.selectedTextTrackId = state.selectedTextTrackId; } }); // One effect per UI region, each auto-tracking only the signals its renderer // reads. The previous single effect snapshotted *all* of engine.state, so // high-frequency fields (currentTime, bandwidthState) re-fired every renderer // many times a second — and the picker's full innerHTML rebuild interrupted // clicks/hover. Now the audio picker re-runs only on presentation / // selectedAudioTrackId / userAudioTrackSelection changes. const stopThroughputUI = effect(() => updateThroughputDisplay()); const stopRenditionUI = effect(() => renderRenditionPicker()); const stopAudioPickerUI = effect(() => renderAudioTrackPicker()); const stopResolutionUI = effect(() => renderResolutionStatus()); // Context logger const stopContextLogger = effect(() => { const ctx = snapshot(engine.context); if (ctx.mediaSource && !prevContext.hasMediaSource) { log(`MediaSource created: ${ctx.mediaSource.readyState}`, 'success'); prevContext.hasMediaSource = true; } if (ctx.videoBuffer && !prevContext.hasVideoBuffer) { log('Video SourceBuffer created', 'success'); prevContext.hasVideoBuffer = true; const origRemove = ctx.videoBuffer.remove.bind(ctx.videoBuffer); ctx.videoBuffer.remove = (start: number, end: number) => { log( `📹 Video SourceBuffer.remove(${start.toFixed(2)}s → ${end === Infinity ? '∞' : end.toFixed(2)}s)`, 'warning' ); return origRemove(start, end); }; ctx.videoBuffer.addEventListener('updateend', () => { const buf = engine.context.videoBuffer.get(); if (!buf) return; const ranges: string[] = []; for (let i = 0; i < buf.buffered.length; i++) { ranges.push(`[${buf.buffered.start(i).toFixed(2)}, ${buf.buffered.end(i).toFixed(2)}]`); } log(`📹 Video buffered: ${ranges.join(' ') || '(empty)'}`, 'info'); }); } if (ctx.audioBuffer && !prevContext.hasAudioBuffer) { log('Audio SourceBuffer created', 'success'); prevContext.hasAudioBuffer = true; const origRemove = ctx.audioBuffer.remove.bind(ctx.audioBuffer); ctx.audioBuffer.remove = (start: number, end: number) => { log( `🔊 Audio SourceBuffer.remove(${start.toFixed(2)}s → ${end === Infinity ? '∞' : end.toFixed(2)}s)`, 'warning' ); return origRemove(start, end); }; ctx.audioBuffer.addEventListener('updateend', () => { const buf = engine.context.audioBuffer.get(); if (!buf) return; const ranges: string[] = []; for (let i = 0; i < buf.buffered.length; i++) { ranges.push(`[${buf.buffered.start(i).toFixed(2)}, ${buf.buffered.end(i).toFixed(2)}]`); } log(`🔊 Audio buffered: ${ranges.join(' ') || '(empty)'}`, 'info'); }); } }); cleanupEffects = () => { stopStateLogger(); stopThroughputUI(); stopRenditionUI(); stopAudioPickerUI(); stopResolutionUI(); stopContextLogger(); }; log('✓ Engine created', 'success'); log('Exposed as window.engine / window.signals / window.state() / window.context()'); log('✓ Reactive effects active', 'success'); // ── Wire media element ────────────────────────────────────────────────────── // Set preload on the element BEFORE wiring context so syncPreload's read // effect picks up the user-selected value rather than the hardcoded "none" // from the HTML. video.preload = preloadSelect.value as 'auto' | 'metadata' | 'none'; signals.context.mediaElement.set(video); signals.state.presentation.set({ url: src }); log('✓ Orchestration started', 'success'); // Auto-inspect periodically setInterval(inspectState, 3000); } try { video.muted = INITIAL_MUTED; video.autoplay = INITIAL_AUTOPLAY; startEngine(INITIAL_SRC); } catch (error) { log(`✗ Error creating engine: ${(error as Error).message}`, 'error'); console.error(error); } // ── Event handlers ──────────────────────────────────────────────────────────── document.getElementById('play')!.addEventListener('click', () => { video .play() .then(() => log('play() succeeded', 'success')) .catch((e) => log(`play() failed: ${e.message}`, 'error')); }); document.getElementById('pause')!.addEventListener('click', () => { video.pause(); log('Video paused'); }); document.getElementById('inspect')!.addEventListener('click', inspectState); document.getElementById('clearLogs')!.addEventListener('click', () => { logsDiv.innerHTML = ''; }); setSrcBtn.addEventListener('click', () => { const url = srcInput.value.trim(); if (!url) return; log(`Setting src: ${url}`, 'info'); startEngine(url); updateShareUrl(); }); srcInput.addEventListener('input', updateShareUrl); mutedToggle.addEventListener('change', () => { video.muted = mutedToggle.checked; log(`Muted: ${mutedToggle.checked}`); updateShareUrl(); }); autoplayToggle.addEventListener('change', () => { video.autoplay = autoplayToggle.checked; log(`Autoplay: ${autoplayToggle.checked}`); updateShareUrl(); }); preloadSelect.addEventListener('change', () => { const value = preloadSelect.value as 'auto' | 'metadata' | 'none'; signals.state.preload.set(value); log(`Preload: ${value}`); updateShareUrl(); }); document.getElementById('open-new-tab')!.addEventListener('click', () => { window.open(shareLink.href, '_blank', 'noopener'); }); // ── Video element events ────────────────────────────────────────────────────── video.addEventListener('timeupdate', updateNowPlayingQuality); video.addEventListener('loadstart', () => log('📺 Video: loadstart')); video.addEventListener('loadedmetadata', () => log('📺 Video: loadedmetadata', 'success')); video.addEventListener('loadeddata', () => log('📺 Video: loadeddata', 'success')); video.addEventListener('canplay', () => log('📺 Video: canplay', 'success')); video.addEventListener('canplaythrough', () => log('📺 Video: canplaythrough', 'success')); video.addEventListener('playing', () => log('📺 Video: playing', 'success')); video.addEventListener('pause', () => log('📺 Video: pause')); video.addEventListener('waiting', () => log('📺 Video: waiting', 'warning')); video.addEventListener('ended', () => log('📺 Video: ended ✅ endOfStream() worked!', 'success')); video.addEventListener('error', () => log(`📺 Video: error - ${video.error?.message}`, 'error'));