A simple, clean, fast browser for the AtmosphereConf(2026) VODs
1
fork

Configure Feed

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

feat: ionosphere.tv enrichment for ATmosphereConf page (AI-assisted)

jack 56225c88 54cbed63

+622 -53
+3 -1
public/favicon.svg
··· 1 - <svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-49.64" y="2.03" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-45.045" y="20.029" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-43.513" y="21.178" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="g" width="74.749" height="58.852" x="15.756" y="-17.901" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="h" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="i" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="j" width="56.045" height="63.649" x="-27.636" y="-22.853" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="k" width="54.814" height="64.646" x="20.116" y="-38.415" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="l" width="33.541" height="35.313" x="24.641" y="-11.323" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="m" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="n" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="o" width="54.814" height="64.646" x="8.244" y="-2.416" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="p" width="39.409" height="43.623" x="18.713" y="10.588" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter></defs></svg> 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"> 2 + <text x="50%" y="52%" text-anchor="middle" dominant-baseline="middle" font-size="52">🎥</text> 3 + </svg>
+58
scripts/explore-ionosphere.ts
··· 1 + const IONOSPHERE_DID = 'did:plc:lkeq4oghyhnztbu4dxr3joff' 2 + const PLC_URL = `https://plc.directory/${IONOSPHERE_DID}` 3 + 4 + async function fetchJson<T>(url: string): Promise<T> { 5 + const response = await fetch(url) 6 + if (!response.ok) { 7 + throw new Error(`Request failed (${response.status}) for ${url}`) 8 + } 9 + return (await response.json()) as T 10 + } 11 + 12 + async function main() { 13 + console.log('=== Ionosphere PLC document ===') 14 + const plcDoc = await fetchJson<{ 15 + service?: Array<{ id?: string; serviceEndpoint?: string }> 16 + }>(PLC_URL) 17 + console.log(JSON.stringify(plcDoc, null, 2)) 18 + 19 + const pds = plcDoc.service?.find((entry) => entry.id === '#atproto_pds')?.serviceEndpoint 20 + if (!pds) { 21 + throw new Error('Could not resolve #atproto_pds service endpoint') 22 + } 23 + 24 + const basePds = pds.replace(/\/$/, '') 25 + console.log('\n=== Resolved PDS endpoint ===') 26 + console.log(basePds) 27 + 28 + const describeUrl = `${basePds}/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(IONOSPHERE_DID)}` 29 + console.log('\n=== describeRepo JSON ===') 30 + const describe = await fetchJson<{ 31 + collections?: string[] 32 + [key: string]: unknown 33 + }>(describeUrl) 34 + console.log(JSON.stringify(describe, null, 2)) 35 + 36 + const collections = (describe.collections ?? []).filter((name) => name.startsWith('tv.ionosphere.')) 37 + console.log('\n=== tv.ionosphere.* collections ===') 38 + console.log(JSON.stringify(collections, null, 2)) 39 + 40 + for (const collection of collections) { 41 + const sampleUrl = 42 + `${basePds}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(IONOSPHERE_DID)}` + 43 + `&collection=${encodeURIComponent(collection)}&limit=5` 44 + 45 + console.log(`\n=== Sample records: ${collection} ===`) 46 + try { 47 + const sample = await fetchJson<Record<string, unknown>>(sampleUrl) 48 + console.log(JSON.stringify(sample, null, 2)) 49 + } catch (error) { 50 + console.log(JSON.stringify({ collection, error: error instanceof Error ? error.message : 'unknown' }, null, 2)) 51 + } 52 + } 53 + } 54 + 55 + main().catch((error) => { 56 + console.error(error) 57 + process.exit(1) 58 + })
+1 -1
src/components/error-panel.tsx
··· 22 22 {onRetry ? ( 23 23 <Button variant="secondary" className="mt-5" onClick={onRetry}> 24 24 <RefreshCcw className="h-4 w-4" /> 25 - Retry 25 + Try again 26 26 </Button> 27 27 ) : null} 28 28 </section>
+13 -7
src/components/layout/app-shell.tsx
··· 29 29 }, 30 30 ] 31 31 32 - function NavItem({ label, icon: Icon, ...props }: NavLinkProps & { label: string; icon: typeof Film }) { 32 + function NavItem({ 33 + label, 34 + icon: Icon, 35 + compact = false, 36 + ...props 37 + }: NavLinkProps & { label: string; icon: typeof Film; compact?: boolean }) { 33 38 return ( 34 39 <NavLink 35 40 {...props} ··· 44 49 cn( 45 50 'flex min-h-11 items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-[background-color,color,border-color] md:justify-start md:px-3.5', 46 51 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text/35 md:text-sm', 52 + compact && 'px-2.5 sm:px-3', 47 53 isActive 48 54 ? 'border border-accent/45 bg-surface/80 text-accent' 49 55 : 'border border-transparent text-muted hover:border-line/45 hover:bg-surface/70 hover:text-text', ··· 51 57 } 52 58 > 53 59 <Icon className="h-4 w-4" /> 54 - <span>{label}</span> 60 + <span className={cn(compact && 'hidden min-[360px]:inline')}>{label}</span> 55 61 </NavLink> 56 62 ) 57 63 } ··· 131 137 <footer className="relative z-10 border-t border-line/45 bg-surface/80 supports-[backdrop-filter]:backdrop-blur-md"> 132 138 <div className="mx-auto flex w-full max-w-5xl flex-col gap-2 px-3 py-4 text-xs text-muted sm:px-4 md:flex-row md:items-center md:justify-between md:px-6"> 133 139 <p>Open source · MIT licence</p> 134 - <p className="flex items-center gap-3"> 140 + <p className="flex flex-wrap items-center gap-x-3 gap-y-1.5"> 135 141 <a 136 142 href="https://tangled.sh/@j4ck.xyz/atmosphere-vods" 137 143 target="_blank" 138 144 rel="noreferrer" 139 - className="underline-offset-4 hover:text-text hover:underline" 145 + className="inline-flex min-h-11 items-center underline-offset-4 hover:text-text hover:underline" 140 146 > 141 147 Tangled 142 148 </a> ··· 144 150 href="https://github.com/j4ckxyz/atmosphere-vods" 145 151 target="_blank" 146 152 rel="noreferrer" 147 - className="underline-offset-4 hover:text-text hover:underline" 153 + className="inline-flex min-h-11 items-center underline-offset-4 hover:text-text hover:underline" 148 154 > 149 155 GitHub 150 156 </a> ··· 152 158 href="https://vod.j4ck.xyz" 153 159 target="_blank" 154 160 rel="noreferrer" 155 - className="underline-offset-4 hover:text-text hover:underline" 161 + className="inline-flex min-h-11 items-center underline-offset-4 hover:text-text hover:underline" 156 162 > 157 163 iStream → 158 164 </a> ··· 176 182 aria-label="Bottom tabs" 177 183 > 178 184 {navItems.map((item) => ( 179 - <NavItem key={item.to} label={item.label} to={item.to} end={item.end} icon={item.icon} /> 185 + <NavItem key={item.to} label={item.label} to={item.to} end={item.end} icon={item.icon} compact /> 180 186 ))} 181 187 </nav> 182 188 </div>
+11
src/lib/format.ts
··· 21 21 }).format(date) 22 22 } 23 23 24 + export function formatDateTime(iso: string): string { 25 + const date = new Date(iso) 26 + return new Intl.DateTimeFormat(undefined, { 27 + year: 'numeric', 28 + month: 'short', 29 + day: 'numeric', 30 + hour: 'numeric', 31 + minute: '2-digit', 32 + }).format(date) 33 + } 34 + 24 35 export function truncateDid(did: string): string { 25 36 if (did.length <= 22) { 26 37 return did
+285
src/lib/ionosphere.ts
··· 1 + import { BSKY_PUBLIC_API } from './constants' 2 + import { resolvePdsUrl } from './api' 3 + import type { 4 + ActorProfile, 5 + AppTalk, 6 + IonosphereAnnotationRecord, 7 + IonosphereConceptRecord, 8 + IonosphereEnrichment, 9 + IonosphereEnrichmentResult, 10 + IonosphereSpeakerRecord, 11 + IonosphereTalkRecord, 12 + } from './types' 13 + 14 + const IONOSPHERE_DID = 'did:plc:lkeq4oghyhnztbu4dxr3joff' 15 + const IONOSPHERE_EVENT_URI = 16 + 'at://did:plc:lkeq4oghyhnztbu4dxr3joff/tv.ionosphere.event/atmosphereconf-2026' 17 + const RECORDS_PAGE_LIMIT = 100 18 + const REQUEST_TIMEOUT_MS = 8_000 19 + 20 + interface GenericListRecordsResponse { 21 + records?: Array<{ 22 + uri: string 23 + cid: string 24 + value: Record<string, unknown> 25 + }> 26 + cursor?: string 27 + } 28 + 29 + let enrichmentCache: Promise<IonosphereEnrichmentResult> | null = null 30 + const profileCache = new Map<string, Promise<ActorProfile | null>>() 31 + 32 + async function fetchWithTimeout(url: string): Promise<Response> { 33 + const controller = new AbortController() 34 + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) 35 + try { 36 + return await fetch(url, { signal: controller.signal }) 37 + } finally { 38 + clearTimeout(timeout) 39 + } 40 + } 41 + 42 + async function fetchJson<T>(url: string): Promise<T> { 43 + const response = await fetchWithTimeout(url) 44 + if (!response.ok) { 45 + throw new Error(`Request failed (${response.status})`) 46 + } 47 + return (await response.json()) as T 48 + } 49 + 50 + async function fetchCollection(collection: string): Promise<GenericListRecordsResponse['records']> { 51 + const pds = await resolvePdsUrl(IONOSPHERE_DID) 52 + const out: GenericListRecordsResponse['records'] = [] 53 + let cursor: string | undefined 54 + 55 + do { 56 + const query = new URLSearchParams({ 57 + repo: IONOSPHERE_DID, 58 + collection, 59 + limit: String(RECORDS_PAGE_LIMIT), 60 + }) 61 + if (cursor) { 62 + query.set('cursor', cursor) 63 + } 64 + 65 + const page = await fetchJson<GenericListRecordsResponse>( 66 + `${pds}/xrpc/com.atproto.repo.listRecords?${query.toString()}`, 67 + ) 68 + 69 + out.push(...(page.records ?? [])) 70 + cursor = page.cursor 71 + } while (cursor) 72 + 73 + return out 74 + } 75 + 76 + function normalizeTitle(value: string): string { 77 + return value 78 + .toLowerCase() 79 + .replace(/[^a-z0-9\s]/g, ' ') 80 + .replace(/\s+/g, ' ') 81 + .trim() 82 + } 83 + 84 + function titleScore(left: string, right: string): number { 85 + if (!left || !right) { 86 + return 0 87 + } 88 + 89 + if (left === right) { 90 + return 1 91 + } 92 + 93 + if (left.includes(right) || right.includes(left)) { 94 + return 0.86 95 + } 96 + 97 + const leftWords = new Set(left.split(' ')) 98 + const rightWords = new Set(right.split(' ')) 99 + const overlap = [...leftWords].filter((word) => rightWords.has(word)).length 100 + const denominator = Math.max(leftWords.size, rightWords.size, 1) 101 + return overlap / denominator 102 + } 103 + 104 + function pickBestTalkByTitle( 105 + talk: AppTalk, 106 + ionosphereTalks: IonosphereTalkRecord[], 107 + ): IonosphereTalkRecord | undefined { 108 + const normalizedVodTitle = normalizeTitle(talk.title) 109 + let best: IonosphereTalkRecord | undefined 110 + let bestScore = 0 111 + 112 + for (const ionosphereTalk of ionosphereTalks) { 113 + const normalizedIonosphereTitle = normalizeTitle(ionosphereTalk.value.title ?? '') 114 + const score = titleScore(normalizedVodTitle, normalizedIonosphereTitle) 115 + if (score > bestScore) { 116 + best = ionosphereTalk 117 + bestScore = score 118 + } 119 + } 120 + 121 + if (bestScore < 0.45) { 122 + return undefined 123 + } 124 + 125 + return best 126 + } 127 + 128 + async function getProfileByHandle(handle: string): Promise<ActorProfile | null> { 129 + const key = handle.toLowerCase() 130 + const cached = profileCache.get(key) 131 + if (cached) { 132 + return cached 133 + } 134 + 135 + const pending = (async () => { 136 + try { 137 + const query = new URLSearchParams({ actor: handle }) 138 + return await fetchJson<ActorProfile>( 139 + `${BSKY_PUBLIC_API}/xrpc/app.bsky.actor.getProfile?${query.toString()}`, 140 + ) 141 + } catch { 142 + return null 143 + } 144 + })() 145 + 146 + profileCache.set(key, pending) 147 + return pending 148 + } 149 + 150 + function asIonosphereTalks(records: GenericListRecordsResponse['records']): IonosphereTalkRecord[] { 151 + return (records ?? []).map((record) => ({ 152 + uri: record.uri, 153 + cid: record.cid, 154 + value: record.value as IonosphereTalkRecord['value'], 155 + })) 156 + } 157 + 158 + function asIonosphereConcepts( 159 + records: GenericListRecordsResponse['records'], 160 + ): IonosphereConceptRecord[] { 161 + return (records ?? []).map((record) => ({ 162 + uri: record.uri, 163 + cid: record.cid, 164 + value: record.value as IonosphereConceptRecord['value'], 165 + })) 166 + } 167 + 168 + function asIonosphereAnnotations( 169 + records: GenericListRecordsResponse['records'], 170 + ): IonosphereAnnotationRecord[] { 171 + return (records ?? []).map((record) => ({ 172 + uri: record.uri, 173 + cid: record.cid, 174 + value: record.value as IonosphereAnnotationRecord['value'], 175 + })) 176 + } 177 + 178 + function asIonosphereSpeakers( 179 + records: GenericListRecordsResponse['records'], 180 + ): IonosphereSpeakerRecord[] { 181 + return (records ?? []).map((record) => ({ 182 + uri: record.uri, 183 + cid: record.cid, 184 + value: record.value as IonosphereSpeakerRecord['value'], 185 + })) 186 + } 187 + 188 + async function buildEnrichment(talks: AppTalk[]): Promise<IonosphereEnrichmentResult> { 189 + const [talksRaw, conceptsRaw, annotationsRaw, speakersRaw] = await Promise.all([ 190 + fetchCollection('tv.ionosphere.talk'), 191 + fetchCollection('tv.ionosphere.concept'), 192 + fetchCollection('tv.ionosphere.annotation'), 193 + fetchCollection('tv.ionosphere.speaker'), 194 + ]) 195 + 196 + const ionosphereTalks = asIonosphereTalks(talksRaw).filter( 197 + (record) => record.value.eventUri === IONOSPHERE_EVENT_URI, 198 + ) 199 + const conceptByUri = new Map( 200 + asIonosphereConcepts(conceptsRaw).map((record) => [ 201 + record.uri, 202 + record.value.name ?? record.value.aliases?.[0] ?? '', 203 + ]), 204 + ) 205 + 206 + const topicsByTalkUri = new Map<string, string[]>() 207 + for (const annotation of asIonosphereAnnotations(annotationsRaw)) { 208 + const talkUri = annotation.value.talkUri 209 + const conceptUri = annotation.value.conceptUri 210 + if (!talkUri || !conceptUri) { 211 + continue 212 + } 213 + const conceptName = conceptByUri.get(conceptUri) 214 + if (!conceptName) { 215 + continue 216 + } 217 + const list = topicsByTalkUri.get(talkUri) ?? [] 218 + if (!list.includes(conceptName)) { 219 + list.push(conceptName) 220 + } 221 + topicsByTalkUri.set(talkUri, list) 222 + } 223 + 224 + const speakerByUri = new Map(asIonosphereSpeakers(speakersRaw).map((record) => [record.uri, record])) 225 + const byVodUri = new Map<string, IonosphereEnrichment>() 226 + const allTopics = new Set<string>() 227 + 228 + for (const vodTalk of talks) { 229 + const match = pickBestTalkByTitle(vodTalk, ionosphereTalks) 230 + if (!match) { 231 + continue 232 + } 233 + 234 + const topics = (topicsByTalkUri.get(match.uri) ?? []).slice(0, 10) 235 + for (const topic of topics) { 236 + allTopics.add(topic) 237 + } 238 + 239 + let speakerName: string | undefined 240 + let speakerHandle: string | undefined 241 + let speakerAvatar: string | undefined 242 + 243 + const firstSpeakerUri = match.value.speakerUris?.[0] 244 + if (firstSpeakerUri) { 245 + const speakerRecord = speakerByUri.get(firstSpeakerUri) 246 + speakerName = speakerRecord?.value.name 247 + speakerHandle = speakerRecord?.value.handle 248 + 249 + if (speakerHandle) { 250 + const profile = await getProfileByHandle(speakerHandle) 251 + speakerAvatar = profile?.avatar 252 + speakerName = profile?.displayName?.trim() || speakerName 253 + speakerHandle = profile?.handle || speakerHandle 254 + } 255 + } 256 + 257 + byVodUri.set(vodTalk.uri, { 258 + room: match.value.room, 259 + scheduledAt: match.value.startsAt, 260 + track: match.value.track || match.value.category, 261 + topics, 262 + speakerName, 263 + speakerHandle, 264 + speakerAvatar, 265 + }) 266 + } 267 + 268 + return { 269 + byVodUri, 270 + allTopics: [...allTopics].sort((a, b) => a.localeCompare(b)), 271 + } 272 + } 273 + 274 + export function fetchAtmosphereIonosphereEnrichment( 275 + talks: AppTalk[], 276 + ): Promise<IonosphereEnrichmentResult> { 277 + if (!enrichmentCache) { 278 + enrichmentCache = buildEnrichment(talks).catch(() => ({ 279 + byVodUri: new Map<string, IonosphereEnrichment>(), 280 + allTopics: [], 281 + })) 282 + } 283 + 284 + return enrichmentCache 285 + }
+63
src/lib/types.ts
··· 73 73 taxonomyTopics?: string[] 74 74 taxonomyKeywords?: string[] 75 75 } 76 + 77 + export interface IonosphereTalkRecord { 78 + uri: string 79 + cid: string 80 + value: { 81 + $type: 'tv.ionosphere.talk' 82 + title?: string 83 + room?: string 84 + track?: string 85 + category?: string 86 + eventUri?: string 87 + startsAt?: string 88 + endsAt?: string 89 + speakerUris?: string[] 90 + videoUri?: string 91 + } 92 + } 93 + 94 + export interface IonosphereConceptRecord { 95 + uri: string 96 + cid: string 97 + value: { 98 + $type: 'tv.ionosphere.concept' 99 + name?: string 100 + aliases?: string[] 101 + } 102 + } 103 + 104 + export interface IonosphereAnnotationRecord { 105 + uri: string 106 + cid: string 107 + value: { 108 + $type: 'tv.ionosphere.annotation' 109 + talkUri?: string 110 + conceptUri?: string 111 + } 112 + } 113 + 114 + export interface IonosphereSpeakerRecord { 115 + uri: string 116 + cid: string 117 + value: { 118 + $type: 'tv.ionosphere.speaker' 119 + name?: string 120 + handle?: string 121 + did?: string 122 + } 123 + } 124 + 125 + export interface IonosphereEnrichment { 126 + room?: string 127 + scheduledAt?: string 128 + track?: string 129 + topics: string[] 130 + speakerName?: string 131 + speakerHandle?: string 132 + speakerAvatar?: string 133 + } 134 + 135 + export interface IonosphereEnrichmentResult { 136 + byVodUri: Map<string, IonosphereEnrichment> 137 + allTopics: string[] 138 + }
+21 -13
src/pages/about-page.tsx
··· 27 27 const nextDisabled = !hapticsDisabled 28 28 setLocalHapticsDisabled(nextDisabled) 29 29 setHapticsDisabled(nextDisabled) 30 + if (!nextDisabled) { 31 + hapticTap() 32 + } 30 33 } 31 34 32 35 return ( ··· 55 58 <h2 className="text-base font-semibold text-text">Keyboard shortcuts</h2> 56 59 <ul className="mt-3 space-y-2 text-sm text-muted"> 57 60 <li>Browse/Search: <code>J</code> next, <code>K</code> previous, <code>/</code> focus search, <code>Enter</code> open selected card.</li> 58 - <li>Video: <code>Space</code> or <code>K</code> play/pause, <code>J</code>/<code>L</code> seek ±10s.</li> 59 - <li>Video: <code>F</code> fullscreen, <code>M</code> mute, <code>0-9</code> seek 0%-90%.</li> 60 - <li>Video: <code>&lt;</code>/<code>&gt;</code> adjust speed by 0.25x, <code>Esc</code> back to browse.</li> 61 + <li>Video: <code>Space</code> or <code>K</code> play/pause, <code>J</code>/<code>L</code> seek +/- 10 seconds.</li> 62 + <li>Video: <code>F</code> fullscreen, <code>M</code> mute, <code>0-9</code> jump to 0-90%.</li> 63 + <li>Video: <code>&lt;</code>/<code>&gt;</code> change speed by 0.25x, <code>Esc</code> return to Browse.</li> 61 64 </ul> 62 65 </section> 63 66 ··· 98 101 {showHapticsToggle ? ( 99 102 <section className="rounded-lg border border-line/45 bg-surface/80 p-5 md:p-6"> 100 103 <h2 className="text-base font-semibold text-text">Haptic feedback</h2> 101 - <p className="mt-2 text-sm text-muted">Subtle vibrations on interactions (Android only)</p> 102 - <button 103 - type="button" 104 - role="switch" 105 - aria-checked={!hapticsDisabled} 106 - onClick={onHapticsToggle} 107 - className="mt-3 inline-flex min-h-11 items-center rounded-md border border-line/45 bg-surface/80 px-3 text-xs text-muted transition hover:border-line/60 hover:text-text" 108 - > 109 - {!hapticsDisabled ? 'Enabled' : 'Disabled'} 110 - </button> 104 + <p className="mt-2 text-sm text-muted">Subtle vibrations on interactions (Android only).</p> 105 + <div className="mt-3 flex items-center gap-3"> 106 + <button 107 + type="button" 108 + role="switch" 109 + aria-checked={!hapticsDisabled} 110 + onClick={onHapticsToggle} 111 + className="inline-flex h-11 w-[3.25rem] items-center rounded-full border border-line/45 bg-surface/80 px-1 transition" 112 + > 113 + <span 114 + className={`h-5 w-5 rounded-full bg-text transition-transform ${hapticsDisabled ? 'translate-x-0' : 'translate-x-5'}`} 115 + /> 116 + </button> 117 + <span className="text-sm text-muted">{!hapticsDisabled ? 'Enabled' : 'Disabled'}</span> 118 + </div> 111 119 </section> 112 120 ) : null} 113 121 </div>
+139 -5
src/pages/atmosphereconf-page.tsx
··· 1 + import { useEffect, useMemo, useState } from 'react' 2 + 1 3 import { ErrorPanel } from '@/components/error-panel' 2 4 import { TalkCard } from '@/components/talk-card' 3 5 import { TalkGridSkeleton } from '@/components/talk-grid-skeleton' 4 6 import { isAtmosphereTalk } from '@/lib/api' 7 + import { formatDateTime } from '@/lib/format' 8 + import { fetchAtmosphereIonosphereEnrichment } from '@/lib/ionosphere' 9 + import type { IonosphereEnrichmentResult } from '@/lib/types' 5 10 import { useVideos } from '@/state/videos-context' 6 11 7 12 export function AtmosphereConfPage() { 8 13 const { talks, loading, error, refresh } = useVideos() 9 14 const filteredTalks = talks.filter((talk) => isAtmosphereTalk(talk)) 10 - const [featuredTalk, ...remainingTalks] = filteredTalks 15 + const [enrichment, setEnrichment] = useState<IonosphereEnrichmentResult>({ 16 + byVodUri: new Map(), 17 + allTopics: [], 18 + }) 19 + const [selectedTopic, setSelectedTopic] = useState<string>('') 20 + 21 + useEffect(() => { 22 + if (filteredTalks.length === 0) { 23 + return 24 + } 25 + 26 + let active = true 27 + 28 + fetchAtmosphereIonosphereEnrichment(filteredTalks) 29 + .then((result) => { 30 + if (!active) { 31 + return 32 + } 33 + setEnrichment(result) 34 + }) 35 + .catch(() => { 36 + if (!active) { 37 + return 38 + } 39 + setEnrichment({ byVodUri: new Map(), allTopics: [] }) 40 + }) 41 + 42 + return () => { 43 + active = false 44 + } 45 + }, [filteredTalks]) 46 + 47 + const filteredByTopic = useMemo(() => { 48 + if (!selectedTopic) { 49 + return filteredTalks 50 + } 51 + 52 + return filteredTalks.filter((talk) => 53 + (enrichment.byVodUri.get(talk.uri)?.topics ?? []).includes(selectedTopic), 54 + ) 55 + }, [filteredTalks, enrichment.byVodUri, selectedTopic]) 56 + 57 + const [featuredTalk, ...remainingTalks] = filteredByTopic 11 58 12 59 return ( 13 60 <div className="space-y-7 md:space-y-10" aria-busy={loading}> 14 61 <header className="space-y-2"> 15 - <h1 className="text-2xl font-semibold text-text">AtmosphereConf 2026</h1> 16 - <p className="text-sm text-muted">Official conference videos from stream.place, newest first.</p> 62 + <div className="flex items-center justify-between gap-3"> 63 + <h1 className="text-2xl font-semibold text-text">AtmosphereConf 2026</h1> 64 + <span className="inline-flex min-h-11 items-center rounded-md border border-line/45 bg-surface/80 px-3 text-xs text-muted"> 65 + ✦ ionosphere.tv 66 + </span> 67 + </div> 68 + <p className="text-sm text-muted">Official conference videos from stream.place, sorted newest first.</p> 69 + 70 + {enrichment.allTopics.length > 0 ? ( 71 + <div className="flex flex-wrap gap-2 pt-2"> 72 + <button 73 + type="button" 74 + onClick={() => setSelectedTopic('')} 75 + className="inline-flex min-h-11 items-center rounded-md border border-line/45 bg-surface/80 px-3 text-xs text-muted transition hover:border-line/60 hover:text-text" 76 + > 77 + All topics 78 + </button> 79 + {enrichment.allTopics.slice(0, 20).map((topic) => ( 80 + <button 81 + key={topic} 82 + type="button" 83 + onClick={() => setSelectedTopic(topic)} 84 + className="inline-flex min-h-11 items-center rounded-md border border-line/45 bg-surface/80 px-3 text-xs text-muted transition hover:border-line/60 hover:text-text" 85 + > 86 + {topic} 87 + </button> 88 + ))} 89 + </div> 90 + ) : null} 17 91 </header> 18 92 19 93 {loading ? ( ··· 26 100 {!loading && error ? ( 27 101 <ErrorPanel 28 102 title="Unable to load AtmosphereConf videos" 29 - message="The app could not load conference records right now. Check your connection and retry." 103 + message="We couldn't load conference videos right now. Check your connection, then try again." 30 104 onRetry={refresh} 31 105 /> 32 106 ) : null} ··· 37 111 <section className="space-y-3 md:space-y-4"> 38 112 <h2 className="text-sm font-medium text-muted">Latest Upload</h2> 39 113 <TalkCard talk={featuredTalk} featured /> 114 + {enrichment.byVodUri.get(featuredTalk.uri) ? ( 115 + <article className="rounded-lg border border-line/45 bg-surface/80 p-4 text-sm text-muted"> 116 + <p> 117 + {enrichment.byVodUri.get(featuredTalk.uri)?.room ?? 'Room TBD'} 118 + {' • '} 119 + {enrichment.byVodUri.get(featuredTalk.uri)?.track ?? 'Track TBD'} 120 + {' • '} 121 + {enrichment.byVodUri.get(featuredTalk.uri)?.scheduledAt 122 + ? formatDateTime(enrichment.byVodUri.get(featuredTalk.uri)?.scheduledAt as string) 123 + : 'Schedule TBD'} 124 + </p> 125 + {enrichment.byVodUri.get(featuredTalk.uri)?.speakerName ? ( 126 + <p className="mt-2"> 127 + Speaker: {enrichment.byVodUri.get(featuredTalk.uri)?.speakerName} 128 + {enrichment.byVodUri.get(featuredTalk.uri)?.speakerHandle 129 + ? ` (@${enrichment.byVodUri.get(featuredTalk.uri)?.speakerHandle})` 130 + : ''} 131 + </p> 132 + ) : null} 133 + {(enrichment.byVodUri.get(featuredTalk.uri)?.topics ?? []).length > 0 ? ( 134 + <div className="mt-2 flex flex-wrap gap-2"> 135 + {(enrichment.byVodUri.get(featuredTalk.uri)?.topics ?? []).slice(0, 8).map((topic) => ( 136 + <button 137 + key={topic} 138 + type="button" 139 + onClick={() => setSelectedTopic(topic)} 140 + className="inline-flex min-h-11 items-center rounded-md border border-line/45 bg-surface/70 px-3 text-xs text-muted transition hover:border-line/60 hover:text-text" 141 + > 142 + {topic} 143 + </button> 144 + ))} 145 + </div> 146 + ) : null} 147 + </article> 148 + ) : null} 40 149 </section> 41 150 ) : null} 42 151 ··· 45 154 <h2 className="text-sm font-medium text-muted">More Videos</h2> 46 155 <div className="grid grid-cols-[repeat(auto-fit,minmax(240px,1fr))] gap-3 md:grid-cols-[repeat(auto-fit,minmax(280px,1fr))] md:gap-4"> 47 156 {remainingTalks.map((talk) => ( 48 - <TalkCard key={talk.uri} talk={talk} /> 157 + <div key={talk.uri} className="space-y-2"> 158 + <TalkCard talk={talk} /> 159 + {enrichment.byVodUri.get(talk.uri) ? ( 160 + <article className="rounded-lg border border-line/45 bg-surface/80 p-3 text-xs text-muted"> 161 + <p> 162 + {enrichment.byVodUri.get(talk.uri)?.room ?? 'Room TBD'} 163 + {' • '} 164 + {enrichment.byVodUri.get(talk.uri)?.track ?? 'Track TBD'} 165 + </p> 166 + {(enrichment.byVodUri.get(talk.uri)?.topics ?? []).length > 0 ? ( 167 + <div className="mt-2 flex flex-wrap gap-2"> 168 + {(enrichment.byVodUri.get(talk.uri)?.topics ?? []).slice(0, 6).map((topic) => ( 169 + <button 170 + key={topic} 171 + type="button" 172 + onClick={() => setSelectedTopic(topic)} 173 + className="inline-flex min-h-11 items-center rounded-md border border-line/45 bg-surface/70 px-2.5 text-[11px] text-muted transition hover:border-line/60 hover:text-text" 174 + > 175 + {topic} 176 + </button> 177 + ))} 178 + </div> 179 + ) : null} 180 + </article> 181 + ) : null} 182 + </div> 49 183 ))} 50 184 </div> 51 185 </section>
+4 -4
src/pages/browse-page.tsx
··· 85 85 <div className="space-y-7 md:space-y-10" aria-busy={loading}> 86 86 <header className="space-y-2"> 87 87 <h1 className="text-2xl font-semibold text-text">Streamplace VOD Browser</h1> 88 - <p className="text-sm text-muted">All discovered repos, newest first.</p> 88 + <p className="text-sm text-muted">Browse every discovered Streamplace VOD, sorted newest first.</p> 89 89 </header> 90 90 91 91 {loading ? ( ··· 98 98 {!loading && error ? ( 99 99 <ErrorPanel 100 100 title="Unable to load talks" 101 - message="The app could not fetch records from discovered Streamplace repos. Check your connection and retry." 101 + message="We couldn't load videos from Streamplace repos right now. Check your connection, then try again." 102 102 onRetry={refresh} 103 103 /> 104 104 ) : null} ··· 141 141 </summary> 142 142 <div className="mt-3 space-y-3"> 143 143 <p className="text-sm text-muted"> 144 - {sourceRepos.length} repo{sourceRepos.length === 1 ? '' : 's'} with{' '} 144 + Found {sourceRepos.length} repo{sourceRepos.length === 1 ? '' : 's'} publishing{' '} 145 145 <code>place.stream.video</code> records. 146 146 </p> 147 147 <div className="flex flex-wrap gap-2"> ··· 155 155 ))} 156 156 </div> 157 157 <p className="text-xs text-muted"> 158 - AtmosphereConf official repo contributes {atmosphereCount} video 158 + The official AtmosphereConf repo currently contributes {atmosphereCount} video 159 159 {atmosphereCount === 1 ? '' : 's'}. 160 160 </p> 161 161 </div>
+7 -7
src/pages/search-page.tsx
··· 252 252 type="search" 253 253 value={query} 254 254 onChange={onQueryChange} 255 - placeholder="Search by title, tags, or topics" 255 + placeholder="Search titles, tags, and topics" 256 256 className="h-11 w-full bg-transparent text-sm text-text outline-none placeholder:text-muted" 257 257 autoComplete="off" 258 258 /> ··· 261 261 {!loading && !error && trimmedQuery ? ( 262 262 <section className="rounded-lg border border-line/45 bg-surface/80 p-4 text-xs text-muted"> 263 263 <p> 264 - Search blends semantic ranking for all Streamplace VODs with richer AtmosphereConf tags/topics. 264 + Results combine semantic ranking for all Streamplace VODs with richer AtmosphereConf metadata. 265 265 </p> 266 - {remoteLoading ? <p className="mt-2">Ranking query...</p> : null} 266 + {remoteLoading ? <p className="mt-2">Ranking results...</p> : null} 267 267 {!remoteLoading && remoteMode ? ( 268 268 <p className="mt-2"> 269 - Mode: {remoteMode === 'semantic' ? 'semantic embeddings' : 'lexical fallback'} 269 + Search mode: {remoteMode === 'semantic' ? 'semantic embeddings' : 'keyword fallback'} 270 270 </p> 271 271 ) : null} 272 272 {!remoteLoading && remoteNotice ? <p className="mt-2">{remoteNotice}</p> : null} 273 273 {!remoteLoading && remoteGeneratedAt ? ( 274 274 <p className="mt-2"> 275 - Index snapshot: {new Date(remoteGeneratedAt).toLocaleString()} 275 + Index updated: {new Date(remoteGeneratedAt).toLocaleString()} 276 276 {remoteIndexedCount !== null ? ` (${remoteIndexedCount} embedded videos)` : ''} 277 277 </p> 278 278 ) : null} ··· 305 305 {!loading && error ? ( 306 306 <ErrorPanel 307 307 title="Search unavailable" 308 - message="Talk metadata failed to load, so live filtering is temporarily unavailable." 308 + message="We couldn't load video metadata, so search is temporarily unavailable. Please try again." 309 309 onRetry={refresh} 310 310 /> 311 311 ) : null} ··· 314 314 <section className="rounded-lg border border-line/45 bg-surface/80 p-5"> 315 315 <h3 className="text-base font-semibold text-text">No results</h3> 316 316 <p className="mt-2 text-sm leading-relaxed text-muted"> 317 - Try a different keyword or remove filters to explore all talks. 317 + Try a different search term, or clear your query to browse everything. 318 318 </p> 319 319 </section> 320 320 ) : null}
+6 -6
src/pages/tag-page.tsx
··· 18 18 if (!tag) { 19 19 return ( 20 20 <ErrorPanel 21 - title="Invalid tag" 22 - message="This tag route is not valid." 23 - onRetry={refresh} 24 - /> 21 + title="Invalid tag" 22 + message="That tag link isn't valid. Go back to Search and pick a tag again." 23 + onRetry={refresh} 24 + /> 25 25 ) 26 26 } 27 27 ··· 47 47 {!loading && error ? ( 48 48 <ErrorPanel 49 49 title="Tag view unavailable" 50 - message="Talk metadata failed to load, so tag filtering is temporarily unavailable." 50 + message="We couldn't load video metadata, so tag filtering is temporarily unavailable." 51 51 onRetry={refresh} 52 52 /> 53 53 ) : null} ··· 56 56 <section className="rounded-lg border border-line/45 bg-surface/80 p-5"> 57 57 <h2 className="text-base font-semibold text-text">No talks for this tag</h2> 58 58 <p className="mt-2 text-sm leading-relaxed text-muted"> 59 - Try a different tag from the search page. 59 + Try another tag from Search. 60 60 </p> 61 61 </section> 62 62 ) : null}
+11 -9
src/pages/video-page.tsx
··· 208 208 }, [resolvedUri, reloadToken]) 209 209 210 210 const onRetryPlayback = useCallback(() => { 211 + hapticTap() 211 212 setReloadToken((token) => token + 1) 212 213 }, []) 213 214 ··· 304 305 const onTouchEnd = () => { 305 306 const distance = latestY - startY 306 307 if (distance > 120) { 308 + hapticBack() 307 309 navigate(-1) 308 310 } 309 311 } ··· 427 429 return ( 428 430 <ErrorPanel 429 431 title="Invalid video link" 430 - message="This link is missing a valid AT URI." 432 + message="This video link is missing required information. Go back and open the video again." 431 433 onRetry={() => navigate('/')} 432 434 /> 433 435 ) ··· 436 438 if (!talk && (talksLoading || metadataLoading)) { 437 439 return ( 438 440 <section className="rounded-lg border border-line/45 bg-surface/80 p-5"> 439 - <p className="text-sm text-muted">Loading video metadata...</p> 441 + <p className="text-sm text-muted">Loading video details...</p> 440 442 </section> 441 443 ) 442 444 } ··· 445 447 return ( 446 448 <ErrorPanel 447 449 title="Talk not found" 448 - message="This video could not be located in the conference catalog." 450 + message="We couldn't find this video in the current catalog. It may have been removed or moved." 449 451 onRetry={() => navigate('/')} 450 452 /> 451 453 ) ··· 487 489 <div className="mt-4"> 488 490 <ErrorPanel 489 491 title="Playback failed" 490 - message={error ?? 'The video playlist could not be loaded.'} 492 + message={error ?? "We couldn't load this video's playlist."} 491 493 onRetry={onRetryPlayback} 492 494 /> 493 495 </div> ··· 499 501 </p> 500 502 ) : null} 501 503 502 - <div className="flex flex-wrap items-center gap-3 text-sm text-muted"> 504 + <div className="grid gap-3 text-sm text-muted sm:grid-cols-[auto,1fr,auto] sm:items-center"> 503 505 <Button variant="secondary" onClick={onTogglePlay}> 504 506 Play / Pause 505 507 </Button> ··· 507 509 {playbackElapsed} / {playbackTotal} 508 510 </p> 509 511 510 - <label className="flex min-h-11 min-w-[12rem] flex-1 items-center gap-2"> 512 + <label className="flex min-h-11 min-w-0 items-center gap-2 sm:col-span-3"> 511 513 <span className="sr-only">Seek timeline</span> 512 514 <input 513 515 type="range" ··· 521 523 /> 522 524 </label> 523 525 524 - <label className="flex min-h-11 items-center gap-2"> 526 + <label className="flex min-h-11 items-center gap-2 sm:justify-self-end"> 525 527 <span>Speed</span> 526 528 <select 527 529 value={playbackRate} ··· 545 547 className="inline-flex min-h-11 items-center gap-2 rounded-lg border border-line/60 bg-surface/80 px-3 text-sm text-text transition hover:bg-surface/90" 546 548 > 547 549 <ArrowDownToLine className="h-4 w-4" /> 548 - Download HLS Playlist 550 + Download playlist (.m3u8) 549 551 </a> 550 552 ) : null} 551 553 ··· 556 558 className="inline-flex min-h-11 items-center gap-2 rounded-lg border border-line/60 bg-surface/80 px-3 text-sm text-text transition hover:bg-surface/90" 557 559 > 558 560 <ArrowDownToLine className="h-4 w-4" /> 559 - Download Source MP4 561 + Download source MP4 560 562 </a> 561 563 ) : null} 562 564 </div>