atmo.rsvp
3
fork

Configure Feed

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

Merge pull request #5 from flo-bit/feat/atmosphere-conf-vods

Feat/atmosphere conf vods

authored by

Florian and committed by
GitHub
6e364741 2d6ef5fb

+1104 -42
+2
package.json
··· 65 65 "@number-flow/svelte": "^0.4.0", 66 66 "@tailwindcss/typography": "^0.5.19", 67 67 "dompurify": "^3.3.3", 68 + "hls.js": "^1.6.15", 68 69 "maplibre-gl": "^5.21.1", 69 70 "marked": "^17.0.5", 70 71 "mode-watcher": "^1.1.0", 72 + "plyr": "^3.8.4", 71 73 "svelte-boring-avatars": "^1.2.6", 72 74 "svelte-maplibre-gl": "^1.0.3", 73 75 "valibot": "^1.3.1"
+6
pnpm-lock.yaml
··· 41 41 dompurify: 42 42 specifier: ^3.3.3 43 43 version: 3.3.3 44 + hls.js: 45 + specifier: ^1.6.15 46 + version: 1.6.15 44 47 maplibre-gl: 45 48 specifier: ^5.21.1 46 49 version: 5.21.1 ··· 50 53 mode-watcher: 51 54 specifier: ^1.1.0 52 55 version: 1.1.0(svelte@5.55.0) 56 + plyr: 57 + specifier: ^3.8.4 58 + version: 3.8.4 53 59 svelte-boring-avatars: 54 60 specifier: ^1.2.6 55 61 version: 1.2.6
+108
scripts/download-vods.ts
··· 1 + /** 2 + * Downloads all matched ATmosphereConf VOD MP4s using ffmpeg. 3 + * Uses the HLS playlist URL so both audio and video are muxed properly. 4 + * 5 + * Requires: ffmpeg 6 + * 7 + * Usage: npx tsx scripts/download-vods.ts [output-dir] [filter] 8 + * 9 + * Default output dir: ./vods 10 + * Optional filter: case-insensitive substring match on event title 11 + */ 12 + 13 + import { existsSync, mkdirSync } from 'fs'; 14 + import { execFile } from 'child_process'; 15 + import vodMap from '../src/lib/vod-map.json'; 16 + 17 + const STREAM_PLACE_DID = 'did:plc:rbvrr34edl5ddpuwcubjiost'; 18 + const PLAYBACK_BASE = 'https://vod-beta.stream.place/xrpc/place.stream.playback.getVideoPlaylist'; 19 + 20 + const outDir = process.argv[2] || './vods'; 21 + const filter = process.argv[3]?.toLowerCase(); 22 + 23 + function sanitizeFilename(name: string): string { 24 + return name.replace(/[/\\?%*:|"<>]/g, '-').replace(/\s+/g, ' ').trim(); 25 + } 26 + 27 + function getPlaylistUrl(vodRkey: string): string { 28 + const uri = `at://${STREAM_PLACE_DID}/place.stream.video/${vodRkey}`; 29 + return `${PLAYBACK_BASE}?uri=${encodeURIComponent(uri)}`; 30 + } 31 + 32 + function downloadWithFfmpeg(playlistUrl: string, dest: string): Promise<void> { 33 + return new Promise((resolve, reject) => { 34 + const proc = execFile( 35 + 'ffmpeg', 36 + [ 37 + '-y', 38 + '-i', playlistUrl, 39 + '-c', 'copy', 40 + dest 41 + ], 42 + { maxBuffer: 10 * 1024 * 1024 }, 43 + (err) => { 44 + if (err) reject(err); 45 + else resolve(); 46 + } 47 + ); 48 + 49 + // Show ffmpeg progress 50 + proc.stderr?.on('data', (data: Buffer) => { 51 + const line = data.toString(); 52 + const timeMatch = line.match(/time=(\d+:\d+:\d+\.\d+)/); 53 + if (timeMatch) { 54 + process.stdout.write(`\r time: ${timeMatch[1]} `); 55 + } 56 + }); 57 + }); 58 + } 59 + 60 + async function main() { 61 + if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true }); 62 + 63 + const entries = filter 64 + ? vodMap.filter((e) => e.eventTitle.toLowerCase().includes(filter)) 65 + : vodMap; 66 + 67 + if (entries.length === 0) { 68 + console.log(`No VODs matching "${filter}"`); 69 + return; 70 + } 71 + 72 + console.log(`Downloading ${entries.length} VOD${entries.length > 1 ? 's' : ''} to ${outDir}/\n`); 73 + 74 + let downloaded = 0; 75 + let skipped = 0; 76 + let failed = 0; 77 + 78 + for (const entry of entries) { 79 + const filename = `${sanitizeFilename(entry.eventTitle)}.mp4`; 80 + const dest = `${outDir}/${filename}`; 81 + 82 + if (existsSync(dest)) { 83 + console.log(` SKIP ${filename} (already exists)`); 84 + skipped++; 85 + continue; 86 + } 87 + 88 + console.log(` GET ${filename}`); 89 + 90 + try { 91 + const playlistUrl = getPlaylistUrl(entry.vodRkey); 92 + await downloadWithFfmpeg(playlistUrl, dest); 93 + process.stdout.write('\r done \n'); 94 + downloaded++; 95 + } catch (e) { 96 + process.stdout.write('\n'); 97 + console.log(` FAILED (${e instanceof Error ? e.message : e})`); 98 + failed++; 99 + } 100 + } 101 + 102 + console.log(`\nDone: ${downloaded} downloaded, ${skipped} skipped, ${failed} failed`); 103 + } 104 + 105 + main().catch((e) => { 106 + console.error(e); 107 + process.exit(1); 108 + });
+176
scripts/match-vods.ts
··· 1 + /** 2 + * Matches ATmosphereConf events to stream.place VODs by title. 3 + * Outputs a JSON mapping to src/lib/vod-map.json. 4 + * 5 + * Usage: npx tsx scripts/match-vods.ts 6 + */ 7 + 8 + const STREAM_PLACE_DID = 'did:plc:rbvrr34edl5ddpuwcubjiost'; 9 + const STREAM_PLACE_PDS = 'https://iameli.com'; 10 + const VOD_COLLECTION = 'place.stream.video'; 11 + const CONTRAIL_URL = 'http://contrail.atmo.rsvp'; 12 + 13 + interface VodRecord { 14 + uri: string; 15 + rkey: string; 16 + title: string; 17 + } 18 + 19 + interface EventRecord { 20 + uri: string; 21 + rkey: string; 22 + name: string; 23 + type?: string; 24 + } 25 + 26 + interface VodMapping { 27 + eventRkey: string; 28 + eventTitle: string; 29 + vodRkey: string; 30 + vodTitle: string; 31 + } 32 + 33 + const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, ''); 34 + 35 + function extractRkey(uri: string): string { 36 + return uri.split('/').pop()!; 37 + } 38 + 39 + async function fetchAllVods(): Promise<VodRecord[]> { 40 + const all: VodRecord[] = []; 41 + let cursor: string | undefined; 42 + 43 + do { 44 + const params = new URLSearchParams({ 45 + repo: STREAM_PLACE_DID, 46 + collection: VOD_COLLECTION, 47 + limit: '100' 48 + }); 49 + if (cursor) params.set('cursor', cursor); 50 + 51 + const res = await fetch(`${STREAM_PLACE_PDS}/xrpc/com.atproto.repo.listRecords?${params}`); 52 + if (!res.ok) break; 53 + 54 + const data = (await res.json()) as { 55 + cursor?: string; 56 + records: Array<{ uri: string; value: { title: string } }>; 57 + }; 58 + 59 + for (const r of data.records ?? []) { 60 + all.push({ 61 + uri: r.uri, 62 + rkey: extractRkey(r.uri), 63 + title: r.value.title 64 + }); 65 + } 66 + cursor = data.cursor; 67 + } while (cursor); 68 + 69 + return all; 70 + } 71 + 72 + async function fetchAllEvents(): Promise<EventRecord[]> { 73 + const res = await fetch( 74 + `${CONTRAIL_URL}/xrpc/community.lexicon.calendar.event.listRecords?actor=atmosphereconf.org&sort=startsAt&order=asc&limit=200` 75 + ); 76 + if (!res.ok) throw new Error(`Failed to fetch events: ${res.status}`); 77 + const data = (await res.json()) as { 78 + records: Array<{ 79 + uri: string; 80 + record: { name: string; additionalData?: { isAtmosphereconf?: boolean; type?: string } }; 81 + }>; 82 + }; 83 + 84 + return data.records 85 + .filter((r) => r.record?.additionalData?.isAtmosphereconf) 86 + .map((r) => ({ 87 + uri: r.uri, 88 + rkey: extractRkey(r.uri), 89 + name: r.record.name, 90 + type: r.record.additionalData?.type 91 + })); 92 + } 93 + 94 + function matchEvents(events: EventRecord[], vods: VodRecord[]): VodMapping[] { 95 + const mappings: VodMapping[] = []; 96 + const usedVods = new Set<string>(); 97 + 98 + for (const event of events) { 99 + const eventNorm = normalize(event.name); 100 + 101 + // Exact match 102 + let match = vods.find((v) => !usedVods.has(v.uri) && normalize(v.title) === eventNorm); 103 + 104 + // Substring match 105 + if (!match && eventNorm.length >= 10) { 106 + match = vods.find((v) => { 107 + if (usedVods.has(v.uri)) return false; 108 + const vodNorm = normalize(v.title); 109 + return vodNorm.length >= 10 && (eventNorm.includes(vodNorm) || vodNorm.includes(eventNorm)); 110 + }); 111 + } 112 + 113 + if (match) { 114 + usedVods.add(match.uri); 115 + mappings.push({ 116 + eventRkey: event.rkey, 117 + eventTitle: event.name, 118 + vodRkey: match.rkey, 119 + vodTitle: match.title 120 + }); 121 + } 122 + } 123 + 124 + return mappings; 125 + } 126 + 127 + async function main() { 128 + console.log('Fetching VODs...'); 129 + const vods = await fetchAllVods(); 130 + console.log(`Found ${vods.length} VODs`); 131 + 132 + console.log('Fetching events...'); 133 + const events = await fetchAllEvents(); 134 + console.log(`Found ${events.length} atmosphere conf events`); 135 + 136 + const mappings = matchEvents(events, vods); 137 + console.log(`\nMatched ${mappings.length} of ${events.length} events to VODs`); 138 + 139 + // Show unmatched events 140 + const matchedRkeys = new Set(mappings.map((m) => m.eventRkey)); 141 + const unmatched = events.filter((e) => !matchedRkeys.has(e.rkey)); 142 + if (unmatched.length > 0) { 143 + console.log(`\nUnmatched events (${unmatched.length}):`); 144 + for (const e of unmatched) { 145 + console.log(` [${e.type || '?'}] ${e.name}`); 146 + } 147 + } 148 + 149 + // Show unmatched VODs 150 + const matchedVodRkeys = new Set(mappings.map((m) => m.vodRkey)); 151 + const unmatchedVods = vods.filter((v) => !matchedVodRkeys.has(v.rkey)); 152 + if (unmatchedVods.length > 0) { 153 + console.log(`\nUnmatched VODs (${unmatchedVods.length}):`); 154 + for (const v of unmatchedVods) { 155 + console.log(` - ${v.title}`); 156 + } 157 + } 158 + 159 + // Write the mapping 160 + const outPath = new URL('../src/lib/vod-map.json', import.meta.url); 161 + const output = mappings.map(({ eventRkey, vodRkey, eventTitle, vodTitle }) => ({ 162 + eventRkey, 163 + vodRkey, 164 + eventTitle, 165 + vodTitle 166 + })); 167 + const { writeFileSync } = await import('fs'); 168 + const { fileURLToPath } = await import('url'); 169 + writeFileSync(fileURLToPath(outPath), JSON.stringify(output, null, '\t') + '\n'); 170 + console.log(`\nWrote ${mappings.length} mappings to src/lib/vod-map.json`); 171 + } 172 + 173 + main().catch((e) => { 174 + console.error(e); 175 + process.exit(1); 176 + });
+85
src/lib/components/VodPlayer.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import 'plyr/dist/plyr.css'; 4 + import type HlsType from 'hls.js'; 5 + import type PlyrType from 'plyr'; 6 + 7 + let { playlistUrl, title }: { playlistUrl: string; title: string } = $props(); 8 + 9 + let videoEl: HTMLVideoElement | undefined = $state(); 10 + let error = $state(false); 11 + 12 + let hls: HlsType | null = null; 13 + let plyr: PlyrType | null = null; 14 + 15 + onMount(() => { 16 + init(); 17 + return () => { 18 + hls?.destroy(); 19 + plyr?.destroy(); 20 + }; 21 + }); 22 + 23 + async function init() { 24 + if (!videoEl) return; 25 + 26 + try { 27 + const [{ default: Plyr }, { default: Hls }] = await Promise.all([ 28 + import('plyr'), 29 + import('hls.js') 30 + ]); 31 + 32 + if (Hls.isSupported()) { 33 + hls = new Hls({ autoStartLoad: false }); 34 + hls.loadSource(playlistUrl); 35 + hls.attachMedia(videoEl); 36 + hls.on(Hls.Events.ERROR, (_event, data) => { 37 + if (data.fatal) { 38 + if (data.type === Hls.ErrorTypes.NETWORK_ERROR) { 39 + hls?.startLoad(); 40 + } else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { 41 + hls?.recoverMediaError(); 42 + } else { 43 + error = true; 44 + } 45 + } 46 + }); 47 + } else if (videoEl.canPlayType('application/vnd.apple.mpegurl')) { 48 + videoEl.src = playlistUrl; 49 + } else { 50 + error = true; 51 + return; 52 + } 53 + 54 + plyr = new Plyr(videoEl, { 55 + controls: ['play-large', 'play', 'progress', 'current-time', 'mute', 'volume', 'settings', 'fullscreen'], 56 + settings: ['speed'], 57 + speed: { selected: 1, options: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2] }, 58 + ratio: '16:9' 59 + }); 60 + 61 + // When user clicks play, tell HLS to start loading segments 62 + plyr.on('play', () => { 63 + hls?.startLoad(); 64 + }); 65 + } catch { 66 + error = true; 67 + } 68 + } 69 + </script> 70 + 71 + {#if error} 72 + <div class="bg-base-100 dark:bg-base-900 border-base-200 dark:border-base-800 flex aspect-video w-full items-center justify-center rounded-xl border"> 73 + <p class="text-base-500 dark:text-base-400 text-sm">Failed to load video</p> 74 + </div> 75 + {:else} 76 + <div class="border-base-300 dark:border-base-400/40 aspect-video w-full max-w-full overflow-hidden rounded-xl border"> 77 + <video bind:this={videoEl} class="h-full w-full" aria-label={title}></video> 78 + </div> 79 + {/if} 80 + 81 + <style> 82 + * { 83 + --plyr-color-main: var(--color-accent-500); 84 + } 85 + </style>
+596
src/lib/vod-map.json
··· 1 + [ 2 + { 3 + "eventRkey": "thurs-lunch", 4 + "vodRkey": "3mi5csvtgdg22", 5 + "eventTitle": "Lunch Break (Not Catered)", 6 + "vodTitle": "Lunch Break" 7 + }, 8 + { 9 + "eventRkey": "Y5XGQQ6", 10 + "vodRkey": "3miabfvrtei26", 11 + "eventTitle": "Abstracting the Appview Workshop", 12 + "vodTitle": "Abstracting the AppView" 13 + }, 14 + { 15 + "eventRkey": "ats26-open", 16 + "vodRkey": "3mi7jorroaa2h", 17 + "eventTitle": "Opening Remarks", 18 + "vodTitle": "Day 2 Opening Remarks!" 19 + }, 20 + { 21 + "eventRkey": "ats26-keynote", 22 + "vodRkey": "3mi2jdevvu626", 23 + "eventTitle": "Keynote: Towards Modular Open Science", 24 + "vodTitle": "Keynote: Towards Modular Open Science with @row1.ca and @matsulab.com" 25 + }, 26 + { 27 + "eventRkey": "ats26-commons", 28 + "vodRkey": "3mi2rtfj5ec2m", 29 + "eventTitle": "Can decentralists cooperate? Rethinking commons and collective action in the age of platforms and AI", 30 + "vodTitle": "Can decentralists cooperate? Rethinking commons and collective action in the age of platforms and AI" 31 + }, 32 + { 33 + "eventRkey": "ats26-biokea", 34 + "vodRkey": "3mi2ryxrnin2a", 35 + "eventTitle": "Reproducible, citation-aware automated paper reviews", 36 + "vodTitle": "Reproducible, citation-aware automated paper reviews @seanjungblluth.bsky.social" 37 + }, 38 + { 39 + "eventRkey": "ats26-skysquare", 40 + "vodRkey": "3mi2u6pl6ah2z", 41 + "eventTitle": "Skysquare is context as a service", 42 + "vodTitle": "Skysquare is context as a service - Travis Simpson - @skysquare.app" 43 + }, 44 + { 45 + "eventRkey": "ats26-viewsift", 46 + "vodRkey": "3mi2son7re62m", 47 + "eventTitle": "Building collective intelligence to reduce division at ViewSift", 48 + "vodTitle": "Building collective intelligence to reduce division at ViewSift @viewsift.com" 49 + }, 50 + { 51 + "eventRkey": "ats26-seams", 52 + "vodRkey": "3mi2tfsbq4p25", 53 + "eventTitle": "Making wisdom together", 54 + "vodTitle": "Making wisdom together - seams.so - @hyl.st" 55 + }, 56 + { 57 + "eventRkey": "ats26-astrosky", 58 + "vodRkey": "3mi2wu5u5rs2i", 59 + "eventTitle": "The Astrosky Ecosystem: An independent online home for astronomy", 60 + "vodTitle": "The Astrosky Ecosystem: An independent online home for astronomy" 61 + }, 62 + { 63 + "eventRkey": "ats26-our-socialmedia-future", 64 + "vodRkey": "3mi33ycoo5d2a", 65 + "eventTitle": "Future of Science Social Media", 66 + "vodTitle": "Future of Science Social Media" 67 + }, 68 + { 69 + "eventRkey": "ats26-research-synthesis", 70 + "vodRkey": "3mi34ps3oym2i", 71 + "eventTitle": "Crowdsourced Research Synthesis on ATProto: Envisioning an Inclusive Future", 72 + "vodTitle": "Crowdsourced Research Synthesis on ATProto: Envisioning an Inclusive Future - Jay Patel - @infotainment.bsky.social" 73 + }, 74 + { 75 + "eventRkey": "ats26-paperskygest", 76 + "vodRkey": "3mi35eplsua23", 77 + "eventTitle": "Studying social media through the Atmosphere", 78 + "vodTitle": "Studying social media through the Atmosphere - " 79 + }, 80 + { 81 + "eventRkey": "ats26-comm-archive", 82 + "vodRkey": "3mi36lcxgce2b", 83 + "eventTitle": "Narrative strands & memetic lineages in community social data using Community Archive", 84 + "vodTitle": "Narrative strands & memetic lineages in community social data using Community Archive - " 85 + }, 86 + { 87 + "eventRkey": "ats26-decentralize", 88 + "vodRkey": "3mi5r2rp7a62e", 89 + "eventTitle": "How (de)centralized is Bluesky, really?", 90 + "vodTitle": "How (de)centralized is Bluesky, really?" 91 + }, 92 + { 93 + "eventRkey": "J9yOpYz", 94 + "vodRkey": "3mi54lyc5ts25", 95 + "eventTitle": "The Aggregation Era burned journalism institutions to the ground. The federated era is emerging from those embers", 96 + "vodTitle": "The Aggregation Era burned journalism institutions to the ground. The federated era is emerging from those embers" 97 + }, 98 + { 99 + "eventRkey": "BzrpDQK", 100 + "vodRkey": "3mi54nec66s2w", 101 + "eventTitle": "Feature / Product / Business: A Framework for Sustainable ATProto Projects", 102 + "vodTitle": "Feature / Product / Business: A Framework for Sustainable ATProto Projects" 103 + }, 104 + { 105 + "eventRkey": "QK9Ae6Y", 106 + "vodRkey": "3mi54oonum62b", 107 + "eventTitle": "Groundings with my Siblings: Lessons Learned Building for Community", 108 + "vodTitle": "Groundings with my Siblings: Lessons Learned Building for Community" 109 + }, 110 + { 111 + "eventRkey": "obLbvQV", 112 + "vodRkey": "3mi56m3hnrq2z", 113 + "eventTitle": "The Economics of Sovereign Media: A Roadmap for AT Protocol", 114 + "vodTitle": "The Economics of Sovereign Media: A Roadmap for AT Protocol" 115 + }, 116 + { 117 + "eventRkey": "obaP26x", 118 + "vodRkey": "3mi56n6j2g22d", 119 + "eventTitle": "Who owns the group chat? Building collaborative spaces on ATProto", 120 + "vodTitle": "Who owns the group chat? Building collaborative spaces on ATProto" 121 + }, 122 + { 123 + "eventRkey": "gDPLaGd", 124 + "vodRkey": "3mi56pnvulh2o", 125 + "eventTitle": "Did Lexicon just accidentally solve the enterprise data problem?", 126 + "vodTitle": "Did Lexicon just accidentally solve the enterprise data problem?" 127 + }, 128 + { 129 + "eventRkey": "ODxNLMM", 130 + "vodRkey": "3mi5a2y3tej2z", 131 + "eventTitle": "This isn't over until we all listen to kpop", 132 + "vodTitle": "This isn't over until we all listen to kpop" 133 + }, 134 + { 135 + "eventRkey": "81VNEBO", 136 + "vodRkey": "3mi5aarspma27", 137 + "eventTitle": "Creators First: Video & Media as the Foundation of a Thriving Creator Economy on ATProto", 138 + "vodTitle": "Creators First: Video & Media as the Foundation of a Thriving Creator Economy on ATProto" 139 + }, 140 + { 141 + "eventRkey": "9qkWqPG", 142 + "vodRkey": "3mi5ag3scgk2k", 143 + "eventTitle": "Building Future of Artificial Intelligence on AT Protocol", 144 + "vodTitle": "Building Future of Artificial Intelligence on AT Protocol" 145 + }, 146 + { 147 + "eventRkey": "VLa69bl", 148 + "vodRkey": "3mi5bya72ub2d", 149 + "eventTitle": "A discussion with news creators", 150 + "vodTitle": "A discussion with news creators" 151 + }, 152 + { 153 + "eventRkey": "7Rrv0E0", 154 + "vodRkey": "3mi5bcpyqg32a", 155 + "eventTitle": "Beyond Bluesky: Community infrastructure", 156 + "vodTitle": "Beyond Bluesky: Community infrastructure" 157 + }, 158 + { 159 + "eventRkey": "VLerG2y", 160 + "vodRkey": "3mi5c65iee42h", 161 + "eventTitle": "Understanding the Landscape of Custom Feeds on Bluesky", 162 + "vodTitle": "Understanding the Landscape of Custom Feeds on Bluesky" 163 + }, 164 + { 165 + "eventRkey": "000Syverson", 166 + "vodRkey": "3mi5hza73vs2z", 167 + "eventTitle": "Sattestations", 168 + "vodTitle": "Sattestations" 169 + }, 170 + { 171 + "eventRkey": "QKlrLXG", 172 + "vodRkey": "3mi5hx4v6cr26", 173 + "eventTitle": "Advocating for Digital Sovereignty: European Experiences and Global Lessons", 174 + "vodTitle": "Advocating for Digital Sovereignty: European Experiences and Global Lessons" 175 + }, 176 + { 177 + "eventRkey": "XxP4RzL", 178 + "vodRkey": "3mi5hy457fu2r", 179 + "eventTitle": "Building Cirrus: a single-user, serverless PDS", 180 + "vodTitle": "Building Cirrus: a single-user, serverless PDS" 181 + }, 182 + { 183 + "eventRkey": "81Xovjr", 184 + "vodRkey": "3mi5itmi65s2z", 185 + "eventTitle": "Feeds Are The New Websites", 186 + "vodTitle": "Feeds Are The New Websites" 187 + }, 188 + { 189 + "eventRkey": "LZxV6dv", 190 + "vodRkey": "3mi5jmsg6kn2m", 191 + "eventTitle": "Consent Before Cryptography", 192 + "vodTitle": "Consent Before Cryptography" 193 + }, 194 + { 195 + "eventRkey": "MelQ8Ak", 196 + "vodRkey": "3mi5k3brfax2b", 197 + "eventTitle": "Rethinking the Client: Why User Choice is the Key to Growth for ATProto", 198 + "vodTitle": "Rethinking the Client: Why User Choice is the Key to Growth for ATProto" 199 + }, 200 + { 201 + "eventRkey": "OD2PpYA", 202 + "vodRkey": "3mi5jrjmk6y2n", 203 + "eventTitle": "Open social tech and geopolitical risk", 204 + "vodTitle": "Open social tech and geopolitical risk" 205 + }, 206 + { 207 + "eventRkey": "ja4ooAa", 208 + "vodRkey": "3mi5lexqbqf2z", 209 + "eventTitle": "Building Public-Interest Infrastructure on ATProto", 210 + "vodTitle": "Building Public-Interest Infrastructure on ATProto" 211 + }, 212 + { 213 + "eventRkey": "gDP6A8N", 214 + "vodRkey": "3mi5lb763vn2j", 215 + "eventTitle": "Account logic in ATProto using Trusted Execution Environments", 216 + "vodTitle": "Account logic in ATProto using Trusted Execution Environments" 217 + }, 218 + { 219 + "eventRkey": "Y561Qv6", 220 + "vodRkey": "3mi5ldvd46r2i", 221 + "eventTitle": "From protocol to product: How Expo powers the next wave of AT Proto applications", 222 + "vodTitle": "From protocol to product: How Expo powers the next wave of AT Proto applications" 223 + }, 224 + { 225 + "eventRkey": "aQ1J9GE", 226 + "vodRkey": "3mi5mvmovsn2i", 227 + "eventTitle": "2026 Atmosphere Report", 228 + "vodTitle": "join us in https://stream.place/stream1.atmosphereconf.org for 2026 Atmosphere Report" 229 + }, 230 + { 231 + "eventRkey": "Bzr448Q", 232 + "vodRkey": "3mi5q3oibtu2i", 233 + "eventTitle": "Why Gander Social?", 234 + "vodTitle": "Why Gander Social?" 235 + }, 236 + { 237 + "eventRkey": "9qP16Kp", 238 + "vodRkey": "3mi5q4ey4232y", 239 + "eventTitle": "Stop Hallucinating the Protocol: Grounding your AI Agents with the Official ATproto Docs", 240 + "vodTitle": "Stop Hallucinating the Protocol: Grounding your AI Agents with the Official ATproto Docs" 241 + }, 242 + { 243 + "eventRkey": "OD6Gd0A", 244 + "vodRkey": "3mi5q57diht27", 245 + "eventTitle": "Semble: Rediscovering the Magic of Trails", 246 + "vodTitle": "Semble: Rediscovering the Magic of Trails" 247 + }, 248 + { 249 + "eventRkey": "000WSocial", 250 + "vodRkey": "3mi5qzkwfe42z", 251 + "eventTitle": "Who, Where, Why, What about W Social", 252 + "vodTitle": "Who, Where, Why, What about W Social" 253 + }, 254 + { 255 + "eventRkey": "QKZoLBX", 256 + "vodRkey": "3mi37ha3edb23", 257 + "eventTitle": "How (de)centralized is Bluesky, really?", 258 + "vodTitle": "How (de)centralized is Bluesky, really? - " 259 + }, 260 + { 261 + "eventRkey": "2EG4YMj", 262 + "vodRkey": "3mi5qywid6z25", 263 + "eventTitle": "What 350,000 users taught me about growing on Open Social", 264 + "vodTitle": "What 350,000 users taught me about growing on Open Social" 265 + }, 266 + { 267 + "eventRkey": "000Ryo", 268 + "vodRkey": "3mi5rl4r4a725", 269 + "eventTitle": "Bridging Social Graphs: How Sky Follower Bridge helps people move to Bluesky", 270 + "vodTitle": "Bridging Social Graphs: How Sky Follower Bridge helps people move to Bluesky" 271 + }, 272 + { 273 + "eventRkey": "Xxd7xqj", 274 + "vodRkey": "3mi5rnhjugb2h", 275 + "eventTitle": "E2EE DMs for Solidarity Social", 276 + "vodTitle": "E2EE DMs for Solidarity Social" 277 + }, 278 + { 279 + "eventRkey": "000Jer", 280 + "vodRkey": "3mi5rm4y7ts2b", 281 + "eventTitle": "The Future of Open Source is Social", 282 + "vodTitle": "The Future of Open Source is Social" 283 + }, 284 + { 285 + "eventRkey": "2EGLPML", 286 + "vodRkey": "3mi5s2x6tkd2d", 287 + "eventTitle": "Burning down data walls in the US Fire Service and Beyond", 288 + "vodTitle": "Burning down data walls in the US Fire Service and Beyond" 289 + }, 290 + { 291 + "eventRkey": "7Rr2zW6", 292 + "vodRkey": "3mi5s5mqmgj2r", 293 + "eventTitle": "Pollen: Prototyping a toolkit for journalists and researchers to restore source transparency in an AI-saturated feed", 294 + "vodTitle": "Pollen: Prototyping a toolkit for journalists and researchers to restore source transparency in an AI-saturated feed" 295 + }, 296 + { 297 + "eventRkey": "lbkWPeN", 298 + "vodRkey": "3mi5s4foj7h2a", 299 + "eventTitle": "Oaklog: Building a community calendar in the Oakland Bay Area", 300 + "vodTitle": "Oaklog: Building a community calendar in the Oakland Bay Area" 301 + }, 302 + { 303 + "eventRkey": "RGqppqd", 304 + "vodRkey": "3mi5stzyxji2e", 305 + "eventTitle": "How Streamplace Works: VODs", 306 + "vodTitle": "How Streamplace Works: VODs" 307 + }, 308 + { 309 + "eventRkey": "EkGROKB", 310 + "vodRkey": "3mi5sn7zmfy23", 311 + "eventTitle": "A Free Press needs Free Protocols", 312 + "vodTitle": "A Free Press needs Free Protocols" 313 + }, 314 + { 315 + "eventRkey": "OD2G9j8", 316 + "vodRkey": "3mi5spj6izy2a", 317 + "eventTitle": "The Phoenix Architecture", 318 + "vodTitle": "The Phoenix Architecture" 319 + }, 320 + { 321 + "eventRkey": "J9EgEdX", 322 + "vodRkey": "3mi5uqr2spg2l", 323 + "eventTitle": "Hypercerts on ATProto: Collective Funding, Evaluation, and Ownership as Social Data", 324 + "vodTitle": "Hypercerts on ATProto: Collective Funding, Evaluation, and Ownership as Social Data" 325 + }, 326 + { 327 + "eventRkey": "PdJ6Q8d", 328 + "vodRkey": "3mi5unzkbat27", 329 + "eventTitle": "Journalism must create its own algorithms", 330 + "vodTitle": "Journalism must create its own algorithms" 331 + }, 332 + { 333 + "eventRkey": "rj8Xv62", 334 + "vodRkey": "3mi5unhqisv22", 335 + "eventTitle": "This Title Left Intentionally Blank", 336 + "vodTitle": "This Title Left Intentionally Blank" 337 + }, 338 + { 339 + "eventRkey": "day-3-closing-remarks", 340 + "vodRkey": "3miafr5amxa2m", 341 + "eventTitle": "Day 3 Closing Remarks", 342 + "vodTitle": "closing remarks" 343 + }, 344 + { 345 + "eventRkey": "VLXBbzJ", 346 + "vodRkey": "3mi7ldfbjwe26", 347 + "eventTitle": "How and Why News Organizations Should Build on the ATProtocol", 348 + "vodTitle": "How and Why News Organizations Should Build on the ATProtocol" 349 + }, 350 + { 351 + "eventRkey": "VLgVWM6", 352 + "vodRkey": "3mi7mb4jbag23", 353 + "eventTitle": "tangled: The Lewis end", 354 + "vodTitle": "tangled: The Lewis end" 355 + }, 356 + { 357 + "eventRkey": "Mej2N5X", 358 + "vodRkey": "3mi7lapdbjc2t", 359 + "eventTitle": "Roomy and community organizing for system change", 360 + "vodTitle": "Roomy and community organizing for system change" 361 + }, 362 + { 363 + "eventRkey": "WOkL11Q", 364 + "vodRkey": "3mi7ok5zmuk2k", 365 + "eventTitle": "How to have more non-english speaking users", 366 + "vodTitle": "How to have more non-english speaking users" 367 + }, 368 + { 369 + "eventRkey": "gDPMMa1", 370 + "vodRkey": "3mi7ofq7ob72a", 371 + "eventTitle": "One Year of Graze - lessons learned funding, building, and growing in the atmosphere", 372 + "vodTitle": "One Year of Graze - lessons learned funding, building, and growing in the atmosphere" 373 + }, 374 + { 375 + "eventRkey": "jaAWVRY", 376 + "vodRkey": "3mi7o5amqqx2d", 377 + "eventTitle": "Bringing Self Sovereign Identities to the Masses via ATproto (and how to maximize coherence between upcoming DID:PLC forks)", 378 + "vodTitle": "Bringing Self Sovereign Identities to the Masses via ATproto" 379 + }, 380 + { 381 + "eventRkey": "ODqQQJA", 382 + "vodRkey": "3mi7pjfdkhv2b", 383 + "eventTitle": "Waiting for the Future to Load", 384 + "vodTitle": "Waiting for the Future to Load" 385 + }, 386 + { 387 + "eventRkey": "1AzdYWM", 388 + "vodRkey": "3mi7pxl6xkz22", 389 + "eventTitle": "Bluenotes: Community Notes for ATProto", 390 + "vodTitle": "Bluenotes: Community Notes for ATProto" 391 + }, 392 + { 393 + "eventRkey": "zxRkxk8", 394 + "vodRkey": "3mi7q5mjlbe2c", 395 + "eventTitle": "Blousques: Case Study on the Challenges in Translating Bluesky's UI", 396 + "vodTitle": "Blousques: Case Study on the Challenges in Translating Bluesky's UI" 397 + }, 398 + { 399 + "eventRkey": "q4qlVLY", 400 + "vodRkey": "3mi7rpxijg725", 401 + "eventTitle": "Community privacy in a decentralized network", 402 + "vodTitle": "Community privacy in a decentralized network" 403 + }, 404 + { 405 + "eventRkey": "kdobWjj", 406 + "vodRkey": "3mi7rvqdpj722", 407 + "eventTitle": "A Fireside Chat on Resonant Computing: Why we wrote the manifesto and where we go from here", 408 + "vodTitle": "A Fireside Chat on Resonant Computing: Why we wrote the manifesto and where we go from here" 409 + }, 410 + { 411 + "eventRkey": "2E9XG1b", 412 + "vodRkey": "3mi7tcodsqu2t", 413 + "eventTitle": "Creating a Safer Web: Blacksky's Moderation Tool", 414 + "vodTitle": "Creating a Safer Web: Blacksky's Moderation Tool" 415 + }, 416 + { 417 + "eventRkey": "9q8ZX5Q", 418 + "vodRkey": "3mi7tqadgv22d", 419 + "eventTitle": "Social Components", 420 + "vodTitle": "Social Components" 421 + }, 422 + { 423 + "eventRkey": "EkexvrN", 424 + "vodRkey": "3mi7tbwgymp25", 425 + "eventTitle": "~~Compete or kill~~ Cooperate and Succeed!", 426 + "vodTitle": "Compete or kill Cooperate and Succeed!" 427 + }, 428 + { 429 + "eventRkey": "GxEe0Vz", 430 + "vodRkey": "3mi7ztge2sf2h", 431 + "eventTitle": "Designing for the social web", 432 + "vodTitle": "Designing for the social web" 433 + }, 434 + { 435 + "eventRkey": "LZ4oWrj", 436 + "vodRkey": "3mia26ffz2j2c", 437 + "eventTitle": "Two Years of Skywatch: Lessons Learned for Community Moderation", 438 + "vodTitle": "Two Years of Skywatch: Lessons Learned for Community Moderation" 439 + }, 440 + { 441 + "eventRkey": "q4QAgVk", 442 + "vodRkey": "3mi7zxhekcu2d", 443 + "eventTitle": "Building decentralized AI on atproto", 444 + "vodTitle": "Building decentralized AI on atproto" 445 + }, 446 + { 447 + "eventRkey": "xXWE9Dv", 448 + "vodRkey": "3mia3vqrg4r23", 449 + "eventTitle": "Data Sovereignty for Games (and Everything Else): Building Decentralized Industry Infrastructure on ATProto", 450 + "vodTitle": "Data Sovereignty for Games (and Everything Else): Building Decentralized Industry Infrastructure on ATProto" 451 + }, 452 + { 453 + "eventRkey": "0Qq9NlZ", 454 + "vodRkey": "3mia3vm5oyd2u", 455 + "eventTitle": "Coop: Open source Trust & Safety infrastructure for all", 456 + "vodTitle": "Coop: Open source Trust & Safety infrastructure for all" 457 + }, 458 + { 459 + "eventRkey": "WObY04Q", 460 + "vodRkey": "3mia3svkvjw22", 461 + "eventTitle": "furryli.st — Building Communities Without Landlords From the Protocol Up", 462 + "vodTitle": "furryli.st — Building Communities Without Landlords From the Protocol Up" 463 + }, 464 + { 465 + "eventRkey": "000WebTiles", 466 + "vodRkey": "3mia5q5vu4q2h", 467 + "eventTitle": "WebTiles Showcase", 468 + "vodTitle": "WebTiles Showcase" 469 + }, 470 + { 471 + "eventRkey": "rjQ96kl", 472 + "vodRkey": "3mia5khdija2h", 473 + "eventTitle": "Protocol Governance & Hard Decentralization", 474 + "vodTitle": "Protocol Governance & Hard Decentralization" 475 + }, 476 + { 477 + "eventRkey": "9qjDJZG", 478 + "vodRkey": "3mia5mbp4tf22", 479 + "eventTitle": "From Toilets to Moths: The Future of Social Media is Weird and Not For Everyone", 480 + "vodTitle": "From Toilets to Moths: The Future of Social Media is Weird and Not For Everyone" 481 + }, 482 + { 483 + "eventRkey": "81gXlXP", 484 + "vodRkey": "3mia74qg3622a", 485 + "eventTitle": "Building Bridgy, Not Walls", 486 + "vodTitle": "Building Bridgy, Not Walls" 487 + }, 488 + { 489 + "eventRkey": "5B02jaM", 490 + "vodRkey": "3mia7iun75l2x", 491 + "eventTitle": "Keywords vs Embeddings", 492 + "vodTitle": "Keywords vs Embeddings" 493 + }, 494 + { 495 + "eventRkey": "q4QdXj7", 496 + "vodRkey": "3mia7dt6fnp2m", 497 + "eventTitle": "ATProto design philosophy behind BookHive", 498 + "vodTitle": "ATProto design philosophy behind BookHive" 499 + }, 500 + { 501 + "eventRkey": "QKNkKMX", 502 + "vodRkey": "3miabh2g67c2c", 503 + "eventTitle": "Scaling the Atmosphere", 504 + "vodTitle": "Scaling the Atmosphere" 505 + }, 506 + { 507 + "eventRkey": "aQJAWl9", 508 + "vodRkey": "3miac22soys22", 509 + "eventTitle": "Affordances of the Atmosphere", 510 + "vodTitle": "Affordances of the Atmosphere" 511 + }, 512 + { 513 + "eventRkey": "ob8N65V", 514 + "vodRkey": "3miacmdq4sm2a", 515 + "eventTitle": "How to use Bluesky to easily and securely preview a software product to users.", 516 + "vodTitle": "How to use Bluesky to easily and securely preview a software product to users." 517 + }, 518 + { 519 + "eventRkey": "xX5yRJr", 520 + "vodRkey": "3miac5ez6a22r", 521 + "eventTitle": "Skylimit: A curating web client with fine-grained controls to mimic the newspaper experience", 522 + "vodTitle": "Skylimit: A curating web client with fine-grained controls to mimic the newspaper experience" 523 + }, 524 + { 525 + "eventRkey": "686gZde", 526 + "vodRkey": "3miac3urhgt23", 527 + "eventTitle": "Using GraphQL to build with ATProto", 528 + "vodTitle": "Using GraphQL to build with ATProto" 529 + }, 530 + { 531 + "eventRkey": "ZjL74D0", 532 + "vodRkey": "3miacmwnd2z2z", 533 + "eventTitle": "Jacquard Magic: how to make atproto actually easy with Rust", 534 + "vodTitle": "Jacquard Magic: how to make atproto actually easy with Rust" 535 + }, 536 + { 537 + "eventRkey": "RG6Nepp", 538 + "vodRkey": "3miadb23fme2a", 539 + "eventTitle": "Wherever You Get Your Podcasts: Interoperability in the Atmosphere", 540 + "vodTitle": "Wherever You Get Your Podcasts: Interoperability in the Atmosphere" 541 + }, 542 + { 543 + "eventRkey": "000TLog", 544 + "vodRkey": "3miacona6fc2e", 545 + "eventTitle": "AT Transparency Logs: accountable record collections", 546 + "vodTitle": "AT Transparency Logs: accountable record collections" 547 + }, 548 + { 549 + "eventRkey": "ZjMOl7o", 550 + "vodRkey": "3miadd43al32c", 551 + "eventTitle": "Matadata! Publishing scientific data straight to AT", 552 + "vodTitle": "Matadata! Publishing scientific data straight to AT" 553 + }, 554 + { 555 + "eventRkey": "A7YLlpl", 556 + "vodRkey": "3miaec7exqi25", 557 + "eventTitle": "An artist dreaming in the Atmosphere: visions about community, sustainability and creativity", 558 + "vodTitle": "An artist dreaming in the Atmosphere: visions about community, sustainability and creativity" 559 + }, 560 + { 561 + "eventRkey": "PdNOkAP", 562 + "vodRkey": "3miaef2b4mh2h", 563 + "eventTitle": "Rewilding the internet with ATProto", 564 + "vodTitle": "Rewilding the internet with ATProto" 565 + }, 566 + { 567 + "eventRkey": "QKqWDrG", 568 + "vodRkey": "3miadvkvhfv2h", 569 + "eventTitle": "DID:PLC War Games", 570 + "vodTitle": "DID:PLC War Games" 571 + }, 572 + { 573 + "eventRkey": "gDELD0M", 574 + "vodRkey": "3mi54jqrm372z", 575 + "eventTitle": "Landslide", 576 + "vodTitle": "(#KelpFacts) Landslide" 577 + }, 578 + { 579 + "eventRkey": "MeWBzWX", 580 + "vodRkey": "3mi7kaogqjs22", 581 + "eventTitle": "npmx: a modern browser for the npm registry", 582 + "vodTitle": "npmx - a fast, modern browser for the npm registry" 583 + }, 584 + { 585 + "eventRkey": "day-4-closing-remarks", 586 + "vodRkey": "3mi5wqocga62h", 587 + "eventTitle": "Day 4 Closing Remarks", 588 + "vodTitle": "Closing remarks, I guess, if i have to." 589 + }, 590 + { 591 + "eventRkey": "3mhzjk45462rg", 592 + "vodRkey": "3mi2ikg6gij26", 593 + "eventTitle": "ATScience Unconference", 594 + "vodTitle": "ATScience at ATmosphereConf 2026!" 595 + } 596 + ]
+32
src/lib/vods.ts
··· 1 + import vodMap from './vod-map.json'; 2 + 3 + const STREAM_PLACE_DID = 'did:plc:rbvrr34edl5ddpuwcubjiost'; 4 + const VOD_PLAYBACK_BASE = 'https://vod-beta.stream.place/xrpc/place.stream.playback.getVideoPlaylist'; 5 + 6 + export interface VodRecord { 7 + vodRkey: string; 8 + vodTitle: string; 9 + playlistUrl: string; 10 + } 11 + 12 + function makePlaylistUrl(vodRkey: string): string { 13 + const uri = `at://${STREAM_PLACE_DID}/place.stream.video/${vodRkey}`; 14 + return `${VOD_PLAYBACK_BASE}?uri=${encodeURIComponent(uri)}`; 15 + } 16 + 17 + const vodByEventRkey = new Map<string, VodRecord>(); 18 + for (const entry of vodMap) { 19 + vodByEventRkey.set(entry.eventRkey, { 20 + vodRkey: entry.vodRkey, 21 + vodTitle: entry.vodTitle, 22 + playlistUrl: makePlaylistUrl(entry.vodRkey) 23 + }); 24 + } 25 + 26 + export function getVodForEvent(eventRkey: string): VodRecord | null { 27 + return vodByEventRkey.get(eventRkey) ?? null; 28 + } 29 + 30 + export function getAllEventVods(): Map<string, VodRecord> { 31 + return vodByEventRkey; 32 + }
+4
src/routes/(app)/p/[actor]/e/[rkey]/+page.server.ts
··· 12 12 listEventAttendeesFromContrail, 13 13 RSVP_HYDRATE_LIMIT 14 14 } from '$lib/contrail'; 15 + import { getVodForEvent } from '$lib/vods'; 15 16 16 17 export async function load({ params, locals, url }) { 17 18 const { rkey } = params; ··· 42 43 const speakers = ((eventData.additionalData as Record<string, unknown> | undefined)?.speakers as 43 44 | Array<{ id: string; name: string }> 44 45 | undefined) ?? []; 46 + 47 + const vod = isAtmosphereconf ? getVodForEvent(rkey) : null; 45 48 46 49 const [attendees, viewerRsvpRecord, parentEvent, ...speakerProfiles] = await Promise.all([ 47 50 listEventAttendeesFromContrail(fullEventRecord.uri), ··· 77 80 viewerRsvpStatus: getRsvpStatus(viewerRsvpRecord?.record?.status), 78 81 viewerRsvpRkey: viewerRsvpRecord?.rkey ?? null, 79 82 parentEvent, 83 + vod, 80 84 speakerProfiles: speakerProfiles as Array<{ id?: string; name: string; avatar?: string; handle?: string }> 81 85 }; 82 86 } catch (e) {
+19 -9
src/routes/(app)/p/[actor]/e/[rkey]/+page.svelte
··· 9 9 import EventRsvp from '$lib/components/EventRsvp.svelte'; 10 10 import EventCard from '$lib/components/EventCard.svelte'; 11 11 import EventAttendees from './EventAttendees.svelte'; 12 + import VodPlayer from '$lib/components/VodPlayer.svelte'; 12 13 import { page } from '$app/state'; 13 - import { goto } from '$app/navigation'; 14 14 import { marked } from 'marked'; 15 15 import { sanitize } from '$lib/cal/sanitize'; 16 16 import { generateICalEvent } from '$lib/cal/ical'; ··· 178 178 ); 179 179 180 180 let isOngoing = $derived(isEventOngoing(eventData.startsAt, eventData.endsAt)); 181 + let isPast = $derived(endDate ? endDate < new Date() : false); 181 182 182 183 const renderer = new marked.Renderer(); 183 184 renderer.link = ({ href, text }) => ··· 372 373 </div> 373 374 {/if} 374 375 376 + <!-- VOD --> 377 + {#if data.vod} 378 + <div class="mb-6"> 379 + <VodPlayer playlistUrl={data.vod.playlistUrl} title={data.vod.vodTitle} /> 380 + </div> 381 + {/if} 382 + 375 383 <!-- Date row --> 376 384 <div class="mb-4 flex items-center gap-4"> 377 385 <div ··· 467 475 </Button> 468 476 {/if} 469 477 470 - <EventRsvp 471 - {eventUri} 472 - eventCid={eventData.cid ?? null} 473 - initialRsvpStatus={data.viewerRsvpStatus} 474 - initialRsvpRkey={data.viewerRsvpRkey} 475 - onrsvp={handleRsvp} 476 - oncancel={handleRsvpCancel} 477 - /> 478 + {#if !isPast} 479 + <EventRsvp 480 + {eventUri} 481 + eventCid={eventData.cid ?? null} 482 + initialRsvpStatus={data.viewerRsvpStatus} 483 + initialRsvpRkey={data.viewerRsvpRkey} 484 + onrsvp={handleRsvp} 485 + oncancel={handleRsvpCancel} 486 + /> 487 + {/if} 478 488 479 489 <!-- About Event --> 480 490 {#if descriptionHtml}
+11 -1
src/routes/(app)/p/atmosphereconf.org/+page.server.ts
··· 4 4 listEventRecordsFromContrail, 5 5 contrail 6 6 } from '$lib/contrail'; 7 + import { getAllEventVods, type VodRecord } from '$lib/vods'; 7 8 8 9 export async function load({ locals }) { 9 10 const actor = 'atmosphereconf.org'; ··· 40 41 } 41 42 } 42 43 44 + // Build event URI → VOD map from static mapping 45 + const vodsByRkey = getAllEventVods(); 46 + const eventVods: Record<string, VodRecord> = {}; 47 + for (const event of events) { 48 + const vod = vodsByRkey.get(event.rkey); 49 + if (vod) eventVods[event.uri] = vod; 50 + } 51 + 43 52 return { 44 53 hostProfile: profile, 45 54 events, 46 55 actor, 47 56 rsvpStatuses, 48 57 rsvpRkeys, 49 - loggedIn: !!locals.did 58 + loggedIn: !!locals.did, 59 + eventVods 50 60 }; 51 61 }
+20 -16
src/routes/(app)/p/atmosphereconf.org/+page.svelte
··· 25 25 let scheduleEvents = $derived(getScheduleEvents(data.events)); 26 26 let rsvpStatuses: Record<string, string> = $state(data.rsvpStatuses ?? {}); 27 27 let rsvpRkeys: Record<string, string> = $state(data.rsvpRkeys ?? {}); 28 + let eventVods = $derived(data.eventVods ?? {}); 28 29 let filterMode: string = $state('all'); 29 30 let selectedDay: string = $state('all'); 30 31 ··· 172 173 {/each} 173 174 </ToggleGroup> 174 175 </div> 175 - {#if data.loggedIn} 176 - <div class="flex items-center gap-3"> 177 - <span class="text-base-500 dark:text-base-400 w-14 text-xs">Events</span> 178 - <ToggleGroup 179 - type="single" 180 - bind:value={ 181 - () => filterMode, 182 - (v) => { 183 - if (v) filterMode = v; 184 - } 176 + <div class="flex items-center gap-3"> 177 + <span class="text-base-500 dark:text-base-400 w-14 text-xs">Events</span> 178 + <ToggleGroup 179 + type="single" 180 + bind:value={ 181 + () => filterMode, 182 + (v) => { 183 + if (v) filterMode = v; 185 184 } 186 - class="w-fit" 187 - > 188 - <ToggleGroupItem value="all" size="sm">All</ToggleGroupItem> 185 + } 186 + class="w-fit" 187 + > 188 + <ToggleGroupItem value="all" size="sm">All</ToggleGroupItem> 189 + <ToggleGroupItem value="recorded" size="sm">Recorded</ToggleGroupItem> 190 + {#if data.loggedIn} 189 191 <ToggleGroupItem value="attending" size="sm">Attending</ToggleGroupItem> 190 - </ToggleGroup> 191 - </div> 192 - {/if} 192 + {/if} 193 + </ToggleGroup> 194 + </div> 193 195 </div> 194 196 195 197 {#each filteredDayGroups as day, dayIndex} ··· 208 210 {nowVancouverMinutes} 209 211 {rsvpStatuses} 210 212 {rsvpRkeys} 213 + {eventVods} 211 214 dimUnattended={filterMode === 'attending'} 215 + dimUnrecorded={filterMode === 'recorded'} 212 216 loggedIn={data.loggedIn} 213 217 onrsvpchange={handleRsvpChange} 214 218 />
+20 -7
src/routes/(app)/p/atmosphereconf.org/DaySchedule.svelte
··· 10 10 getNowGridRow 11 11 } from './schedule-utils'; 12 12 13 + import type { VodRecord } from '$lib/vods'; 14 + 13 15 let { 14 16 grid, 15 17 rooms, ··· 20 22 nowVancouverMinutes, 21 23 rsvpStatuses = {}, 22 24 rsvpRkeys = {}, 25 + eventVods = {}, 23 26 dimUnattended = false, 27 + dimUnrecorded = false, 24 28 loggedIn = false, 25 29 onrsvpchange 26 30 }: { ··· 33 37 nowVancouverMinutes: number; 34 38 rsvpStatuses?: Record<string, string>; 35 39 rsvpRkeys?: Record<string, string>; 40 + eventVods?: Record<string, VodRecord>; 36 41 dimUnattended?: boolean; 42 + dimUnrecorded?: boolean; 37 43 loggedIn?: boolean; 38 44 onrsvpchange?: (uri: string, status: string | null, rkey?: string) => void; 39 45 } = $props(); ··· 42 48 let nowRow = $derived(getNowGridRow(grid, dayKey, nowVancouverKey, nowVancouverMinutes)); 43 49 44 50 function isDimmed(event: { did: string; rkey: string; type: string }): boolean { 45 - if (!dimUnattended) return false; 46 - if (event.type === 'info') return true; 47 - const uri = `at://${event.did}/community.lexicon.calendar.event/${event.rkey}`; 48 - const status = rsvpStatuses[uri]; 49 - return !status || status === 'notgoing'; 51 + if (dimUnrecorded) { 52 + if (event.type === 'info') return true; 53 + const uri = `at://${event.did}/community.lexicon.calendar.event/${event.rkey}`; 54 + return !eventVods[uri]; 55 + } 56 + if (dimUnattended) { 57 + if (event.type === 'info') return true; 58 + const uri = `at://${event.did}/community.lexicon.calendar.event/${event.rkey}`; 59 + const status = rsvpStatuses[uri]; 60 + return !status || status === 'notgoing'; 61 + } 62 + return false; 50 63 } 51 64 </script> 52 65 ··· 104 117 class="relative flex min-h-5 p-0.5 transition-opacity {isDimmed(event) ? (linkableTypes.has(event.type) && event.rkey ? 'opacity-30 hover:opacity-80' : 'opacity-30') : ''}" 105 118 style="grid-row: {event.startRow} / span {event.spanRows}; grid-column: 1; z-index: {event.zIndex}" 106 119 > 107 - <ScheduleEventCell {event} {rsvpStatuses} {rsvpRkeys} {loggedIn} {onrsvpchange} /> 120 + <ScheduleEventCell {event} {rsvpStatuses} {rsvpRkeys} {loggedIn} vodPlaylistUrl={eventVods[event.uri]?.playlistUrl} {onrsvpchange} /> 108 121 </li> 109 122 {/each} 110 123 {#if nowRow} ··· 186 199 class="relative flex min-h-5 p-0.5 transition-opacity {isDimmed(event) ? (linkableTypes.has(event.type) && event.rkey ? 'opacity-30 hover:opacity-80' : 'opacity-30') : ''}" 187 200 style="grid-row: {event.startRow} / span {event.spanRows}; grid-column: {event.colStart} / span {event.colSpan}; z-index: {event.zIndex}" 188 201 > 189 - <ScheduleEventCell {event} {rsvpStatuses} {rsvpRkeys} {loggedIn} {onrsvpchange} /> 202 + <ScheduleEventCell {event} {rsvpStatuses} {rsvpRkeys} {loggedIn} vodPlaylistUrl={eventVods[event.uri]?.playlistUrl} {onrsvpchange} /> 190 203 </li> 191 204 {/each} 192 205 {#if nowRow}
+25 -9
src/routes/(app)/p/atmosphereconf.org/ScheduleEventCell.svelte
··· 9 9 } from './schedule-utils'; 10 10 import { Modal, Button } from '@foxui/core'; 11 11 import EventRsvp from '$lib/components/EventRsvp.svelte'; 12 + import VodPlayer from '$lib/components/VodPlayer.svelte'; 12 13 13 14 let { 14 15 event, 15 16 rsvpStatuses = {}, 16 17 rsvpRkeys = {}, 17 18 loggedIn = false, 19 + vodPlaylistUrl, 18 20 onrsvpchange 19 21 }: { 20 22 event: GridEvent; 21 23 rsvpStatuses?: Record<string, string>; 22 24 rsvpRkeys?: Record<string, string>; 23 25 loggedIn?: boolean; 26 + vodPlaylistUrl?: string; 24 27 onrsvpchange?: (uri: string, status: string | null, rkey?: string) => void; 25 28 } = $props(); 26 29 ··· 28 31 29 32 let initialRsvpStatus = $derived((rsvpStatuses[event.uri] as 'going' | 'interested' | 'notgoing' | undefined) ?? null); 30 33 let initialRsvpRkey = $derived(rsvpRkeys[event.uri] ?? null); 34 + let isPast = $derived(event.end ? new Date(event.end) < new Date() : false); 31 35 </script> 32 36 33 37 {#if linkableTypes.has(event.type) && event.rkey} ··· 44 48 </p> 45 49 {#if event.speakers?.length && !isCompact(event.type, event.start, event.end)} 46 50 <p class="mt-0.5 opacity-75 {durationMinutes(event.start, event.end) < 60 ? 'line-clamp-1' : ''}">{event.speakers.map((s) => s.name).join(', ')}</p> 51 + {/if} 52 + {#if vodPlaylistUrl} 53 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="absolute top-1 right-1 size-3 opacity-60"> 54 + <path d="M3.25 4A2.25 2.25 0 0 0 1 6.25v7.5A2.25 2.25 0 0 0 3.25 16h7.5A2.25 2.25 0 0 0 13 13.75v-1.956l3.203 1.602A.75.75 0 0 0 17.25 12.75v-5.5a.75.75 0 0 0-1.047-.646L13 8.206V6.25A2.25 2.25 0 0 0 10.75 4h-7.5Z" /> 55 + </svg> 47 56 {/if} 48 57 {#if initialRsvpStatus === 'going'} 49 58 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="absolute right-1 bottom-1 size-3 opacity-60"> ··· 76 85 <p class="text-base-500 dark:text-base-400 mt-3 line-clamp-3 break-words text-sm">{event.description}</p> 77 86 {/if} 78 87 79 - <EventRsvp 80 - eventUri={event.uri} 81 - eventCid={event.cid ?? null} 82 - {initialRsvpStatus} 83 - {initialRsvpRkey} 84 - onlogin={() => (modalOpen = false)} 85 - onrsvp={(status, key) => { onrsvpchange?.(event.uri, status, key); modalOpen = false; }} 86 - oncancel={() => { onrsvpchange?.(event.uri, null); }} 87 - /> 88 + {#if !isPast} 89 + <EventRsvp 90 + eventUri={event.uri} 91 + eventCid={event.cid ?? null} 92 + {initialRsvpStatus} 93 + {initialRsvpRkey} 94 + onlogin={() => (modalOpen = false)} 95 + onrsvp={(status, key) => { onrsvpchange?.(event.uri, status, key); modalOpen = false; }} 96 + oncancel={() => { onrsvpchange?.(event.uri, null); }} 97 + /> 98 + {/if} 88 99 100 + {#if vodPlaylistUrl} 101 + <div class="mt-3"> 102 + <VodPlayer playlistUrl={vodPlaylistUrl} title={event.title} /> 103 + </div> 104 + {/if} 89 105 <Button href="/p/atmosphereconf.org/e/{event.rkey}" variant="secondary" class="mt-2 w-full">Go to event</Button> 90 106 </div> 91 107 </Modal>