channel surf through AtmosphereConf talks
4
fork

Configure Feed

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

Replace hls.js with Shaka Player for HLS playback

Shaka Player handles both video and audio via MSE from the master
playlist, avoiding the manual audio fetch/sync workaround. Configures
preferredAudioCodecs to select the segmented AAC track over the
single-segment opus track. Removes Safari warning banner (Shaka falls
back to native HLS), debug audio element, and muted indicator.

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

+53 -211
+53 -211
public/index.html
··· 328 328 } 329 329 .loading-indicator.visible { opacity: 1; } 330 330 331 - /* Muted indicator */ 332 - .muted-indicator { 333 - position: absolute; 334 - bottom: 16px; left: 16px; 335 - z-index: 7; 336 - font-family: 'VT323', monospace; 337 - font-size: 20px; 338 - color: rgba(255, 255, 255, 0.6); 339 - text-shadow: 0 0 6px rgba(0,0,0,0.8); 340 - pointer-events: none; 341 - opacity: 0; 342 - transition: opacity 0.5s; 343 - } 344 - .muted-indicator.visible { opacity: 1; } 345 - .muted-indicator.fade-out { opacity: 0; transition: opacity 1.5s; } 346 - 347 331 .source-link { 348 332 font-family: 'VT323', monospace; 349 333 font-size: 14px; ··· 353 337 } 354 338 .source-link:hover { color: rgba(255, 255, 255, 0.5); } 355 339 356 - .safari-warning { 357 - display: none; 358 - position: fixed; 359 - bottom: 24px; left: 50%; transform: translateX(-50%); 360 - z-index: 100; 361 - background: rgba(30, 26, 20, 0.95); 362 - border: 1px solid rgba(160, 216, 160, 0.3); 363 - border-radius: 8px; 364 - padding: 12px 20px; 365 - font-family: 'VT323', monospace; 366 - font-size: 18px; 367 - color: var(--phosphor); 368 - text-shadow: 0 0 6px rgba(100, 200, 100, 0.3); 369 - text-align: center; 370 - max-width: 90vw; 371 - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6); 372 - } 373 - .safari-warning.visible { display: block; } 374 - .safari-warning-close { 375 - background: none; border: none; color: var(--phosphor); 376 - font-family: 'VT323', monospace; font-size: 18px; 377 - cursor: pointer; margin-left: 12px; opacity: 0.6; 378 - } 379 - .safari-warning-close:hover { opacity: 1; } 380 340 </style> 381 341 </head> 382 342 <body> ··· 389 349 <div class="screen-bezel"> 390 350 <div class="screen" id="screen"> 391 351 <video id="video" playsinline></video> 392 - <audio id="audio" preload="none"></audio> 393 352 <div class="screen-glare"></div> 394 353 <div class="static-overlay" id="static"><canvas id="staticCanvas"></canvas></div> 395 354 <div class="screen-flash" id="flash"></div> ··· 398 357 <a class="channel-title" id="channelTitle" target="_blank" rel="noopener"></a> 399 358 </div> 400 359 <div class="loading-indicator" id="loadingIndicator">TUNING...</div> 401 - <div class="muted-indicator" id="mutedIndicator">LOADING SOUND ///</div> 402 360 <div class="standby" id="standby"> 403 361 <div class="standby-text">ATmosphereConf<br>2026</div> 404 362 <div class="standby-subtitle">turn the dial to start</div> ··· 424 382 <a class="source-link" href="https://tangled.org/btao.org/atmosphereconf-tv" target="_blank" rel="noopener">source code</a> 425 383 </div> 426 384 427 - <div class="safari-warning" id="safariWarning"> 428 - ⚠ Desktop Safari isn't supported yet — try Firefox or Chrome for the best experience 429 - <button class="safari-warning-close" id="safariWarningClose" aria-label="Dismiss">✕</button> 430 - </div> 431 - 432 - <script> 433 - // Desktop Safari detection: Safari UA without mobile indicators 434 - (function() { 435 - var ua = navigator.userAgent; 436 - var isSafari = /Safari/.test(ua) && !/Chrome|Chromium|CriOS|FxiOS|EdgA|Edg/.test(ua); 437 - var isMobile = /iPhone|iPad|iPod|Android/.test(ua) || (navigator.maxTouchPoints > 1 && /Macintosh/.test(ua)); 438 - if (isSafari && !isMobile) { 439 - document.getElementById('safariWarning').classList.add('visible'); 440 - document.getElementById('safariWarningClose').addEventListener('click', function() { 441 - document.getElementById('safariWarning').classList.remove('visible'); 442 - }); 443 - } 444 - })(); 445 - </script> 446 - <script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script> 385 + <script src="https://cdn.jsdelivr.net/npm/shaka-player/dist/shaka-player.compiled.js"></script> 447 386 <script> 448 387 (function() { 449 388 'use strict'; ··· 454 393 const PLAYBACK = 'https://vod-beta.stream.place/xrpc'; 455 394 456 395 const video = document.getElementById('video'); 457 - const audioEl = document.getElementById('audio'); 458 396 const dial = document.getElementById('channelDial'); 459 397 const led = document.getElementById('led'); 460 398 const standby = document.getElementById('standby'); ··· 465 403 const channelNumber = document.getElementById('channelNumber'); 466 404 const channelTitle = document.getElementById('channelTitle'); 467 405 const loadingIndicator = document.getElementById('loadingIndicator'); 468 - const mutedIndicator = document.getElementById('mutedIndicator'); 469 406 470 407 let vods = []; 471 408 let currentChannel = -1; 472 409 let gen = 0; 473 410 let overlayTimeout = null; 474 411 let switching = false; 475 - let hls = null; 412 + let player = null; 476 413 let staticCtx = staticCanvas.getContext('2d'); 477 414 let staticAnimFrame = null; 478 415 ··· 546 483 return all; 547 484 } 548 485 549 - // --- Parse HLS playlist (for audio track only) --- 550 - function parsePlaylist(text) { 551 - const lines = text.split('\n'); 552 - let initUri = null; 553 - const segments = []; 554 - let dur = 0, off = 0, len = 0; 555 - for (const line of lines) { 556 - const l = line.trim(); 557 - if (l.startsWith('#EXT-X-MAP:')) { 558 - const m = l.match(/URI="([^"]+)"/); 559 - if (m) initUri = m[1]; 560 - } else if (l.startsWith('#EXTINF:')) { 561 - dur = parseFloat(l.split(':')[1]); 562 - } else if (l.startsWith('#EXT-X-BYTERANGE:')) { 563 - const parts = l.split(':')[1].split('@'); 564 - len = parseInt(parts[0]); 565 - if (parts[1] !== undefined) off = parseInt(parts[1]); 566 - } else if (l && !l.startsWith('#')) { 567 - segments.push({ uri: l, duration: dur, offset: off, length: len }); 568 - off += len; 569 - } 570 - } 571 - return { initUri, segments }; 572 - } 573 - 574 - function resolveUrl(relative) { 575 - return `${PLAYBACK}/${relative}`; 576 - } 577 - 578 486 // --- Static noise --- 579 487 function drawStatic() { 580 488 const w = staticCanvas.width = (staticCanvas.offsetWidth / 2) || 160; ··· 637 545 standby.classList.add('hidden'); 638 546 led.classList.add('on'); 639 547 loadingIndicator.classList.add('visible'); 640 - mutedIndicator.classList.remove('visible', 'fade-out'); 641 548 showChannelInfo(idx + 1, title, uri); 642 549 643 550 // Kill previous playback 644 - if (hls) { hls.destroy(); hls = null; } 551 + if (player) { 552 + try { await player.destroy(); } catch (e) {} 553 + player = null; 554 + } 645 555 video.pause(); video.removeAttribute('src'); video.load(); 646 - audioEl.pause(); audioEl.removeAttribute('src'); 647 556 648 557 await new Promise(r => setTimeout(r, 250)); 649 558 if (stale()) { switching = false; return; } ··· 651 560 const randomStart = durationSec > 30 ? Math.random() * durationSec * 0.8 : 0; 652 561 console.log('[TV] Target time:', Math.round(randomStart) + 's'); 653 562 654 - // --- Video: hls.js on video-only playlist (track=1) for fast start --- 655 - const videoOnlyUrl = `${PLAYBACK}/place.stream.playback.getVideoPlaylist?uri=${encodedUri}&track=1`; 563 + const playlistUrl = `${PLAYBACK}/place.stream.playback.getVideoPlaylist?uri=${encodedUri}`; 656 564 657 - if (!Hls.isSupported()) { 658 - // Safari — native HLS with full playlist 659 - const playlistUrl = `${PLAYBACK}/place.stream.playback.getVideoPlaylist?uri=${encodedUri}`; 565 + if (!shaka.Player.isBrowserSupported()) { 566 + // Safari / iOS — native HLS 660 567 video.src = playlistUrl; 661 568 video.currentTime = randomStart; 662 569 try { 663 570 await video.play(); 664 - stopStatic(); 665 - loadingIndicator.classList.remove('visible'); 666 571 } catch (e) { 667 572 console.warn('[TV] Playback error:', e); 668 - stopStatic(); 669 - loadingIndicator.classList.remove('visible'); 670 573 } 574 + stopStatic(); 575 + loadingIndicator.classList.remove('visible'); 671 576 switching = false; 672 577 return; 673 578 } 674 579 675 - hls = new Hls({ 676 - startPosition: randomStart, 677 - maxBufferLength: 30, 678 - maxMaxBufferLength: 60, 679 - }); 680 - 681 - hls.loadSource(videoOnlyUrl); 682 - hls.attachMedia(video); 580 + // Shaka Player instance 581 + player = new shaka.Player(); 582 + await player.attach(video); 683 583 684 - // Play as soon as manifest is parsed 685 - hls.on(Hls.Events.MANIFEST_PARSED, () => { 686 - if (stale()) return; 687 - console.log('[TV] Video manifest parsed'); 688 - video.muted = true; 689 - video.play().catch(e => console.warn('[TV] Play failed:', e)); 584 + player.configure({ 585 + preferredAudioCodecs: ['mp4a.40.2'], 586 + streaming: { 587 + bufferingGoal: 30, 588 + rebufferingGoal: 2, 589 + bufferBehind: 10, 590 + }, 690 591 }); 691 592 692 - // Hide static once actual frames are rendering 693 - const onPlaying = () => { 593 + // Mute before loading so autoplay works regardless of gesture timing 594 + video.muted = true; 595 + 596 + const onReady = () => { 597 + video.removeEventListener('playing', onReady); 694 598 if (stale()) return; 695 - video.removeEventListener('playing', onPlaying); 696 - console.log('[TV] Video playing!'); 599 + console.log('[TV] Playing!'); 697 600 stopStatic(); 698 601 loadingIndicator.classList.remove('visible'); 699 - mutedIndicator.classList.add('visible'); 602 + video.muted = false; 700 603 }; 701 - video.addEventListener('playing', onPlaying); 604 + video.addEventListener('playing', onReady); 702 605 703 - hls.on(Hls.Events.ERROR, (event, data) => { 606 + player.addEventListener('error', (event) => { 704 607 if (stale()) return; 705 - console.warn('[TV] HLS error:', data.type, data.details, data.fatal); 706 - if (data.fatal) { 707 - if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { 708 - hls.recoverMediaError(); 709 - } else { 710 - hls.destroy(); hls = null; 711 - switching = false; 712 - changeChannel(); 713 - } 608 + const error = event.detail; 609 + console.warn('[TV] Shaka error:', error.code, error.message); 610 + if (player) { 611 + player.destroy().catch(() => {}); 612 + player = null; 714 613 } 614 + switching = false; 615 + changeChannel(); 715 616 }); 716 617 717 - // --- Audio: fetch in background, sync to video --- 718 - // The audio track is a single giant fMP4 segment. We fetch it separately 719 - // and play it in a synced <audio> element so video isn't blocked. 720 - (async () => { 721 - try { 722 - const audioPlaylistUrl = `${PLAYBACK}/place.stream.playback.getVideoPlaylist?uri=${encodedUri}&track=2`; 723 - const audioText = await (await fetch(audioPlaylistUrl)).text(); 724 - if (stale()) return; 725 - const audioPl = parsePlaylist(audioText); 726 - if (!audioPl.segments.length) return; 727 - 728 - const audioSeg = audioPl.segments[0]; 729 - const audioInitUrl = resolveUrl(audioPl.initUri); 730 - const audioBlobUrl = resolveUrl(audioSeg.uri); 731 - 732 - console.log(`[TV] Loading audio: ${(audioSeg.length/1024/1024).toFixed(1)}MB`); 733 - 734 - const audioInit = await fetch(audioInitUrl, { cache: 'no-store' }).then(r => r.arrayBuffer()); 735 - if (stale()) return; 736 - 737 - const end = audioSeg.offset + audioSeg.length - 1; 738 - const rangeRes = await fetch(audioBlobUrl, { 739 - headers: { Range: `bytes=${audioSeg.offset}-${end}` }, 740 - cache: 'no-store', 741 - }); 742 - 743 - let audioData; 744 - if (rangeRes.status === 206) { 745 - audioData = await rangeRes.arrayBuffer(); 746 - } else { 747 - const full = await rangeRes.arrayBuffer(); 748 - if (stale()) return; 749 - audioData = full.slice(audioSeg.offset, audioSeg.offset + audioSeg.length); 750 - } 751 - if (stale()) return; 752 - 753 - console.log(`[TV] Audio fetched: ${(audioData.byteLength/1024/1024).toFixed(1)}MB`); 754 - 755 - const blob = new Blob([audioInit, audioData], { type: 'video/mp4' }); 756 - audioEl.src = URL.createObjectURL(blob); 757 - 758 - // Wait for the browser to be ready enough to seek 759 - await new Promise(r => { 760 - if (audioEl.readyState >= 1) { r(); return; } 761 - audioEl.addEventListener('loadedmetadata', r, { once: true }); 762 - audioEl.addEventListener('canplay', r, { once: true }); 763 - setTimeout(r, 1000); 764 - }); 765 - if (stale()) return; 766 - 767 - audioEl.currentTime = video.currentTime; 768 - console.log('[TV] Audio seeking to', video.currentTime.toFixed(1) + 's'); 769 - 770 - await new Promise(r => { 771 - if (!audioEl.seeking) { r(); return; } 772 - audioEl.addEventListener('seeked', r, { once: true }); 773 - setTimeout(r, 2000); 774 - }); 775 - if (stale()) return; 776 - 777 - audioEl.volume = 0; 778 - await audioEl.play(); 779 - const fadeStart = performance.now(); 780 - (function fadeIn() { 781 - const t = Math.min(1, (performance.now() - fadeStart) / 1500); 782 - audioEl.volume = t; 783 - if (t < 1) requestAnimationFrame(fadeIn); 784 - })(); 785 - 786 - mutedIndicator.classList.add('fade-out'); 787 - mutedIndicator.classList.remove('visible'); 788 - console.log('[TV] Audio playing!'); 789 - } catch (e) { 790 - console.warn('[TV] Audio load failed (non-fatal):', e); 618 + try { 619 + await player.load(playlistUrl, randomStart); 620 + if (stale()) return; 621 + console.log('[TV] Loaded, seeking to', Math.round(randomStart) + 's'); 622 + await video.play(); 623 + } catch (e) { 624 + if (stale()) return; 625 + console.warn('[TV] Load/play error:', e); 626 + if (player) { 627 + player.destroy().catch(() => {}); 628 + player = null; 791 629 } 792 - })(); 630 + switching = false; 631 + changeChannel(); 632 + return; 633 + } 793 634 794 635 switching = false; 795 636 } ··· 807 648 }); 808 649 809 650 // --- Init --- 651 + shaka.polyfill.installAll(); 810 652 (async () => { 811 653 try { 812 654 vods = await fetchVods();