atmo.rsvp
3
fork

Configure Feed

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

atmosphereconf vods

Florian 9443beea 2d6ef5fb

+304 -44
+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
+99
src/lib/components/VodPlayer.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import type HlsType from 'hls.js'; 4 + import type PlyrType from 'plyr'; 5 + 6 + let { playlistUrl, title }: { playlistUrl: string; title: string } = $props(); 7 + 8 + let videoEl: HTMLVideoElement | undefined = $state(); 9 + let error = $state(false); 10 + 11 + let hls: HlsType | null = null; 12 + let plyr: PlyrType | null = null; 13 + let hlsLoaded = false; 14 + 15 + onMount(() => { 16 + initPlyr(); 17 + return () => { 18 + hls?.destroy(); 19 + plyr?.destroy(); 20 + }; 21 + }); 22 + 23 + async function initPlyr() { 24 + if (!videoEl) return; 25 + 26 + try { 27 + const { default: Plyr } = await import('plyr'); 28 + plyr = new Plyr(videoEl, { 29 + controls: ['play-large', 'play', 'progress', 'current-time', 'mute', 'volume', 'fullscreen'], 30 + ratio: '16:9' 31 + }); 32 + // Load HLS source only when user clicks play 33 + plyr.on('play', loadHls); 34 + } catch { 35 + error = true; 36 + } 37 + } 38 + 39 + async function loadHls() { 40 + if (!videoEl || hlsLoaded) return; 41 + hlsLoaded = true; 42 + 43 + try { 44 + const { default: Hls } = await import('hls.js'); 45 + 46 + if (Hls.isSupported()) { 47 + hls = new Hls({ 48 + maxBufferLength: 10, 49 + maxMaxBufferLength: 30, 50 + maxBufferSize: 30 * 1000 * 1000, 51 + startLevel: 0, 52 + }); 53 + hls.loadSource(playlistUrl); 54 + hls.attachMedia(videoEl); 55 + hls.on(Hls.Events.MANIFEST_PARSED, () => { 56 + videoEl?.play(); 57 + }); 58 + hls.on(Hls.Events.ERROR, (_event, data) => { 59 + if (data.fatal) { 60 + if (data.type === Hls.ErrorTypes.NETWORK_ERROR) { 61 + hls?.startLoad(); 62 + } else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { 63 + hls?.recoverMediaError(); 64 + } else { 65 + error = true; 66 + } 67 + } 68 + }); 69 + } else if (videoEl.canPlayType('application/vnd.apple.mpegurl')) { 70 + videoEl.src = playlistUrl; 71 + videoEl.play(); 72 + } else { 73 + error = true; 74 + } 75 + } catch { 76 + error = true; 77 + } 78 + } 79 + </script> 80 + 81 + <svelte:head> 82 + <link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css" /> 83 + </svelte:head> 84 + 85 + {#if error} 86 + <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"> 87 + <p class="text-base-500 dark:text-base-400 text-sm">Failed to load video</p> 88 + </div> 89 + {:else} 90 + <div class="border-base-300 dark:border-base-400/40 w-full max-w-full overflow-hidden rounded-xl border"> 91 + <video bind:this={videoEl} class="h-full w-full" aria-label={title}></video> 92 + </div> 93 + {/if} 94 + 95 + <style> 96 + * { 97 + --plyr-color-main: var(--color-accent-500); 98 + } 99 + </style>
+89
src/lib/vods.ts
··· 1 + const STREAM_PLACE_DID = 'did:plc:rbvrr34edl5ddpuwcubjiost'; 2 + const STREAM_PLACE_PDS = 'https://iameli.com'; 3 + const VOD_COLLECTION = 'place.stream.video'; 4 + const VOD_PLAYBACK_BASE = 'https://vod-beta.stream.place/xrpc/place.stream.playback.getVideoPlaylist'; 5 + 6 + export interface VodRecord { 7 + uri: string; 8 + title: string; 9 + creator: string; 10 + duration: number; // nanoseconds 11 + playlistUrl: string; 12 + } 13 + 14 + let cachedVods: VodRecord[] | null = null; 15 + let cacheTime = 0; 16 + const CACHE_TTL = 5 * 60 * 1000; // 5 minutes 17 + 18 + export async function fetchVods(): Promise<VodRecord[]> { 19 + if (cachedVods && Date.now() - cacheTime < CACHE_TTL) { 20 + return cachedVods; 21 + } 22 + 23 + const allRecords: VodRecord[] = []; 24 + let cursor: string | undefined; 25 + 26 + do { 27 + const params = new URLSearchParams({ 28 + repo: STREAM_PLACE_DID, 29 + collection: VOD_COLLECTION, 30 + limit: '100' 31 + }); 32 + if (cursor) params.set('cursor', cursor); 33 + 34 + const res = await fetch(`${STREAM_PLACE_PDS}/xrpc/com.atproto.repo.listRecords?${params}`); 35 + if (!res.ok) break; 36 + 37 + const data = (await res.json()) as { 38 + cursor?: string; 39 + records: Array<{ 40 + uri: string; 41 + value: { 42 + title: string; 43 + creator: string; 44 + duration: number; 45 + }; 46 + }>; 47 + }; 48 + 49 + for (const r of data.records ?? []) { 50 + allRecords.push({ 51 + uri: r.uri, 52 + title: r.value.title, 53 + creator: r.value.creator, 54 + duration: r.value.duration, 55 + playlistUrl: `${VOD_PLAYBACK_BASE}?uri=${encodeURIComponent(r.uri)}` 56 + }); 57 + } 58 + 59 + cursor = data.cursor; 60 + } while (cursor); 61 + 62 + cachedVods = allRecords; 63 + cacheTime = Date.now(); 64 + return cachedVods; 65 + } 66 + 67 + const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, ''); 68 + 69 + export async function findVodForEvent(eventName: string): Promise<VodRecord | null> { 70 + const vods = await fetchVods(); 71 + const eventNorm = normalize(eventName); 72 + 73 + // Exact normalized match 74 + const exact = vods.find((v) => normalize(v.title) === eventNorm); 75 + if (exact) return exact; 76 + 77 + // Substring match (event name in VOD title or vice versa), require reasonable length 78 + if (eventNorm.length >= 10) { 79 + const partial = vods.find( 80 + (v) => { 81 + const vodNorm = normalize(v.title); 82 + return vodNorm.length >= 10 && (eventNorm.includes(vodNorm) || vodNorm.includes(eventNorm)); 83 + } 84 + ); 85 + if (partial) return partial; 86 + } 87 + 88 + return null; 89 + }
+6 -1
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 { findVodForEvent } from '$lib/vods'; 15 16 16 17 export async function load({ params, locals, url }) { 17 18 const { rkey } = params; ··· 43 44 | Array<{ id: string; name: string }> 44 45 | undefined) ?? []; 45 46 46 - const [attendees, viewerRsvpRecord, parentEvent, ...speakerProfiles] = await Promise.all([ 47 + const [attendees, viewerRsvpRecord, parentEvent, vod, ...speakerProfiles] = await Promise.all([ 47 48 listEventAttendeesFromContrail(fullEventRecord.uri), 48 49 locals.did 49 50 ? getViewerRsvpFromContrail({ eventUri: fullEventRecord.uri, actor: locals.did }) ··· 53 54 .then((r) => r ? flattenEventRecord(r) : null) 54 55 .catch(() => null) 55 56 : null, 57 + isAtmosphereconf 58 + ? findVodForEvent(eventData.name).catch(() => null) 59 + : null, 56 60 ...speakers.map((s) => 57 61 s.id 58 62 ? getProfileFromContrail(s.id as ActorIdentifier) ··· 77 81 viewerRsvpStatus: getRsvpStatus(viewerRsvpRecord?.record?.status), 78 82 viewerRsvpRkey: viewerRsvpRecord?.rkey ?? null, 79 83 parentEvent, 84 + vod, 80 85 speakerProfiles: speakerProfiles as Array<{ id?: string; name: string; avatar?: string; handle?: string }> 81 86 }; 82 87 } catch (e) {
+19 -8
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 14 import { goto } from '$app/navigation'; 14 15 import { marked } from 'marked'; ··· 178 179 ); 179 180 180 181 let isOngoing = $derived(isEventOngoing(eventData.startsAt, eventData.endsAt)); 182 + let isPast = $derived(endDate ? endDate < new Date() : false); 181 183 182 184 const renderer = new marked.Renderer(); 183 185 renderer.link = ({ href, text }) => ··· 372 374 </div> 373 375 {/if} 374 376 377 + <!-- VOD --> 378 + {#if data.vod} 379 + <div class="mb-6"> 380 + <VodPlayer playlistUrl={data.vod.playlistUrl} title={data.vod.title} /> 381 + </div> 382 + {/if} 383 + 375 384 <!-- Date row --> 376 385 <div class="mb-4 flex items-center gap-4"> 377 386 <div ··· 467 476 </Button> 468 477 {/if} 469 478 470 - <EventRsvp 471 - {eventUri} 472 - eventCid={eventData.cid ?? null} 473 - initialRsvpStatus={data.viewerRsvpStatus} 474 - initialRsvpRkey={data.viewerRsvpRkey} 475 - onrsvp={handleRsvp} 476 - oncancel={handleRsvpCancel} 477 - /> 479 + {#if !isPast} 480 + <EventRsvp 481 + {eventUri} 482 + eventCid={eventData.cid ?? null} 483 + initialRsvpStatus={data.viewerRsvpStatus} 484 + initialRsvpRkey={data.viewerRsvpRkey} 485 + onrsvp={handleRsvp} 486 + oncancel={handleRsvpCancel} 487 + /> 488 + {/if} 478 489 479 490 <!-- About Event --> 480 491 {#if descriptionHtml}
+18 -3
src/routes/(app)/p/atmosphereconf.org/+page.server.ts
··· 4 4 listEventRecordsFromContrail, 5 5 contrail 6 6 } from '$lib/contrail'; 7 + import { fetchVods, type VodRecord } from '$lib/vods'; 7 8 8 9 export async function load({ locals }) { 9 10 const actor = 'atmosphereconf.org'; 10 11 11 - const [profile, response, rsvpResponse] = await Promise.all([ 12 + const [profile, response, rsvpResponse, vods] = await Promise.all([ 12 13 getProfileFromContrail(actor), 13 14 listEventRecordsFromContrail({ 14 15 actor, ··· 20 21 ? contrail.get('community.lexicon.calendar.rsvp.listRecords', { 21 22 params: { actor: locals.did, limit: 200 } 22 23 }) 23 - : null 24 + : null, 25 + fetchVods().catch(() => [] as VodRecord[]) 24 26 ]); 25 27 26 28 const events = response ? flattenEventRecords(response.records) : []; ··· 40 42 } 41 43 } 42 44 45 + // Build map of event name → VOD for quick lookup in the schedule 46 + const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, ''); 47 + const vodsByName: Record<string, VodRecord> = {}; 48 + for (const vod of vods) { 49 + vodsByName[normalize(vod.title)] = vod; 50 + } 51 + const eventVods: Record<string, VodRecord> = {}; 52 + for (const event of events) { 53 + const match = vodsByName[normalize(event.name)]; 54 + if (match) eventVods[event.uri] = match; 55 + } 56 + 43 57 return { 44 58 hostProfile: profile, 45 59 events, 46 60 actor, 47 61 rsvpStatuses, 48 62 rsvpRkeys, 49 - loggedIn: !!locals.did 63 + loggedIn: !!locals.did, 64 + eventVods 50 65 }; 51 66 }
+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>