A viewer for AtmosphereConf 2026 talks with fixed routes you can link to
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Replace manual MSE + sync loop with hls.js for video playback

Use hls.js on the video-only playlist (track=1) for fast video start,
while fetching the single-segment audio blob in the background. Removes
~120 lines of manual M3U8 parsing, byte-range fetching, MediaSource
buffer management, and the fragile playbackRate-based audio sync loop
that caused crackling and drift. Audio now just seeks once to match
video position and plays without continuous correction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+128 -250
+128 -250
public/index.html
··· 321 321 } 322 322 .loading-indicator.visible { opacity: 1; } 323 323 324 - /* Muted indicator — shows while audio is loading */ 324 + /* Muted indicator */ 325 325 .muted-indicator { 326 326 position: absolute; 327 327 bottom: 16px; left: 16px; ··· 347 347 <div class="tv"> 348 348 <div class="screen-bezel"> 349 349 <div class="screen" id="screen"> 350 - <video id="video" playsinline muted></video> 350 + <video id="video" playsinline></video> 351 351 <audio id="audio" preload="none"></audio> 352 352 <div class="screen-glare"></div> 353 353 <div class="static-overlay" id="static"><canvas id="staticCanvas"></canvas></div> ··· 382 382 </div> 383 383 </div> 384 384 385 + <script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script> 385 386 <script> 386 387 (function() { 387 388 'use strict'; ··· 405 406 const loadingIndicator = document.getElementById('loadingIndicator'); 406 407 const mutedIndicator = document.getElementById('mutedIndicator'); 407 408 408 - // ----------------------------------------------------------------------- 409 - // Playback architecture notes: 410 - // 411 - // The Streamplace VOD API serves each recording as a single large MP4 blob 412 - // (often 1-2GB), with an HLS playlist that maps byte-range segments into it. 413 - // Video is split into ~2-3s segments; audio is a SINGLE segment covering 414 - // the entire talk duration (10-30MB). 415 - // 416 - // We can't use hls.js because: 417 - // 1. hls.js's startPosition / startLoad() don't reliably seek into 418 - // byte-range playlists — it often loads from segment 0 regardless. 419 - // 2. The single-segment audio track blocks MSE playback until the entire 420 - // audio chunk is downloaded, so video won't render until audio is ready. 421 - // 422 - // We can't rely on HTTP Range requests because: 423 - // The CDN (Cloudflare) intermittently ignores Range headers and returns 424 - // the full blob (status 200 instead of 206). This seems to depend on 425 - // edge cache state — some videos work, others don't. 426 - // 427 - // So instead we: parse the m3u8 ourselves, compute the target segment for 428 - // a random seek position, fetch just that segment's bytes via Range, and 429 - // feed it directly to MSE. If Range fails (200 instead of 206), we abort 430 - // and try a different video — this fits the channel-surfing metaphor. 431 - // 432 - // Audio is loaded separately in the background: fetch the single audio 433 - // segment, wrap it in a blob URL, and play it in a synced <audio> element. 434 - // MSE can't be used for audio because the segment often exceeds the 435 - // browser's source buffer quota. The blob must use type "video/mp4" (not 436 - // "audio/mp4") for the browser to recognise the fMP4 container. 437 - // ----------------------------------------------------------------------- 438 - 439 409 let vods = []; 440 410 let currentChannel = -1; 441 411 let gen = 0; 442 412 let overlayTimeout = null; 443 413 let switching = false; 414 + let hls = null; 444 415 let staticCtx = staticCanvas.getContext('2d'); 445 416 let staticAnimFrame = null; 446 417 ··· 455 426 staticAudioCtx = new (window.AudioContext || window.webkitAudioContext)(); 456 427 } 457 428 if (staticAudioCtx.state === 'suspended') staticAudioCtx.resume(); 458 - if (staticNoiseNode) return; // already playing 429 + if (staticNoiseNode) return; 459 430 460 - // Create white noise buffer (1 second, looped) 461 431 const bufferSize = staticAudioCtx.sampleRate; 462 432 const noiseBuffer = staticAudioCtx.createBuffer(1, bufferSize, staticAudioCtx.sampleRate); 463 433 const data = noiseBuffer.getChannelData(0); 464 - for (let i = 0; i < bufferSize; i++) { 465 - data[i] = Math.random() * 2 - 1; 466 - } 434 + for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1; 467 435 468 436 staticNoiseNode = staticAudioCtx.createBufferSource(); 469 437 staticNoiseNode.buffer = noiseBuffer; 470 438 staticNoiseNode.loop = true; 471 439 472 440 staticGainNode = staticAudioCtx.createGain(); 473 - staticGainNode.gain.value = 0.015; // barely there 441 + staticGainNode.gain.value = 0.015; 474 442 475 443 staticNoiseNode.connect(staticGainNode); 476 444 staticGainNode.connect(staticAudioCtx.destination); 477 445 staticNoiseNode.start(); 478 - } catch (e) { 479 - // Audio context not available — fine, just skip 480 - } 446 + } catch (e) {} 481 447 } 482 448 483 449 function stopStaticAudio() { 484 450 if (staticNoiseNode) { 485 451 try { 486 - // Quick fade out to avoid click 487 - if (staticGainNode) { 488 - staticGainNode.gain.setTargetAtTime(0, staticAudioCtx.currentTime, 0.02); 489 - } 452 + if (staticGainNode) staticGainNode.gain.setTargetAtTime(0, staticAudioCtx.currentTime, 0.02); 490 453 setTimeout(() => { 491 454 try { staticNoiseNode.stop(); } catch (e) {} 492 455 staticNoiseNode = null; ··· 510 473 cursor = data.cursor; 511 474 } while (cursor); 512 475 513 - // Filter out denied VODs (ones known to not support Range requests) 514 476 try { 515 477 const text = await (await fetch('/denied-vods.json')).text(); 516 478 const denied = JSON.parse(text.replace(/\/\/.*$/gm, '')); ··· 518 480 const before = all.length; 519 481 all = all.filter(r => !denySet.has(r.uri)); 520 482 if (before !== all.length) console.log(`[TV] Filtered out ${before - all.length} denied VODs`); 521 - } catch (e) { 522 - // No deny list — that's fine, use all VODs 523 - } 483 + } catch (e) {} 524 484 525 485 return all; 526 486 } 527 487 528 - // --- Parse HLS playlist --- 488 + // --- Parse HLS playlist (for audio track only) --- 529 489 function parsePlaylist(text) { 530 490 const lines = text.split('\n'); 531 491 let initUri = null; ··· 550 510 return { initUri, segments }; 551 511 } 552 512 553 - function segmentAt(segments, time) { 554 - let t = 0; 555 - for (let i = 0; i < segments.length; i++) { 556 - t += segments[i].duration; 557 - if (t > time) return i; 558 - } 559 - return segments.length - 1; 560 - } 561 - 562 - function segmentTime(segments, idx) { 563 - let t = 0; 564 - for (let i = 0; i < idx; i++) t += segments[i].duration; 565 - return t; 566 - } 567 - 568 513 function resolveUrl(relative) { 569 514 return `${PLAYBACK}/${relative}`; 570 - } 571 - 572 - // Fetch a byte range from the blob. Throws RANGE_NOT_SUPPORTED if the CDN 573 - // returns the full file (see architecture notes above). 574 - async function fetchRange(url, offset, length, signal) { 575 - const end = offset + length - 1; 576 - const res = await fetch(url, { 577 - headers: { Range: `bytes=${offset}-${end}` }, 578 - cache: 'no-store', 579 - signal, 580 - }); 581 - if (res.status === 200) { 582 - res.body?.cancel(); 583 - throw new Error('RANGE_NOT_SUPPORTED'); 584 - } 585 - if (res.status !== 206) throw new Error(`HTTP ${res.status}`); 586 - return res.arrayBuffer(); 587 515 } 588 516 589 517 // --- Static noise --- ··· 652 580 showChannelInfo(idx + 1, title, uri); 653 581 654 582 // Kill previous playback 583 + if (hls) { hls.destroy(); hls = null; } 655 584 video.pause(); video.removeAttribute('src'); video.load(); 656 585 audioEl.pause(); audioEl.removeAttribute('src'); 657 586 ··· 661 590 const randomStart = durationSec > 30 ? Math.random() * durationSec * 0.8 : 0; 662 591 console.log('[TV] Target time:', Math.round(randomStart) + 's'); 663 592 664 - try { 665 - // Fetch video-only playlist (track=1). We avoid the master playlist because 666 - // it includes the audio track, which would block playback (see notes above). 667 - const videoPlaylistUrl = `${PLAYBACK}/place.stream.playback.getVideoPlaylist?uri=${encodedUri}&track=1`; 668 - const plText = await (await fetch(videoPlaylistUrl)).text(); 669 - if (stale()) { switching = false; return; } 670 - const pl = parsePlaylist(plText); 671 - 672 - const segIdx = segmentAt(pl.segments, randomStart); 673 - const seg = pl.segments[segIdx]; 674 - const segStartTime = segmentTime(pl.segments, segIdx); 675 - const blobUrl = resolveUrl(seg.uri); 676 - const initUrl = resolveUrl(pl.initUri); 593 + // --- Video: hls.js on video-only playlist (track=1) for fast start --- 594 + const videoOnlyUrl = `${PLAYBACK}/place.stream.playback.getVideoPlaylist?uri=${encodedUri}&track=1`; 677 595 678 - console.log(`[TV] Segment ${segIdx}: ${seg.offset}-${seg.offset + seg.length} (${(seg.length/1024/1024).toFixed(1)}MB) at ${segStartTime.toFixed(1)}s`); 679 - 680 - // Fetch init (~1KB) + one video segment (~3MB) — all we need to start 681 - const [initBuf, segBuf] = await Promise.all([ 682 - fetch(initUrl, { cache: 'no-store' }).then(r => r.arrayBuffer()), 683 - fetchRange(blobUrl, seg.offset, seg.length, null), 684 - ]); 685 - if (stale()) { switching = false; return; } 686 - 687 - console.log('[TV] Init + first segment fetched'); 688 - 689 - const ms = new MediaSource(); 690 - video.src = URL.createObjectURL(ms); 691 - await new Promise(r => ms.addEventListener('sourceopen', r, { once: true })); 692 - if (stale()) { switching = false; return; } 693 - 694 - const codec = 'video/mp4; codecs="avc1.42c01f"'; 695 - const sb = ms.addSourceBuffer(codec); 696 - 697 - sb.appendBuffer(initBuf); 698 - await new Promise(r => sb.addEventListener('updateend', r, { once: true })); 699 - 700 - // Offset timestamps so the segment plays from time=0 in the video element 701 - sb.timestampOffset = -segStartTime; 596 + if (!Hls.isSupported()) { 597 + // Safari — native HLS with full playlist 598 + const playlistUrl = `${PLAYBACK}/place.stream.playback.getVideoPlaylist?uri=${encodedUri}`; 599 + video.src = playlistUrl; 600 + video.currentTime = randomStart; 601 + try { 602 + await video.play(); 603 + stopStatic(); 604 + loadingIndicator.classList.remove('visible'); 605 + } catch (e) { 606 + console.warn('[TV] Playback error:', e); 607 + stopStatic(); 608 + loadingIndicator.classList.remove('visible'); 609 + } 610 + switching = false; 611 + return; 612 + } 702 613 703 - sb.appendBuffer(segBuf); 704 - await new Promise(r => sb.addEventListener('updateend', r, { once: true })); 705 - if (stale()) { switching = false; return; } 614 + hls = new Hls({ 615 + startPosition: randomStart, 616 + maxBufferLength: 30, 617 + maxMaxBufferLength: 60, 618 + }); 706 619 707 - console.log('[TV] Buffer ready, playing'); 620 + hls.loadSource(videoOnlyUrl); 621 + hls.attachMedia(video); 708 622 623 + // Play as soon as manifest is parsed 624 + hls.on(Hls.Events.MANIFEST_PARSED, () => { 625 + if (stale()) return; 626 + console.log('[TV] Video manifest parsed'); 709 627 video.muted = true; 710 - await video.play(); 628 + video.play().catch(e => console.warn('[TV] Play failed:', e)); 629 + }); 630 + 631 + // Hide static once actual frames are rendering 632 + const onPlaying = () => { 633 + if (stale()) return; 634 + video.removeEventListener('playing', onPlaying); 635 + console.log('[TV] Video playing!'); 711 636 stopStatic(); 712 637 loadingIndicator.classList.remove('visible'); 713 - mutedIndicator.classList.remove('fade-out'); 714 638 mutedIndicator.classList.add('visible'); 715 - console.log('[TV] Playing!'); 639 + }; 640 + video.addEventListener('playing', onPlaying); 716 641 717 - // Buffer ahead in the background (lazy — only when buffer drops below 20s) 718 - (async () => { 719 - for (let i = segIdx + 1; i < pl.segments.length && i < segIdx + 60; i++) { 720 - if (stale()) return; 721 - // Wait if we have plenty buffered 722 - while (video.buffered.length > 0 && 723 - video.buffered.end(0) - video.currentTime > 20) { 724 - await new Promise(r => setTimeout(r, 2000)); 725 - if (stale()) return; 726 - } 727 - const s = pl.segments[i]; 728 - try { 729 - const buf = await fetchRange(resolveUrl(s.uri), s.offset, s.length); 730 - if (stale()) return; 731 - if (sb.updating) await new Promise(r => sb.addEventListener('updateend', r, { once: true })); 732 - sb.appendBuffer(buf); 733 - await new Promise(r => sb.addEventListener('updateend', r, { once: true })); 734 - } catch (e) { 735 - console.warn('[TV] Segment error:', e); 736 - break; 737 - } 642 + hls.on(Hls.Events.ERROR, (event, data) => { 643 + if (stale()) return; 644 + console.warn('[TV] HLS error:', data.type, data.details, data.fatal); 645 + if (data.fatal) { 646 + if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { 647 + hls.recoverMediaError(); 648 + } else { 649 + hls.destroy(); hls = null; 650 + switching = false; 651 + changeChannel(); 738 652 } 739 - })(); 653 + } 654 + }); 740 655 741 - // Load audio in background. Audio is a separate <audio> element synced to 742 - // the video because it can't share the video's MediaSource (see notes). 743 - const audioSegStartTime = segStartTime; 744 - (async () => { 745 - try { 746 - const audioPlaylistUrl = `${PLAYBACK}/place.stream.playback.getVideoPlaylist?uri=${encodedUri}&track=2`; 747 - const audioText = await (await fetch(audioPlaylistUrl)).text(); 748 - if (stale()) return; 749 - const audioPl = parsePlaylist(audioText); 750 - if (!audioPl.segments.length) return; 751 - const audioSeg = audioPl.segments[0]; 752 - const audioInitUrl = resolveUrl(audioPl.initUri); 753 - const audioBlobUrl = resolveUrl(audioSeg.uri); 656 + // --- Audio: fetch in background, sync to video --- 657 + // The audio track is a single giant fMP4 segment. We fetch it separately 658 + // and play it in a synced <audio> element so video isn't blocked. 659 + (async () => { 660 + try { 661 + const audioPlaylistUrl = `${PLAYBACK}/place.stream.playback.getVideoPlaylist?uri=${encodedUri}&track=2`; 662 + const audioText = await (await fetch(audioPlaylistUrl)).text(); 663 + if (stale()) return; 664 + const audioPl = parsePlaylist(audioText); 665 + if (!audioPl.segments.length) return; 754 666 755 - console.log(`[TV] Loading audio: ${(audioSeg.length/1024/1024).toFixed(1)}MB at offset ${audioSeg.offset}`); 667 + const audioSeg = audioPl.segments[0]; 668 + const audioInitUrl = resolveUrl(audioPl.initUri); 669 + const audioBlobUrl = resolveUrl(audioSeg.uri); 756 670 757 - const audioInit = await fetch(audioInitUrl, { cache: 'no-store' }).then(r => r.arrayBuffer()); 758 - if (stale()) return; 671 + console.log(`[TV] Loading audio: ${(audioSeg.length/1024/1024).toFixed(1)}MB`); 759 672 760 - let audioData; 761 - const end = audioSeg.offset + audioSeg.length - 1; 762 - const rangeRes = await fetch(audioBlobUrl, { 763 - headers: { Range: `bytes=${audioSeg.offset}-${end}` }, 764 - cache: 'no-store', 765 - }); 766 - if (rangeRes.status === 206) { 767 - console.log('[TV] Audio: range worked'); 768 - audioData = await rangeRes.arrayBuffer(); 769 - } else { 770 - console.log('[TV] Audio: downloading full blob to slice...'); 771 - const full = await rangeRes.arrayBuffer(); 772 - if (stale()) return; 773 - audioData = full.slice(audioSeg.offset, audioSeg.offset + audioSeg.length); 774 - } 775 - if (stale()) return; 673 + const audioInit = await fetch(audioInitUrl, { cache: 'no-store' }).then(r => r.arrayBuffer()); 674 + if (stale()) return; 776 675 777 - console.log(`[TV] Audio data: ${(audioData.byteLength/1024/1024).toFixed(1)}MB, creating blob`); 676 + const end = audioSeg.offset + audioSeg.length - 1; 677 + const rangeRes = await fetch(audioBlobUrl, { 678 + headers: { Range: `bytes=${audioSeg.offset}-${end}` }, 679 + cache: 'no-store', 680 + }); 778 681 779 - // Blob must be "video/mp4" not "audio/mp4" — browsers need the full 780 - // fMP4 container type to parse the init segment's moov atom correctly. 781 - const blob = new Blob([audioInit, audioData], { type: 'video/mp4' }); 782 - audioEl.src = URL.createObjectURL(blob); 682 + let audioData; 683 + if (rangeRes.status === 206) { 684 + audioData = await rangeRes.arrayBuffer(); 685 + } else { 686 + const full = await rangeRes.arrayBuffer(); 687 + if (stale()) return; 688 + audioData = full.slice(audioSeg.offset, audioSeg.offset + audioSeg.length); 689 + } 690 + if (stale()) return; 783 691 784 - // Wait for browser to parse the fMP4. Neither loadedmetadata nor canplay 785 - // fires reliably for all fMP4 blobs, so we also use a 3s timeout. 786 - await new Promise((resolve, reject) => { 787 - const onReady = () => { cleanup(); resolve(); }; 788 - const onError = () => { cleanup(); reject(audioEl.error); }; 789 - const cleanup = () => { 790 - audioEl.removeEventListener('loadedmetadata', onReady); 791 - audioEl.removeEventListener('canplay', onReady); 792 - audioEl.removeEventListener('error', onError); 793 - }; 794 - audioEl.addEventListener('loadedmetadata', onReady, { once: true }); 795 - audioEl.addEventListener('canplay', onReady, { once: true }); 796 - audioEl.addEventListener('error', onError, { once: true }); 797 - setTimeout(resolve, 3000); 798 - }); 799 - if (stale()) return; 692 + console.log(`[TV] Audio fetched: ${(audioData.byteLength/1024/1024).toFixed(1)}MB`); 800 693 801 - console.log('[TV] Audio loaded, readyState=' + audioEl.readyState, 'duration=' + audioEl.duration); 802 - audioEl.currentTime = audioSegStartTime + video.currentTime; 803 - console.log('[TV] Audio seeking to', audioEl.currentTime.toFixed(1) + 's'); 694 + const blob = new Blob([audioInit, audioData], { type: 'video/mp4' }); 695 + audioEl.src = URL.createObjectURL(blob); 804 696 805 - // Fade audio in over 1.5s — less jarring than a hard cut 806 - audioEl.volume = 0; 807 - audioEl.play().catch(e => console.warn('[TV] Audio play failed:', e)); 808 - const fadeStart = performance.now(); 809 - const fadeDuration = 1500; 810 - (function fadeIn() { 811 - const elapsed = performance.now() - fadeStart; 812 - audioEl.volume = Math.min(1, elapsed / fadeDuration); 813 - if (elapsed < fadeDuration) requestAnimationFrame(fadeIn); 814 - })(); 697 + await new Promise((resolve, reject) => { 698 + const done = () => { audioEl.removeEventListener('canplay', done); audioEl.removeEventListener('error', fail); resolve(); }; 699 + const fail = () => { audioEl.removeEventListener('canplay', done); audioEl.removeEventListener('error', fail); reject(audioEl.error); }; 700 + audioEl.addEventListener('canplay', done, { once: true }); 701 + audioEl.addEventListener('error', fail, { once: true }); 702 + setTimeout(resolve, 3000); 703 + }); 704 + if (stale()) return; 815 705 816 - // Hide muted indicator 817 - mutedIndicator.classList.add('fade-out'); 818 - mutedIndicator.classList.remove('visible'); 819 - } catch (e) { 820 - console.warn('[TV] Audio load failed (non-fatal):', e); 821 - } 822 - })(); 706 + // Seek audio to match video position, wait for seek, then play 707 + audioEl.currentTime = video.currentTime; 708 + console.log('[TV] Audio seeking to', video.currentTime.toFixed(1) + 's'); 823 709 824 - // Keep audio synced to video via RAF (~60fps instead of ~4fps timeupdate) 825 - (function syncLoop() { 710 + await new Promise(r => { 711 + if (!audioEl.seeking) { r(); return; } 712 + audioEl.addEventListener('seeked', r, { once: true }); 713 + setTimeout(r, 2000); 714 + }); 826 715 if (stale()) return; 827 - if (audioEl.readyState >= 2 && !audioEl.paused && !video.paused) { 828 - const expected = audioSegStartTime + video.currentTime; 829 - const drift = audioEl.currentTime - expected; 830 - if (Math.abs(drift) > 0.5) { 831 - // Large drift — hard seek 832 - audioEl.currentTime = expected; 833 - audioEl.playbackRate = 1; 834 - } else if (Math.abs(drift) > 0.05) { 835 - // Small drift — nudge playback rate to catch up/slow down 836 - audioEl.playbackRate = drift > 0 ? 0.97 : 1.03; 837 - } else { 838 - audioEl.playbackRate = 1; 839 - } 840 - } 841 - requestAnimationFrame(syncLoop); 842 - })(); 716 + 717 + audioEl.volume = 0; 718 + await audioEl.play(); 719 + const fadeStart = performance.now(); 720 + (function fadeIn() { 721 + const t = Math.min(1, (performance.now() - fadeStart) / 1500); 722 + audioEl.volume = t; 723 + if (t < 1) requestAnimationFrame(fadeIn); 724 + })(); 843 725 844 - } catch (e) { 845 - if (e.message === 'RANGE_NOT_SUPPORTED') { 846 - console.warn('[TV] Range not supported for this video, trying another...'); 847 - switching = false; 848 - changeChannel(); 849 - return; 726 + mutedIndicator.classList.add('fade-out'); 727 + mutedIndicator.classList.remove('visible'); 728 + console.log('[TV] Audio playing!'); 729 + } catch (e) { 730 + console.warn('[TV] Audio load failed (non-fatal):', e); 850 731 } 851 - console.warn('[TV] Playback error:', e); 852 - stopStatic(); 853 - loadingIndicator.classList.remove('visible'); 854 - } 732 + })(); 855 733 856 734 switching = false; 857 735 }