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.

ATmosphereConf CRT TV: retro channel-surfing VOD player

A static web app that presents ATmosphereConf 2026 conference recordings
as a CRT television channel-surfing experience. Turn the dial to land on
a random talk at a random point, exactly like flipping channels mid-programme.

Uses MSE directly (no hls.js) to work around CDN byte-range quirks and
single-segment audio tracks. Deploys to Cloudflare Workers.

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

+1060
+2
.gitignore
··· 1 + node_modules/ 2 + test-range.log
+8
package.json
··· 1 + { 2 + "name": "atmosphereconf-vod-crt", 3 + "private": true, 4 + "scripts": { 5 + "dev": "npx wrangler pages dev public", 6 + "deploy": "npx wrangler deploy" 7 + } 8 + }
+9
pnpm-lock.yaml
··· 1 + lockfileVersion: '9.0' 2 + 3 + settings: 4 + autoInstallPeers: true 5 + excludeLinksFromLockfile: false 6 + 7 + importers: 8 + 9 + .: {}
+124
public/denied-vods.json
··· 1 + [ 2 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miah6keewv2n", // follow @stream.place for VODs (400s) 3 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7sb5disa2y", // IRL only / No Stream (1100s) 4 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7jx4xi2g25", // ATmosphereConf Starting Soon (1479s) 5 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7jnnnh7i2h", // ATmosphereConf Starting Soon! (36s) 6 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7isob4ae2a", // ATmosphereConf starting soon! (706s) 7 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5dvnqcf72u", // lunch (4334s) 8 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5dv7xthz26", // lunch (4307s) 9 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5csvtgdg22", // Lunch Break (5488s) 10 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi2ubr4zq72d", // lunch break 1 hour (2766s) 11 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miafywhnca2r", // Please join us in stream 1 for closing remarks (448s) 12 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miafuv53nh2z", // Please join us in stream 1 for closing remarks (280s) 13 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5xky7ogz23", // Join us tomorrow! (550s) 14 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5wqwpqx72z", // Join us tomorrow! (162s) 15 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5wj4mc6t2e", // Join us tomorrow! (367s) 16 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5n54d6r42u", // join us in https://stream.place/stream1.atmosphereconf.org for 2026 Atmosphere Report (3174s) 17 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5mvmovsn2i", // join us in https://stream.place/stream1.atmosphereconf.org for 2026 Atmosphere Report (3411s) 18 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miafr5amxa2m", // closing remarks (1506s) 19 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miaef2b4mh2h", // Rewilding the internet with ATProto (1452s) 20 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miaec7exqi25", // An artist dreaming in the Atmosphere: visions about community, sustainability and creativity (1683s) 21 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miadvkvhfv2h", // DID:PLC War Games (2248s) 22 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miadd43al32c", // Matadata! Publishing scientific data straight to AT (619s) 23 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miadb23fme2a", // Wherever You Get Your Podcasts: Interoperability in the Atmosphere (1192s) 24 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miacona6fc2e", // AT Transparency Logs: accountable record collections (679s) 25 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miacmwnd2z2z", // Jacquard Magic: how to make atproto actually easy with Rust (1782s) 26 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miacmdq4sm2a", // How to use Bluesky to easily and securely preview a software product to users. (678s) 27 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miac5ez6a22r", // Skylimit: A curating web client with fine-grained controls to mimic the newspaper experience (565s) 28 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miac3urhgt23", // Using GraphQL to build with ATProto (540s) 29 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miac22soys22", // Affordances of the Atmosphere (588s) 30 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miabh2g67c2c", // Scaling the Atmosphere (718s) 31 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miabfvrtei26", // Abstracting the AppView (721s) 32 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mia7iun75l2x", // Keywords vs Embeddings (2080s) 33 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mia7dt6fnp2m", // ATProto design philosophy behind BookHive (2191s) 34 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mia74qg3622a", // Building Bridgy, Not Walls (3112s) 35 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mia5q5vu4q2h", // WebTiles Showcase (1902s) 36 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mia5mbp4tf22", // From Toilets to Moths: The Future of Social Media is Weird and Not For Everyone (1854s) 37 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mia5khdija2h", // Protocol Governance & Hard Decentralization (1668s) 38 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mia3vqrg4r23", // Data Sovereignty for Games (and Everything Else): Building Decentralized Industry Infrastructure on ATProto (1748s) 39 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mia3vm5oyd2u", // Coop: Open source Trust & Safety infrastructure for all (1950s) 40 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mia3svkvjw22", // furryli.st — Building Communities Without Landlords From the Protocol Up (1916s) 41 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mia26ffz2j2c", // Two Years of Skywatch: Lessons Learned for Community Moderation (1854s) 42 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7zxhekcu2d", // Building decentralized AI on atproto (1992s) 43 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7ztge2sf2h", // Designing for the social web (2217s) 44 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7tqadgv22d", // Social Components (6533s) 45 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7tcodsqu2t", // Creating a Safer Web: Blacksky's Moderation Tool (7351s) 46 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7tbwgymp25", // Compete or kill Cooperate and Succeed! (7166s) 47 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7rvqdpj722", // A Fireside Chat on Resonant Computing: Why we wrote the manifesto and where we go from here (1944s) 48 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7rpxijg725", // Community privacy in a decentralized network (1647s) 49 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7q5mjlbe2c", // Blousques: Case Study on the Challenges in Translating Bluesky's UI (2242s) 50 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7pxl6xkz22", // Bluenotes: Community Notes for ATProto (1886s) 51 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7pjfdkhv2b", // Waiting for the Future to Load (2561s) 52 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7ok5zmuk2k", // How to have more non-english speaking users (1722s) 53 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7ofq7ob72a", // One Year of Graze - lessons learned funding, building, and growing in the atmosphere (1178s) 54 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7o5amqqx2d", // Bringing Self Sovereign Identities to the Masses via ATproto (1959s) 55 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7mb4jbag23", // tangled: The Lewis end (2298s) 56 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7ldfbjwe26", // How and Why News Organizations Should Build on the ATProtocol (3011s) 57 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7lapdbjc2t", // Roomy and community organizing for system change (3513s) 58 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7kaogqjs22", // npmx - a fast, modern browser for the npm registry (2133s) 59 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7jorroaa2h", // Day 2 Opening Remarks! (1650s) 60 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5wqocga62h", // Closing remarks, I guess, if i have to. (856s) 61 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5uqr2spg2l", // Hypercerts on ATProto: Collective Funding, Evaluation, and Ownership as Social Data (1863s) 62 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5unzkbat27", // Journalism must create its own algorithms (2245s) 63 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5unhqisv22", // This Title Left Intentionally Blank (2153s) 64 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5stzyxji2e", // How Streamplace Works: VODs (2020s) 65 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5spj6izy2a", // The Phoenix Architecture (2060s) 66 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5sn7zmfy23", // A Free Press needs Free Protocols (2146s) 67 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5s5mqmgj2r", // Pollen: Prototyping a toolkit for journalists and researchers to restore source transparency in an AI-saturated feed (727s) 68 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5s4foj7h2a", // Oaklog: Building a community calendar in the Oakland Bay Area (556s) 69 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5s2x6tkd2d", // Burning down data walls in the US Fire Service and Beyond (676s) 70 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5rnhjugb2h", // E2EE DMs for Solidarity Social (544s) 71 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5rm4y7ts2b", // The Future of Open Source is Social (474s) 72 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5rl4r4a725", // Bridging Social Graphs: How Sky Follower Bridge helps people move to Bluesky (580s) 73 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5r2rp7a62e", // How (de)centralized is Bluesky, really? (607s) 74 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5qzkwfe42z", // Who, Where, Why, What about W Social (586s) 75 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5qywid6z25", // What 350,000 users taught me about growing on Open Social (640s) 76 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5q57diht27", // Semble: Rediscovering the Magic of Trails (906s) 77 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5q4ey4232y", // Stop Hallucinating the Protocol: Grounding your AI Agents with the Official ATproto Docs (1001s) 78 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5q3oibtu2i", // Why Gander Social? (1004s) 79 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5nh7yjb326", // THE AtmosYear 2026 (2771s) 80 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5lexqbqf2z", // Building Public-Interest Infrastructure on ATProto (1617s) 81 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5ldvd46r2i", // From protocol to product: How Expo powers the next wave of AT Proto applications (2216s) 82 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5lb763vn2j", // Account logic in ATProto using Trusted Execution Environments (2014s) 83 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5k3brfax2b", // Rethinking the Client: Why User Choice is the Key to Growth for ATProto (1271s) 84 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5jrjmk6y2n", // Open social tech and geopolitical risk (1716s) 85 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5jmsg6kn2m", // Consent Before Cryptography (1794s) 86 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5itmi65s2z", // Feeds Are The New Websites (816s) 87 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5hza73vs2z", // Sattestations (870s) 88 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5hy457fu2r", // Building Cirrus: a single-user, serverless PDS (2236s) 89 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5hx4v6cr26", // Advocating for Digital Sovereignty: European Experiences and Global Lessons (1945s) 90 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5c65iee42h", // Understanding the Landscape of Custom Feeds on Bluesky (1854s) 91 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5bya72ub2d", // A discussion with news creators (2029s) 92 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5bcpyqg32a", // Beyond Bluesky: Community infrastructure (1587s) 93 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5ag3scgk2k", // Building Future of Artificial Intelligence on AT Protocol (1851s) 94 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5aarspma27", // Creators First: Video & Media as the Foundation of a Thriving Creator Economy on ATProto (1858s) 95 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5a2y3tej2z", // This isn't over until we all listen to kpop (1319s) 96 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi56pnvulh2o", // Did Lexicon just accidentally solve the enterprise data problem? (1830s) 97 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi56n6j2g22d", // Who owns the group chat? Building collaborative spaces on ATProto (1522s) 98 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi56m3hnrq2z", // The Economics of Sovereign Media: A Roadmap for AT Protocol (1762s) 99 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi56l4hpil2x", // ho owns the group chat? Building collaborative spaces on ATProto (56s) 100 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi54oonum62b", // Groundings with my Siblings: Lessons Learned Building for Community (2019s) 101 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi54nec66s2w", // Feature / Product / Business: A Framework for Sustainable ATProto Projects (2200s) 102 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi54lyc5ts25", // The Aggregation Era burned journalism institutions to the ground. The federated era is emerging from those embers (2145s) 103 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi4zoce3a22j", // ATmosphereConf 2026 - Room 2301 (2771s) 104 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi4ziviq2d25", // ATmosphereConf 2026 - Performance Theater (2708s) 105 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi4y35l2f425", // ATmosphereConf 2026!!! (4768s) 106 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi3e7ly25h2b", // https://atmosphereconf.org/#schedule-conf1 (544s) 107 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi3cjemc6a27", // Everything Everywhere All at Once - an impromtu session - panproto.dev (1804s) 108 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi3bf7s6zn27", // Everything Everywhere All at Once - an impromtu session (1193s) 109 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi3ar75zqs23", // Everything Everywhere All at Once - Blaine (670s) 110 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi37ha3edb23", // How (de)centralized is Bluesky, really? - (1395s) 111 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi36lcxgce2b", // Narrative strands & memetic lineages in community social data using Community Archive - (921s) 112 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi35eplsua23", // Studying social media through the Atmosphere - (1273s) 113 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi34ps3oym2i", // Crowdsourced Research Synthesis on ATProto: Envisioning an Inclusive Future - Jay Patel - @infotainment.bsky.social (673s) 114 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi33ycoo5d2a", // Future of Science Social Media (784s) 115 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi2wu5u5rs2i", // The Astrosky Ecosystem: An independent online home for astronomy (5508s) 116 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi2u6pl6ah2z", // Skysquare is context as a service - Travis Simpson - @skysquare.app (96s) 117 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi2tfsbq4p25", // Making wisdom together - seams.so - @hyl.st (834s) 118 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi2tdpailb2m", // Making wisdom together - seams - @hyl.st (51s) 119 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi2son7re62m", // Building collective intelligence to reduce division at ViewSift @viewsift.com (691s) 120 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi2ryxrnin2a", // Reproducible, citation-aware automated paper reviews @seanjungblluth.bsky.social (727s) 121 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi2rtfj5ec2m", // Can decentralists cooperate? Rethinking commons and collective action in the age of platforms and AI (137s) 122 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi2jdevvu626", // Keynote: Towards Modular Open Science with @row1.ca and @matsulab.com (9111s) 123 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi2ikg6gij26" // ATScience at ATmosphereConf 2026! (814s) 124 + ]
+827
public/index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>ATmosphereConf TV</title> 7 + <meta name="description" content="Channel-surf through ATmosphereConf 2026 talks like it's 1987."> 8 + <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📺</text></svg>"> 9 + <style> 10 + @import url('https://fonts.googleapis.com/css2?family=VT323&family=Press+Start+2P&display=swap'); 11 + 12 + * { margin: 0; padding: 0; box-sizing: border-box; } 13 + 14 + :root { 15 + --tv-body: #3a3024; 16 + --tv-body-light: #5a4d3a; 17 + --tv-body-dark: #1e1a14; 18 + --tv-bezel: #1a1610; 19 + --screen-glow: rgba(100, 200, 100, 0.08); 20 + --phosphor: #a0d8a0; 21 + } 22 + 23 + html, body { 24 + width: 100%; height: 100%; 25 + overflow: hidden; 26 + background: #0a0a0e; 27 + font-family: 'VT323', monospace; 28 + } 29 + 30 + body { 31 + display: flex; 32 + align-items: center; 33 + justify-content: center; 34 + background: 35 + radial-gradient(ellipse at 50% 80%, #1a1820 0%, #0a0a0e 70%), 36 + #0a0a0e; 37 + } 38 + 39 + body::before { 40 + content: ''; 41 + position: fixed; 42 + inset: 0; 43 + background: 44 + repeating-linear-gradient( 45 + 90deg, 46 + transparent 0px, transparent 48px, 47 + rgba(255,255,255,0.015) 48px, rgba(255,255,255,0.015) 50px 48 + ); 49 + pointer-events: none; 50 + } 51 + 52 + .tv-wrapper { 53 + position: relative; 54 + display: flex; 55 + flex-direction: column; 56 + align-items: center; 57 + } 58 + 59 + .antenna { 60 + position: relative; 61 + width: 200px; 62 + height: 80px; 63 + margin-bottom: -8px; 64 + z-index: 1; 65 + } 66 + .antenna::before, .antenna::after { 67 + content: ''; 68 + position: absolute; 69 + bottom: 0; 70 + width: 4px; 71 + height: 80px; 72 + background: linear-gradient(to top, #888, #ccc); 73 + border-radius: 2px; 74 + } 75 + .antenna::before { left: 50px; transform: rotate(-25deg); transform-origin: bottom center; } 76 + .antenna::after { right: 50px; transform: rotate(25deg); transform-origin: bottom center; } 77 + 78 + .tv { 79 + position: relative; 80 + width: min(92vw, 960px); 81 + background: linear-gradient(160deg, var(--tv-body-light) 0%, var(--tv-body) 40%, var(--tv-body-dark) 100%); 82 + border-radius: 20px 20px 10px 10px; 83 + box-shadow: 84 + 0 8px 40px rgba(0,0,0,0.8), 85 + inset 0 2px 0 rgba(255,255,255,0.08), 86 + inset 0 -2px 0 rgba(0,0,0,0.3); 87 + display: grid; 88 + grid-template-columns: 1fr; 89 + grid-template-rows: 1fr auto; 90 + padding: 20px 20px 0 20px; 91 + overflow: hidden; 92 + } 93 + 94 + @media (max-width: 600px) { 95 + .tv { padding: 12px 12px 0 12px; border-radius: 14px 14px 8px 8px; width: 98vw; } 96 + .antenna { display: none; } 97 + .tv-legs { display: none; } 98 + } 99 + 100 + .screen-bezel { 101 + position: relative; 102 + background: var(--tv-bezel); 103 + border-radius: 12px; 104 + padding: 8px; 105 + box-shadow: inset 0 2px 8px rgba(0,0,0,0.9), inset 0 0 2px rgba(0,0,0,1); 106 + overflow: hidden; 107 + aspect-ratio: 16 / 9; 108 + } 109 + 110 + .screen { 111 + position: relative; 112 + width: 100%; height: 100%; 113 + border-radius: 6px; 114 + overflow: hidden; 115 + background: #000800; 116 + } 117 + 118 + .screen video { 119 + width: 100%; height: 100%; 120 + object-fit: cover; 121 + display: block; 122 + } 123 + 124 + .screen::after { 125 + content: ''; 126 + position: absolute; 127 + inset: 0; 128 + background: repeating-linear-gradient(0deg, transparent 0px, transparent 2px, rgba(0,0,0,0.15) 2px, rgba(0,0,0,0.15) 4px); 129 + pointer-events: none; 130 + z-index: 4; 131 + } 132 + 133 + .screen::before { 134 + content: ''; 135 + position: absolute; 136 + inset: 0; 137 + background: radial-gradient(ellipse at center, transparent 60%, rgba(0,0,0,0.5) 100%); 138 + pointer-events: none; 139 + z-index: 5; 140 + border-radius: 6px; 141 + } 142 + 143 + .screen-glare { 144 + position: absolute; 145 + top: 8%; left: 12%; 146 + width: 50%; height: 30%; 147 + background: radial-gradient(ellipse at center, rgba(255,255,255,0.06) 0%, transparent 70%); 148 + transform: rotate(-8deg); 149 + pointer-events: none; 150 + z-index: 6; 151 + } 152 + 153 + @keyframes flicker { 154 + 0% { opacity: 0.97; } 5% { opacity: 1; } 10% { opacity: 0.98; } 155 + 15% { opacity: 1; } 50% { opacity: 0.99; } 80% { opacity: 1; } 156 + 90% { opacity: 0.98; } 100% { opacity: 0.97; } 157 + } 158 + .screen video { animation: flicker 0.15s infinite; } 159 + 160 + .static-overlay { 161 + position: absolute; 162 + inset: 0; 163 + z-index: 10; 164 + pointer-events: none; 165 + opacity: 0; 166 + transition: opacity 0.05s; 167 + background: #111; 168 + } 169 + .static-overlay.active { opacity: 1; } 170 + .static-overlay canvas { width: 100%; height: 100%; } 171 + 172 + @keyframes channel-flash { 173 + 0% { opacity: 1; background: #fff; } 174 + 30% { opacity: 0.8; background: #a0ffa0; } 175 + 100% { opacity: 0; background: transparent; } 176 + } 177 + .screen-flash { 178 + position: absolute; inset: 0; z-index: 8; 179 + pointer-events: none; opacity: 0; 180 + } 181 + .screen-flash.active { animation: channel-flash 0.2s ease-out forwards; } 182 + 183 + .channel-overlay { 184 + position: absolute; 185 + top: 20px; right: 24px; 186 + z-index: 7; 187 + text-align: right; 188 + opacity: 0; 189 + transition: opacity 0.3s; 190 + pointer-events: none; 191 + } 192 + .channel-overlay.visible { opacity: 1; } 193 + .channel-number { 194 + font-family: 'Press Start 2P', monospace; 195 + font-size: clamp(12px, 2vw, 20px); 196 + color: #fff; 197 + text-shadow: 0 0 10px var(--phosphor), 0 0 20px var(--phosphor), 2px 2px 0 rgba(0,0,0,0.8); 198 + letter-spacing: 4px; 199 + } 200 + .channel-title { 201 + font-family: 'VT323', monospace; 202 + font-size: clamp(14px, 2vw, 22px); 203 + color: #ddd; 204 + text-shadow: 0 0 8px var(--phosphor), 1px 1px 0 #000; 205 + margin-top: 4px; 206 + max-width: 400px; 207 + text-decoration: none; 208 + pointer-events: auto; 209 + display: block; 210 + cursor: pointer; 211 + } 212 + .channel-title:hover { 213 + color: #fff; 214 + text-decoration: underline; 215 + } 216 + 217 + .standby { 218 + position: absolute; inset: 0; 219 + display: flex; flex-direction: column; align-items: center; justify-content: center; 220 + z-index: 3; 221 + background: #000a00; 222 + } 223 + .standby.hidden { display: none; } 224 + .standby-text { 225 + font-family: 'Press Start 2P', monospace; 226 + font-size: clamp(10px, 2.2vw, 18px); 227 + color: var(--phosphor); 228 + text-shadow: 0 0 15px var(--phosphor), 0 0 30px rgba(100,200,100,0.3); 229 + text-align: center; line-height: 2.2; 230 + animation: standby-pulse 3s ease-in-out infinite; 231 + } 232 + @keyframes standby-pulse { 0%, 100% { opacity: 0.8; } 50% { opacity: 1; } } 233 + .standby-subtitle { 234 + font-family: 'VT323', monospace; 235 + font-size: clamp(14px, 2.5vw, 24px); 236 + color: rgba(160, 216, 160, 0.6); 237 + margin-top: 24px; 238 + text-shadow: 0 0 8px rgba(100,200,100,0.3); 239 + } 240 + 241 + .tv-controls { 242 + display: flex; flex-direction: row; align-items: center; justify-content: center; 243 + padding: 12px 16px; gap: 24px; 244 + } 245 + 246 + .tv-brand { 247 + font-family: 'Press Start 2P', monospace; 248 + font-size: 7px; 249 + color: rgba(180, 165, 140, 0.7); 250 + letter-spacing: 2px; 251 + text-transform: uppercase; 252 + text-align: center; line-height: 1.6; 253 + } 254 + 255 + .dial-housing { position: relative; width: 48px; height: 48px; } 256 + .dial-ring { 257 + position: absolute; inset: 0; border-radius: 50%; 258 + background: linear-gradient(145deg, #555, #222); 259 + box-shadow: inset 0 2px 4px rgba(0,0,0,0.8), 0 1px 0 rgba(255,255,255,0.1); 260 + } 261 + .dial { 262 + position: absolute; inset: 4px; border-radius: 50%; 263 + background: linear-gradient(145deg, #888, #444); 264 + cursor: pointer; 265 + display: flex; align-items: center; justify-content: center; 266 + box-shadow: 0 2px 6px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.2); 267 + transition: transform 0.15s; 268 + user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent; 269 + } 270 + .dial:active { transform: rotate(30deg); } 271 + .dial::after { 272 + content: ''; width: 3px; height: 16px; 273 + background: #222; border-radius: 2px; 274 + position: absolute; top: 8px; 275 + } 276 + 277 + .power-led { 278 + width: 8px; height: 8px; border-radius: 50%; 279 + background: #330000; 280 + box-shadow: inset 0 1px 2px rgba(0,0,0,0.5); 281 + transition: all 0.3s; 282 + } 283 + .power-led.on { 284 + background: #00ff00; 285 + box-shadow: 0 0 4px #00ff00, 0 0 10px rgba(0,255,0,0.4); 286 + } 287 + 288 + .speaker { 289 + width: 120px; height: 28px; 290 + background: repeating-linear-gradient(90deg, var(--tv-body-dark) 0px, var(--tv-body-dark) 2px, var(--tv-body) 2px, var(--tv-body) 5px); 291 + border-radius: 4px; 292 + box-shadow: inset 0 2px 6px rgba(0,0,0,0.6); 293 + } 294 + 295 + .tv-legs { display: flex; justify-content: center; gap: 500px; } 296 + .tv-leg { 297 + width: 16px; height: 28px; 298 + background: linear-gradient(to bottom, var(--tv-body-dark), #111); 299 + border-radius: 0 0 4px 4px; 300 + box-shadow: 0 4px 8px rgba(0,0,0,0.5); 301 + } 302 + 303 + @media (max-width: 600px) { 304 + .speaker { width: 60px; } 305 + .dial-housing { width: 40px; height: 40px; } 306 + .tv-brand { font-size: 6px; } 307 + .tv-controls { gap: 12px; padding: 8px; } 308 + } 309 + 310 + .screen video { filter: contrast(1.1) saturate(0.9) brightness(1.05); } 311 + 312 + .loading-indicator { 313 + position: absolute; 314 + bottom: 16px; left: 50%; transform: translateX(-50%); 315 + z-index: 7; 316 + font-family: 'VT323', monospace; font-size: 18px; 317 + color: var(--phosphor); 318 + text-shadow: 0 0 8px var(--phosphor); 319 + opacity: 0; transition: opacity 0.3s; 320 + pointer-events: none; 321 + } 322 + .loading-indicator.visible { opacity: 1; } 323 + 324 + /* Muted indicator — shows while audio is loading */ 325 + .muted-indicator { 326 + position: absolute; 327 + bottom: 16px; left: 16px; 328 + z-index: 7; 329 + font-family: 'VT323', monospace; 330 + font-size: 20px; 331 + color: rgba(255, 255, 255, 0.6); 332 + text-shadow: 0 0 6px rgba(0,0,0,0.8); 333 + pointer-events: none; 334 + opacity: 0; 335 + transition: opacity 0.5s; 336 + } 337 + .muted-indicator.visible { opacity: 1; } 338 + .muted-indicator.fade-out { opacity: 0; transition: opacity 1.5s; } 339 + </style> 340 + </head> 341 + <body> 342 + 343 + <div class="tv-wrapper"> 344 + <div class="antenna"> 345 + </div> 346 + 347 + <div class="tv"> 348 + <div class="screen-bezel"> 349 + <div class="screen" id="screen"> 350 + <video id="video" playsinline muted></video> 351 + <audio id="audio" preload="none"></audio> 352 + <div class="screen-glare"></div> 353 + <div class="static-overlay" id="static"><canvas id="staticCanvas"></canvas></div> 354 + <div class="screen-flash" id="flash"></div> 355 + <div class="channel-overlay" id="channelOverlay"> 356 + <div class="channel-number" id="channelNumber">CH 00</div> 357 + <a class="channel-title" id="channelTitle" target="_blank" rel="noopener"></a> 358 + </div> 359 + <div class="loading-indicator" id="loadingIndicator">TUNING...</div> 360 + <div class="muted-indicator" id="mutedIndicator">LOADING SOUND ///</div> 361 + <div class="standby" id="standby"> 362 + <div class="standby-text">ATmosphereConf<br>2026</div> 363 + <div class="standby-subtitle">turn the dial to start</div> 364 + </div> 365 + </div> 366 + </div> 367 + 368 + <div class="tv-controls"> 369 + <div class="tv-brand">ATMO<br>SPHERE</div> 370 + <div class="dial-housing"> 371 + <div class="dial-ring"></div> 372 + <div class="dial" id="channelDial" role="button" aria-label="Change channel" tabindex="0"></div> 373 + </div> 374 + <div class="power-led" id="led"></div> 375 + <div class="speaker"></div> 376 + </div> 377 + </div> 378 + 379 + <div class="tv-legs"> 380 + <div class="tv-leg"></div> 381 + <div class="tv-leg"></div> 382 + </div> 383 + </div> 384 + 385 + <script> 386 + (function() { 387 + 'use strict'; 388 + 389 + const PDS = 'https://iameli.com'; 390 + const REPO = 'did:plc:rbvrr34edl5ddpuwcubjiost'; 391 + const COLLECTION = 'place.stream.video'; 392 + const PLAYBACK = 'https://vod-beta.stream.place/xrpc'; 393 + 394 + const video = document.getElementById('video'); 395 + const audioEl = document.getElementById('audio'); 396 + const dial = document.getElementById('channelDial'); 397 + const led = document.getElementById('led'); 398 + const standby = document.getElementById('standby'); 399 + const staticOverlay = document.getElementById('static'); 400 + const staticCanvas = document.getElementById('staticCanvas'); 401 + const flash = document.getElementById('flash'); 402 + const channelOverlay = document.getElementById('channelOverlay'); 403 + const channelNumber = document.getElementById('channelNumber'); 404 + const channelTitle = document.getElementById('channelTitle'); 405 + const loadingIndicator = document.getElementById('loadingIndicator'); 406 + const mutedIndicator = document.getElementById('mutedIndicator'); 407 + 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 + let vods = []; 440 + let currentChannel = -1; 441 + let gen = 0; 442 + let overlayTimeout = null; 443 + let switching = false; 444 + let staticCtx = staticCanvas.getContext('2d'); 445 + let staticAnimFrame = null; 446 + 447 + async function fetchVods() { 448 + let all = []; 449 + let cursor; 450 + do { 451 + const p = new URLSearchParams({ repo: REPO, collection: COLLECTION, limit: '100' }); 452 + if (cursor) p.set('cursor', cursor); 453 + const data = await (await fetch(`${PDS}/xrpc/com.atproto.repo.listRecords?${p}`)).json(); 454 + all = all.concat(data.records || []); 455 + cursor = data.cursor; 456 + } while (cursor); 457 + 458 + // Filter out denied VODs (ones known to not support Range requests) 459 + try { 460 + const text = await (await fetch('/denied-vods.json')).text(); 461 + const denied = JSON.parse(text.replace(/\/\/.*$/gm, '')); 462 + const denySet = new Set(denied); 463 + const before = all.length; 464 + all = all.filter(r => !denySet.has(r.uri)); 465 + if (before !== all.length) console.log(`[TV] Filtered out ${before - all.length} denied VODs`); 466 + } catch (e) { 467 + // No deny list — that's fine, use all VODs 468 + } 469 + 470 + return all; 471 + } 472 + 473 + // --- Parse HLS playlist --- 474 + function parsePlaylist(text) { 475 + const lines = text.split('\n'); 476 + let initUri = null; 477 + const segments = []; 478 + let dur = 0, off = 0, len = 0; 479 + for (const line of lines) { 480 + const l = line.trim(); 481 + if (l.startsWith('#EXT-X-MAP:')) { 482 + const m = l.match(/URI="([^"]+)"/); 483 + if (m) initUri = m[1]; 484 + } else if (l.startsWith('#EXTINF:')) { 485 + dur = parseFloat(l.split(':')[1]); 486 + } else if (l.startsWith('#EXT-X-BYTERANGE:')) { 487 + const parts = l.split(':')[1].split('@'); 488 + len = parseInt(parts[0]); 489 + if (parts[1] !== undefined) off = parseInt(parts[1]); 490 + } else if (l && !l.startsWith('#')) { 491 + segments.push({ uri: l, duration: dur, offset: off, length: len }); 492 + off += len; 493 + } 494 + } 495 + return { initUri, segments }; 496 + } 497 + 498 + function segmentAt(segments, time) { 499 + let t = 0; 500 + for (let i = 0; i < segments.length; i++) { 501 + t += segments[i].duration; 502 + if (t > time) return i; 503 + } 504 + return segments.length - 1; 505 + } 506 + 507 + function segmentTime(segments, idx) { 508 + let t = 0; 509 + for (let i = 0; i < idx; i++) t += segments[i].duration; 510 + return t; 511 + } 512 + 513 + function resolveUrl(relative) { 514 + return `${PLAYBACK}/${relative}`; 515 + } 516 + 517 + // Fetch a byte range from the blob. Throws RANGE_NOT_SUPPORTED if the CDN 518 + // returns the full file (see architecture notes above). 519 + async function fetchRange(url, offset, length, signal) { 520 + const end = offset + length - 1; 521 + const res = await fetch(url, { 522 + headers: { Range: `bytes=${offset}-${end}` }, 523 + cache: 'no-store', 524 + signal, 525 + }); 526 + if (res.status === 200) { 527 + res.body?.cancel(); 528 + throw new Error('RANGE_NOT_SUPPORTED'); 529 + } 530 + if (res.status !== 206) throw new Error(`HTTP ${res.status}`); 531 + return res.arrayBuffer(); 532 + } 533 + 534 + // --- Static noise --- 535 + function drawStatic() { 536 + const w = staticCanvas.width = (staticCanvas.offsetWidth / 2) || 160; 537 + const h = staticCanvas.height = (staticCanvas.offsetHeight / 2) || 90; 538 + const img = staticCtx.createImageData(w, h); 539 + const d = img.data; 540 + for (let i = 0; i < d.length; i += 4) { 541 + const v = Math.random() * 255; 542 + d[i] = d[i+1] = d[i+2] = v; 543 + d[i+3] = 255; 544 + } 545 + staticCtx.putImageData(img, 0, 0); 546 + staticAnimFrame = requestAnimationFrame(drawStatic); 547 + } 548 + function startStatic() { 549 + staticOverlay.classList.add('active'); 550 + if (!staticAnimFrame) drawStatic(); 551 + } 552 + function stopStatic() { 553 + staticOverlay.classList.remove('active'); 554 + if (staticAnimFrame) { cancelAnimationFrame(staticAnimFrame); staticAnimFrame = null; } 555 + } 556 + 557 + function showChannelInfo(ch, title, uri) { 558 + channelNumber.textContent = 'CH ' + String(ch).padStart(2, '0'); 559 + channelTitle.textContent = title; 560 + channelTitle.href = 'https://pds.ls/' + uri; 561 + channelOverlay.classList.add('visible'); 562 + clearTimeout(overlayTimeout); 563 + } 564 + 565 + // --- Channel change: pick a random VOD, seek to a random point --- 566 + async function changeChannel() { 567 + if (switching || vods.length === 0) return; 568 + switching = true; 569 + const myGen = ++gen; 570 + const stale = () => myGen !== gen; 571 + 572 + // Pick random VOD 573 + let idx; 574 + do { idx = Math.floor(Math.random() * vods.length); } 575 + while (vods.length > 1 && idx === currentChannel); 576 + currentChannel = idx; 577 + 578 + const record = vods[idx]; 579 + const title = record.value.title || 'Unknown Programme'; 580 + const durationSec = (record.value.duration || 0) / 1e9; 581 + const uri = record.uri; 582 + const encodedUri = encodeURIComponent(uri); 583 + 584 + console.log('[TV] Switching to:', title, `(${Math.round(durationSec)}s)`); 585 + 586 + // Visual: flash + static 587 + flash.classList.remove('active'); 588 + void flash.offsetWidth; 589 + flash.classList.add('active'); 590 + startStatic(); 591 + standby.classList.add('hidden'); 592 + led.classList.add('on'); 593 + loadingIndicator.classList.add('visible'); 594 + mutedIndicator.classList.remove('visible', 'fade-out'); 595 + showChannelInfo(idx + 1, title, uri); 596 + 597 + // Kill previous playback 598 + video.pause(); video.removeAttribute('src'); video.load(); 599 + audioEl.pause(); audioEl.removeAttribute('src'); 600 + 601 + await new Promise(r => setTimeout(r, 250)); 602 + if (stale()) { switching = false; return; } 603 + 604 + const randomStart = durationSec > 30 ? Math.random() * durationSec * 0.8 : 0; 605 + console.log('[TV] Target time:', Math.round(randomStart) + 's'); 606 + 607 + try { 608 + // Fetch video-only playlist (track=1). We avoid the master playlist because 609 + // it includes the audio track, which would block playback (see notes above). 610 + const videoPlaylistUrl = `${PLAYBACK}/place.stream.playback.getVideoPlaylist?uri=${encodedUri}&track=1`; 611 + const plText = await (await fetch(videoPlaylistUrl)).text(); 612 + if (stale()) { switching = false; return; } 613 + const pl = parsePlaylist(plText); 614 + 615 + const segIdx = segmentAt(pl.segments, randomStart); 616 + const seg = pl.segments[segIdx]; 617 + const segStartTime = segmentTime(pl.segments, segIdx); 618 + const blobUrl = resolveUrl(seg.uri); 619 + const initUrl = resolveUrl(pl.initUri); 620 + 621 + console.log(`[TV] Segment ${segIdx}: ${seg.offset}-${seg.offset + seg.length} (${(seg.length/1024/1024).toFixed(1)}MB) at ${segStartTime.toFixed(1)}s`); 622 + 623 + // Fetch init (~1KB) + one video segment (~3MB) — all we need to start 624 + const [initBuf, segBuf] = await Promise.all([ 625 + fetch(initUrl, { cache: 'no-store' }).then(r => r.arrayBuffer()), 626 + fetchRange(blobUrl, seg.offset, seg.length, null), 627 + ]); 628 + if (stale()) { switching = false; return; } 629 + 630 + console.log('[TV] Init + first segment fetched'); 631 + 632 + const ms = new MediaSource(); 633 + video.src = URL.createObjectURL(ms); 634 + await new Promise(r => ms.addEventListener('sourceopen', r, { once: true })); 635 + if (stale()) { switching = false; return; } 636 + 637 + const codec = 'video/mp4; codecs="avc1.42c01f"'; 638 + const sb = ms.addSourceBuffer(codec); 639 + 640 + sb.appendBuffer(initBuf); 641 + await new Promise(r => sb.addEventListener('updateend', r, { once: true })); 642 + 643 + // Offset timestamps so the segment plays from time=0 in the video element 644 + sb.timestampOffset = -segStartTime; 645 + 646 + sb.appendBuffer(segBuf); 647 + await new Promise(r => sb.addEventListener('updateend', r, { once: true })); 648 + if (stale()) { switching = false; return; } 649 + 650 + console.log('[TV] Buffer ready, playing'); 651 + 652 + video.muted = true; 653 + await video.play(); 654 + stopStatic(); 655 + loadingIndicator.classList.remove('visible'); 656 + mutedIndicator.classList.remove('fade-out'); 657 + mutedIndicator.classList.add('visible'); 658 + console.log('[TV] Playing!'); 659 + 660 + // Buffer ahead in the background (lazy — only when buffer drops below 20s) 661 + (async () => { 662 + for (let i = segIdx + 1; i < pl.segments.length && i < segIdx + 60; i++) { 663 + if (stale()) return; 664 + // Wait if we have plenty buffered 665 + while (video.buffered.length > 0 && 666 + video.buffered.end(0) - video.currentTime > 20) { 667 + await new Promise(r => setTimeout(r, 2000)); 668 + if (stale()) return; 669 + } 670 + const s = pl.segments[i]; 671 + try { 672 + const buf = await fetchRange(resolveUrl(s.uri), s.offset, s.length); 673 + if (stale()) return; 674 + if (sb.updating) await new Promise(r => sb.addEventListener('updateend', r, { once: true })); 675 + sb.appendBuffer(buf); 676 + await new Promise(r => sb.addEventListener('updateend', r, { once: true })); 677 + } catch (e) { 678 + console.warn('[TV] Segment error:', e); 679 + break; 680 + } 681 + } 682 + })(); 683 + 684 + // Load audio in background. Audio is a separate <audio> element synced to 685 + // the video because it can't share the video's MediaSource (see notes). 686 + const audioSegStartTime = segStartTime; 687 + (async () => { 688 + try { 689 + const audioPlaylistUrl = `${PLAYBACK}/place.stream.playback.getVideoPlaylist?uri=${encodedUri}&track=2`; 690 + const audioText = await (await fetch(audioPlaylistUrl)).text(); 691 + if (stale()) return; 692 + const audioPl = parsePlaylist(audioText); 693 + if (!audioPl.segments.length) return; 694 + const audioSeg = audioPl.segments[0]; 695 + const audioInitUrl = resolveUrl(audioPl.initUri); 696 + const audioBlobUrl = resolveUrl(audioSeg.uri); 697 + 698 + console.log(`[TV] Loading audio: ${(audioSeg.length/1024/1024).toFixed(1)}MB at offset ${audioSeg.offset}`); 699 + 700 + const audioInit = await fetch(audioInitUrl, { cache: 'no-store' }).then(r => r.arrayBuffer()); 701 + if (stale()) return; 702 + 703 + let audioData; 704 + const end = audioSeg.offset + audioSeg.length - 1; 705 + const rangeRes = await fetch(audioBlobUrl, { 706 + headers: { Range: `bytes=${audioSeg.offset}-${end}` }, 707 + cache: 'no-store', 708 + }); 709 + if (rangeRes.status === 206) { 710 + console.log('[TV] Audio: range worked'); 711 + audioData = await rangeRes.arrayBuffer(); 712 + } else { 713 + console.log('[TV] Audio: downloading full blob to slice...'); 714 + const full = await rangeRes.arrayBuffer(); 715 + if (stale()) return; 716 + audioData = full.slice(audioSeg.offset, audioSeg.offset + audioSeg.length); 717 + } 718 + if (stale()) return; 719 + 720 + console.log(`[TV] Audio data: ${(audioData.byteLength/1024/1024).toFixed(1)}MB, creating blob`); 721 + 722 + // Blob must be "video/mp4" not "audio/mp4" — browsers need the full 723 + // fMP4 container type to parse the init segment's moov atom correctly. 724 + const blob = new Blob([audioInit, audioData], { type: 'video/mp4' }); 725 + audioEl.src = URL.createObjectURL(blob); 726 + 727 + // Wait for browser to parse the fMP4. Neither loadedmetadata nor canplay 728 + // fires reliably for all fMP4 blobs, so we also use a 3s timeout. 729 + await new Promise((resolve, reject) => { 730 + const onReady = () => { cleanup(); resolve(); }; 731 + const onError = () => { cleanup(); reject(audioEl.error); }; 732 + const cleanup = () => { 733 + audioEl.removeEventListener('loadedmetadata', onReady); 734 + audioEl.removeEventListener('canplay', onReady); 735 + audioEl.removeEventListener('error', onError); 736 + }; 737 + audioEl.addEventListener('loadedmetadata', onReady, { once: true }); 738 + audioEl.addEventListener('canplay', onReady, { once: true }); 739 + audioEl.addEventListener('error', onError, { once: true }); 740 + setTimeout(resolve, 3000); 741 + }); 742 + if (stale()) return; 743 + 744 + console.log('[TV] Audio loaded, readyState=' + audioEl.readyState, 'duration=' + audioEl.duration); 745 + audioEl.currentTime = audioSegStartTime + video.currentTime; 746 + console.log('[TV] Audio seeking to', audioEl.currentTime.toFixed(1) + 's'); 747 + 748 + // Fade audio in over 1.5s — less jarring than a hard cut 749 + audioEl.volume = 0; 750 + audioEl.play().catch(e => console.warn('[TV] Audio play failed:', e)); 751 + const fadeStart = performance.now(); 752 + const fadeDuration = 1500; 753 + (function fadeIn() { 754 + const elapsed = performance.now() - fadeStart; 755 + audioEl.volume = Math.min(1, elapsed / fadeDuration); 756 + if (elapsed < fadeDuration) requestAnimationFrame(fadeIn); 757 + })(); 758 + 759 + // Hide muted indicator 760 + mutedIndicator.classList.add('fade-out'); 761 + mutedIndicator.classList.remove('visible'); 762 + } catch (e) { 763 + console.warn('[TV] Audio load failed (non-fatal):', e); 764 + } 765 + })(); 766 + 767 + // Keep audio synced to video via RAF (~60fps instead of ~4fps timeupdate) 768 + (function syncLoop() { 769 + if (stale()) return; 770 + if (audioEl.readyState >= 2 && !audioEl.paused && !video.paused) { 771 + const expected = audioSegStartTime + video.currentTime; 772 + const drift = audioEl.currentTime - expected; 773 + if (Math.abs(drift) > 0.5) { 774 + // Large drift — hard seek 775 + audioEl.currentTime = expected; 776 + audioEl.playbackRate = 1; 777 + } else if (Math.abs(drift) > 0.05) { 778 + // Small drift — nudge playback rate to catch up/slow down 779 + audioEl.playbackRate = drift > 0 ? 0.97 : 1.03; 780 + } else { 781 + audioEl.playbackRate = 1; 782 + } 783 + } 784 + requestAnimationFrame(syncLoop); 785 + })(); 786 + 787 + } catch (e) { 788 + if (e.message === 'RANGE_NOT_SUPPORTED') { 789 + console.warn('[TV] Range not supported for this video, trying another...'); 790 + switching = false; 791 + changeChannel(); 792 + return; 793 + } 794 + console.warn('[TV] Playback error:', e); 795 + stopStatic(); 796 + loadingIndicator.classList.remove('visible'); 797 + } 798 + 799 + switching = false; 800 + } 801 + 802 + // --- Events --- 803 + dial.addEventListener('click', changeChannel); 804 + dial.addEventListener('keydown', (e) => { 805 + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); changeChannel(); } 806 + }); 807 + document.addEventListener('keydown', (e) => { 808 + if (e.key === ' ' || e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown') { 809 + e.preventDefault(); 810 + changeChannel(); 811 + } 812 + }); 813 + 814 + // --- Init --- 815 + (async () => { 816 + try { 817 + vods = await fetchVods(); 818 + console.log(`Loaded ${vods.length} VODs`); 819 + } catch (e) { 820 + console.error('Failed to load VODs:', e); 821 + } 822 + })(); 823 + })(); 824 + </script> 825 + 826 + </body> 827 + </html>
+85
public/working-vods.json
··· 1 + [ 2 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miah6keewv2n", 3 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miafywhnca2r", 4 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miafuv53nh2z", 5 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miaef2b4mh2h", 6 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miaec7exqi25", 7 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miadd43al32c", 8 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miacona6fc2e", 9 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miacmwnd2z2z", 10 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miac5ez6a22r", 11 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miac3urhgt23", 12 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miac22soys22", 13 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miabh2g67c2c", 14 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miabfvrtei26", 15 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mia5q5vu4q2h", 16 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mia3vqrg4r23", 17 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mia3vm5oyd2u", 18 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mia3svkvjw22", 19 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7zxhekcu2d", 20 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7tqadgv22d", 21 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7tbwgymp25", 22 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7sb5disa2y", 23 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7rvqdpj722", 24 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7pjfdkhv2b", 25 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7o5amqqx2d", 26 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7mb4jbag23", 27 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7ldfbjwe26", 28 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7lapdbjc2t", 29 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7kaogqjs22", 30 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7jx4xi2g25", 31 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7jorroaa2h", 32 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7jnnnh7i2h", 33 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7isob4ae2a", 34 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5xky7ogz23", 35 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5wqwpqx72z", 36 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5wnpkchp2z", 37 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5wj4mc6t2e", 38 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5uqr2spg2l", 39 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5stzyxji2e", 40 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5sn7zmfy23", 41 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5s5mqmgj2r", 42 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5s4foj7h2a", 43 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5s2x6tkd2d", 44 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5rnhjugb2h", 45 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5rm4y7ts2b", 46 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5rl4r4a725", 47 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5r2rp7a62e", 48 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5qzkwfe42z", 49 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5qywid6z25", 50 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5q4ey4232y", 51 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5q2kpn7o2m", 52 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5n54d6r42u", 53 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5mvmovsn2i", 54 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5lb763vn2j", 55 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5k3brfax2b", 56 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5jrjmk6y2n", 57 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5itmi65s2z", 58 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5hza73vs2z", 59 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5hx4v6cr26", 60 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5csvtgdg22", 61 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5c65iee42h", 62 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5bya72ub2d", 63 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5ag3scgk2k", 64 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5aarspma27", 65 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5a2y3tej2z", 66 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi56l4hpil2x", 67 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi54oonum62b", 68 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi54lyc5ts25", 69 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi54jqrm372z", 70 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi4zoce3a22j", 71 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi4ziviq2d25", 72 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi4y35l2f425", 73 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi3e7ly25h2b", 74 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi3cjemc6a27", 75 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi3ar75zqs23", 76 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi34ps3oym2i", 77 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi33ycoo5d2a", 78 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi2ubr4zq72d", 79 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi2u6pl6ah2z", 80 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi2tdpailb2m", 81 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi2son7re62m", 82 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi2ryxrnin2a", 83 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi2rtfj5ec2m", 84 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi2ikg6gij26" 85 + ]
+5
wrangler.toml
··· 1 + name = "atmosphereconf-vod-crt" 2 + compatibility_date = "2024-12-01" 3 + 4 + [assets] 5 + directory = "./public"