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

Configure Feed

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

feat: monochrome redesign, native player, footer, download + speed controls (AI-assisted)

j4ckxyz 7f7cf3c4 4dda7ff8

+791 -317
+20
.github/copilot-instructions.md
··· 1 + ## Design Context 2 + 3 + ### Users 4 + Developers, researchers, and ATmosphereConf attendees browsing conference talks across desktop and mobile. They need to quickly scan talks, find relevant sessions by title, and jump into playback with minimal friction. 5 + 6 + ### Brand Personality 7 + Atmospheric, focused, and technical. The experience should feel calm and immersive while still snappy and practical for heavy browsing. 8 + 9 + ### Aesthetic Direction 10 + Dark, glassy, aurora-inspired interface with subtle motion and texture. Monospace-first typography per product requirement, translucent surfaces over a deep blue/teal/purple backdrop, and restrained interactive glow for key controls. 11 + 12 + ### Accessibility Target 13 + WCAG AA baseline across all views and interactions. 14 + 15 + ### Design Principles 16 + 1. Keep browsing fast and legible first, then layer atmosphere through motion and texture. 17 + 2. Use glass treatment purposefully for navigational and content surfaces, not decorative overuse. 18 + 3. Maintain strong interaction clarity with touch-friendly controls and immediate feedback. 19 + 4. Prioritize resilient UX: clear loading, graceful failures, and predictable recovery. 20 + 5. Preserve mobile parity with desktop through adaptive navigation and gesture support.
+12 -1
index.html
··· 3 3 <head> 4 4 <meta charset="UTF-8" /> 5 5 <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> 6 + <link rel="preconnect" href="https://fonts.googleapis.com" /> 7 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> 8 + <link 9 + rel="preload" 10 + as="style" 11 + href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" 12 + /> 13 + <link 14 + rel="stylesheet" 15 + href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" 16 + /> 6 17 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 - <meta name="theme-color" content="#0a1020" /> 18 + <meta name="theme-color" content="#000000" /> 8 19 <meta 9 20 name="description" 10 21 content="Atmosphere VODs is a glassy PWA for browsing ATmosphereConf 2026 talks."
+1 -1
src/App.tsx
··· 10 10 11 11 function RouteFallback() { 12 12 return ( 13 - <section className="glass-panel rounded-2xl p-5"> 13 + <section className="rounded-lg border border-line/45 bg-surface/80 p-5"> 14 14 <p className="text-sm text-muted">Loading view...</p> 15 15 </section> 16 16 )
+3 -3
src/components/error-panel.tsx
··· 10 10 11 11 export function ErrorPanel({ title, message, onRetry }: ErrorPanelProps) { 12 12 return ( 13 - <section className="glass-panel rounded-2xl p-6"> 13 + <section className="rounded-lg border border-line/45 bg-surface/80 p-5"> 14 14 <div className="flex items-start gap-3"> 15 - <AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-accent" /> 15 + <AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-muted" /> 16 16 <div> 17 17 <h2 className="text-base font-semibold text-text">{title}</h2> 18 - <p className="mt-2 max-w-[65ch] text-sm leading-relaxed text-muted">{message}</p> 18 + <p className="mt-2 max-w-[65ch] text-sm leading-relaxed text-text/85">{message}</p> 19 19 </div> 20 20 </div> 21 21
+54 -23
src/components/layout/app-shell.tsx
··· 29 29 {...props} 30 30 className={({ isActive }) => 31 31 cn( 32 - 'group min-h-11 rounded-xl border border-transparent px-3 py-2 text-xs text-muted transition-all duration-200 hover:text-text', 33 - 'flex flex-1 items-center justify-center gap-2 md:w-full md:justify-start md:px-4 md:text-sm', 34 - isActive && 'glass-panel text-text shadow-glass', 32 + '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', 33 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text/35 md:text-sm', 34 + isActive 35 + ? 'border border-accent/45 bg-surface/80 text-accent' 36 + : 'border border-transparent text-muted hover:border-line/45 hover:bg-surface/70 hover:text-text', 35 37 ) 36 38 } 37 39 > ··· 43 45 44 46 export function AppShell({ children }: PropsWithChildren) { 45 47 return ( 46 - <div className="relative min-h-svh overflow-x-hidden"> 47 - <div className="aurora animate-aurora" aria-hidden="true" /> 48 - <div className="noise-overlay animate-noise" aria-hidden="true" /> 48 + <div className="relative isolate min-h-svh bg-bg"> 49 + <header className="sticky top-0 z-10 border-b border-line/45 bg-surface/80 supports-[backdrop-filter]:backdrop-blur-md"> 50 + <div className="mx-auto flex w-full max-w-5xl items-center justify-between gap-3 px-3 py-2.5 sm:px-4 md:px-6 md:py-3"> 51 + <p className="text-base font-bold tracking-[0.01em] text-text md:text-lg">Atmosphere VODs</p> 49 52 50 - <div className="relative z-10 mx-auto flex min-h-svh w-full max-w-7xl pb-24 md:pb-0"> 51 - <aside className="hidden w-64 shrink-0 p-4 md:block lg:p-6"> 52 - <div className="glass-panel sticky top-6 rounded-2xl p-4"> 53 - <h1 className="text-lg font-semibold text-text">Atmosphere VODs</h1> 54 - <p className="mt-2 max-w-[22ch] text-sm leading-relaxed text-muted"> 55 - Browse every ATmosphereConf 2026 talk in a fast glassy PWA. 56 - </p> 53 + <a href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-2 focus:rounded-md focus:bg-surface focus:px-3 focus:py-2 focus:text-sm focus:text-text"> 54 + Skip to content 55 + </a> 56 + 57 + <nav className="hidden items-center gap-1.5 md:flex lg:gap-2" aria-label="Primary"> 58 + {navItems.map((item) => ( 59 + <NavItem key={item.to} label={item.label} to={item.to} end={item.end} icon={item.icon} /> 60 + ))} 61 + </nav> 62 + </div> 63 + </header> 57 64 58 - <nav className="mt-6 flex flex-col gap-2"> 59 - {navItems.map((item) => ( 60 - <NavItem key={item.to} label={item.label} to={item.to} end={item.end} icon={item.icon} /> 61 - ))} 62 - </nav> 63 - </div> 64 - </aside> 65 + <main 66 + id="main-content" 67 + className="relative z-10 mx-auto w-full max-w-5xl px-3 pb-24 pt-7 sm:px-4 md:px-6 md:pb-10 md:pt-10" 68 + > 69 + {children} 70 + </main> 65 71 66 - <main className="w-full p-4 md:p-6 lg:p-8">{children}</main> 67 - </div> 72 + <footer className="relative z-10 border-t border-line/45 bg-surface/80 supports-[backdrop-filter]:backdrop-blur-md"> 73 + <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"> 74 + <p>Open source · MIT licence</p> 75 + <p className="flex items-center gap-3"> 76 + <a 77 + href="https://tangled.sh/@j4ck.xyz/atmosphere-vods" 78 + target="_blank" 79 + rel="noreferrer" 80 + className="underline-offset-4 hover:text-text hover:underline" 81 + > 82 + Tangled 83 + </a> 84 + <a 85 + href="https://github.com/j4ckxyz/atmosphere-vods" 86 + target="_blank" 87 + rel="noreferrer" 88 + className="underline-offset-4 hover:text-text hover:underline" 89 + > 90 + GitHub 91 + </a> 92 + </p> 93 + <p>Built for the Streamplace VOD JAM</p> 94 + </div> 95 + </footer> 68 96 69 - <nav className="glass-panel fixed inset-x-3 bottom-3 z-20 flex min-h-16 items-center gap-2 rounded-2xl px-2 py-2 md:hidden"> 97 + <nav 98 + className="fixed inset-x-2 bottom-[max(0.5rem,env(safe-area-inset-bottom))] z-20 flex min-h-16 items-center gap-1.5 rounded-xl border border-line/45 bg-surface/80 px-1.5 py-1.5 supports-[backdrop-filter]:backdrop-blur-md sm:inset-x-3 sm:gap-2 sm:px-2 sm:py-2 md:hidden" 99 + aria-label="Bottom tabs" 100 + > 70 101 {navItems.map((item) => ( 71 102 <NavItem key={item.to} label={item.label} to={item.to} end={item.end} icon={item.icon} /> 72 103 ))}
+108 -23
src/components/talk-card.tsx
··· 1 1 import { CalendarDays, Clock3, UserRound } from 'lucide-react' 2 + import { useEffect, useRef, useState } from 'react' 2 3 import { Link } from 'react-router-dom' 3 4 4 5 import { cardTapHaptic } from '@/lib/haptics' 5 6 import { formatDate, formatDuration, truncateDid } from '@/lib/format' 6 7 import { toVideoPath } from '@/lib/routes' 8 + import { getCachedThumbnail, getOrCreateThumbnail } from '@/lib/thumbnails' 7 9 import type { AppTalk } from '@/lib/types' 10 + import { cn } from '@/lib/utils' 8 11 9 12 interface TalkCardProps { 10 13 talk: AppTalk 11 - index: number 14 + featured?: boolean 12 15 } 13 16 14 - export function TalkCard({ talk, index }: TalkCardProps) { 17 + export function TalkCard({ talk, featured = false }: TalkCardProps) { 18 + const cardRef = useRef<HTMLAnchorElement | null>(null) 19 + const [thumbnail, setThumbnail] = useState<string | null>(() => getCachedThumbnail(talk.uri)) 20 + const [hasEnteredView, setHasEnteredView] = useState<boolean>(false) 21 + const featuredThumbnail = featured ? thumbnail : null 22 + 23 + useEffect(() => { 24 + if (!featured || thumbnail || hasEnteredView || !cardRef.current) { 25 + return 26 + } 27 + 28 + const observer = new IntersectionObserver( 29 + (entries) => { 30 + const [entry] = entries 31 + if (!entry?.isIntersecting) { 32 + return 33 + } 34 + 35 + setHasEnteredView(true) 36 + observer.disconnect() 37 + }, 38 + { 39 + rootMargin: '240px 0px', 40 + threshold: 0.12, 41 + }, 42 + ) 43 + 44 + observer.observe(cardRef.current) 45 + 46 + return () => { 47 + observer.disconnect() 48 + } 49 + }, [featured, thumbnail, hasEnteredView]) 50 + 51 + useEffect(() => { 52 + if (!featured || !hasEnteredView || thumbnail) { 53 + return 54 + } 55 + 56 + let active = true 57 + 58 + getOrCreateThumbnail(talk.uri).then((result) => { 59 + if (!active || !result) { 60 + return 61 + } 62 + setThumbnail(result) 63 + }) 64 + 65 + return () => { 66 + active = false 67 + } 68 + }, [featured, hasEnteredView, thumbnail, talk.uri]) 69 + 15 70 return ( 16 71 <Link 72 + ref={cardRef} 17 73 to={toVideoPath(talk.uri)} 18 74 onClick={cardTapHaptic} 19 - className="glass-panel group animate-rise rounded-2xl p-4 transition-all duration-300 hover:-translate-y-1 hover:shadow-lift" 20 - style={{ animationDelay: `${Math.min(index * 35, 350)}ms` }} 75 + className={cn( 76 + 'group relative block w-full overflow-hidden rounded-xl border transition-[background-color,border-color,box-shadow] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text/35', 77 + featured 78 + ? 'border-line/45 bg-surface/80 hover:border-line/60 supports-[backdrop-filter]:backdrop-blur-md' 79 + : 'border-line/35 bg-surface/80 hover:border-line/50', 80 + !featured && 'perf-content-auto', 81 + featured ? 'p-5 md:p-6' : 'p-4', 82 + )} 21 83 > 22 - <div className="flex min-h-28 flex-col gap-4"> 23 - <h3 className="line-clamp-2 text-base font-semibold leading-tight text-text">{talk.title}</h3> 84 + {featuredThumbnail ? ( 85 + <div className="pointer-events-none absolute -inset-px"> 86 + <img 87 + src={featuredThumbnail} 88 + alt="" 89 + aria-hidden="true" 90 + loading="lazy" 91 + decoding="async" 92 + className="block h-full w-full object-cover" 93 + /> 94 + <div 95 + className="absolute inset-0" 96 + style={{ 97 + backgroundImage: 98 + 'linear-gradient(165deg, oklch(0 0 0 / 0.84), oklch(0.12 0 0 / 0.66))', 99 + }} 100 + /> 101 + </div> 102 + ) : null} 103 + 104 + <div className="relative z-10"> 105 + <h3 106 + className={cn( 107 + 'line-clamp-2 font-semibold leading-tight text-text', 108 + featured ? 'text-lg md:text-xl' : 'text-base', 109 + )} 110 + > 111 + {talk.title} 112 + </h3> 24 113 25 - <dl className="mt-auto space-y-2 text-xs text-muted"> 26 - <div className="flex items-center gap-2"> 27 - <UserRound className="h-3.5 w-3.5" /> 28 - <dt className="sr-only">Speaker</dt> 29 - <dd className="truncate">{talk.creatorName || truncateDid(talk.creatorDid)}</dd> 30 - </div> 31 - <div className="flex items-center gap-2"> 32 - <Clock3 className="h-3.5 w-3.5" /> 33 - <dt className="sr-only">Duration</dt> 34 - <dd>{formatDuration(talk.durationNs)}</dd> 35 - </div> 36 - <div className="flex items-center gap-2"> 37 - <CalendarDays className="h-3.5 w-3.5" /> 38 - <dt className="sr-only">Date</dt> 39 - <dd>{formatDate(talk.createdAt)}</dd> 40 - </div> 41 - </dl> 114 + <div className={cn('text-sm text-muted', featured ? 'mt-5 space-y-2' : 'mt-4 space-y-1')}> 115 + <p className="flex items-center gap-2 leading-relaxed"> 116 + <UserRound className="h-3.5 w-3.5 text-muted" /> 117 + <span className="truncate">{talk.creatorName || truncateDid(talk.creatorDid)}</span> 118 + </p> 119 + <p className={cn('flex items-center gap-2 leading-relaxed', featured && 'flex-wrap')}> 120 + <Clock3 className="h-3.5 w-3.5 text-muted" /> 121 + <span>{formatDuration(talk.durationNs)}</span> 122 + <span aria-hidden="true">•</span> 123 + <CalendarDays className="h-3.5 w-3.5 text-muted" /> 124 + <span>{formatDate(talk.createdAt)}</span> 125 + </p> 126 + </div> 42 127 </div> 43 128 </Link> 44 129 )
+9 -2
src/components/talk-grid-skeleton.tsx
··· 2 2 3 3 export function TalkGridSkeleton() { 4 4 return ( 5 - <div className="grid grid-cols-[repeat(auto-fit,minmax(260px,1fr))] gap-4 md:gap-5"> 5 + <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"> 6 6 {Array.from({ length: 12 }).map((_, index) => ( 7 - <article key={index} className="glass-panel rounded-2xl p-4"> 7 + <article 8 + key={index} 9 + className={ 10 + index === 0 11 + ? 'perf-content-auto rounded-xl border border-line/45 bg-surface/80 p-5 md:p-6' 12 + : 'perf-content-auto rounded-xl border border-line/45 bg-surface/80 p-4' 13 + } 14 + > 8 15 <Skeleton className="h-5 w-11/12" /> 9 16 <Skeleton className="mt-2 h-5 w-3/4" /> 10 17 <div className="mt-7 space-y-2">
+4 -4
src/components/ui/button.tsx
··· 5 5 import { cn } from '@/lib/utils' 6 6 7 7 const buttonVariants = cva( 8 - 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 shrink-0 [&_svg]:shrink-0 min-h-11 min-w-11', 8 + 'inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-xl text-sm font-medium transition-[transform,background-color,border-color,color,box-shadow] duration-200 active:translate-y-px focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text/35 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', 9 9 { 10 10 variants: { 11 11 variant: { 12 12 default: 13 - 'bg-accent/20 text-text border border-accent/40 hover:bg-accent/30 hover:-translate-y-0.5', 13 + 'bg-surface/80 text-text border border-line/55 hover:bg-surface/90 hover:-translate-y-0.5', 14 14 secondary: 15 - 'bg-surface text-text border border-line hover:bg-surface/80 hover:-translate-y-0.5', 15 + 'bg-surface/70 text-text border border-line/45 hover:bg-surface/85 hover:-translate-y-0.5', 16 16 ghost: 'text-muted hover:text-text hover:bg-surface/70', 17 17 }, 18 18 size: { 19 19 default: 'h-11 px-5 py-2', 20 - sm: 'h-10 rounded-lg gap-1.5 px-3', 20 + sm: 'h-11 rounded-lg gap-1.5 px-3', 21 21 icon: 'h-11 w-11', 22 22 }, 23 23 },
+1 -1
src/components/ui/skeleton.tsx
··· 4 4 return ( 5 5 <div 6 6 className={cn( 7 - 'rounded-xl bg-surface/80 before:absolute before:inset-0 before:animate-pulse before:rounded-xl before:bg-white/5', 7 + 'rounded-lg bg-surface/45 before:absolute before:inset-0 before:animate-pulse before:rounded-lg before:bg-text/6', 8 8 'relative overflow-hidden', 9 9 className, 10 10 )}
+16 -52
src/index.css
··· 1 - @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap'); 2 - 3 1 @tailwind base; 4 2 @tailwind components; 5 3 @tailwind utilities; ··· 14 12 } 15 13 16 14 * { 17 - scrollbar-width: thin; 18 - scrollbar-color: oklch(0.66 0.03 238 / 0.6) oklch(0.16 0.03 246 / 0.6); 19 - } 20 - 21 - *::-webkit-scrollbar { 22 - width: 10px; 23 - height: 10px; 24 - } 25 - 26 - *::-webkit-scrollbar-thumb { 27 - background: oklch(0.66 0.03 238 / 0.6); 28 - border-radius: 999px; 29 - } 30 - 31 - *::-webkit-scrollbar-track { 32 - background: oklch(0.16 0.03 246 / 0.6); 15 + box-sizing: border-box; 33 16 } 34 17 35 18 body { 36 19 margin: 0; 37 20 min-height: 100svh; 38 - color: oklch(0.93 0.02 250); 39 - background: oklch(0.11 0.03 248); 21 + color: oklch(0.95 0 0); 22 + background: oklch(0 0 0); 40 23 } 41 24 42 25 a { ··· 48 31 min-height: 100svh; 49 32 } 50 33 51 - .aurora { 52 - position: fixed; 53 - inset: -25%; 54 - background: 55 - radial-gradient(circle at 20% 25%, oklch(0.41 0.16 233 / 0.28), transparent 45%), 56 - radial-gradient(circle at 82% 18%, oklch(0.48 0.17 286 / 0.2), transparent 42%), 57 - radial-gradient(circle at 62% 82%, oklch(0.46 0.15 197 / 0.25), transparent 46%), 58 - radial-gradient(circle at 10% 88%, oklch(0.3 0.09 252 / 0.25), transparent 56%), 59 - linear-gradient(140deg, oklch(0.12 0.03 248) 0%, oklch(0.1 0.04 255) 100%); 60 - filter: saturate(120%); 61 - } 62 - 63 - .noise-overlay { 64 - position: fixed; 65 - inset: -50%; 66 - pointer-events: none; 67 - opacity: 0.085; 68 - mix-blend-mode: screen; 69 - background-image: 70 - radial-gradient(circle at 15% 18%, rgba(255, 255, 255, 0.35) 0 0.6px, transparent 0.7px), 71 - radial-gradient(circle at 73% 66%, rgba(255, 255, 255, 0.26) 0 0.6px, transparent 0.8px), 72 - radial-gradient(circle at 44% 37%, rgba(255, 255, 255, 0.15) 0 0.7px, transparent 0.9px); 73 - background-size: 170px 170px; 74 - } 75 - 76 - .glass-panel { 77 - backdrop-filter: blur(18px); 78 - background: oklch(0.22 0.03 247 / 0.45); 79 - border: 1px solid oklch(0.64 0.03 244 / 0.32); 80 - box-shadow: 0 24px 70px oklch(0.03 0.02 250 / 0.5); 81 - } 82 - 83 34 .line-clamp-2 { 84 35 display: -webkit-box; 85 36 -webkit-line-clamp: 2; 86 37 -webkit-box-orient: vertical; 87 38 overflow: hidden; 39 + } 40 + 41 + .eyebrow-label { 42 + font-size: 0.875rem; 43 + line-height: 1.25rem; 44 + font-weight: 500; 45 + } 46 + 47 + @supports (content-visibility: auto) { 48 + .perf-content-auto { 49 + content-visibility: auto; 50 + contain-intrinsic-size: 320px; 51 + } 88 52 } 89 53 90 54 @media (prefers-reduced-motion: reduce) {
+77 -3
src/lib/api.ts
··· 14 14 15 15 const profileCache = new Map<string, Promise<ActorProfile | null>>() 16 16 let pdsUrlPromise: Promise<string> | null = null 17 + const REQUEST_TIMEOUT_MS = 8_000 18 + const PROFILE_CONCURRENCY = 6 19 + 20 + async function fetchWithTimeout( 21 + url: string, 22 + init?: RequestInit, 23 + timeoutMs: number = REQUEST_TIMEOUT_MS, 24 + ): Promise<Response> { 25 + const controller = new AbortController() 26 + const timeout = setTimeout(() => controller.abort(), timeoutMs) 27 + 28 + try { 29 + return await fetch(url, { ...init, signal: controller.signal }) 30 + } finally { 31 + clearTimeout(timeout) 32 + } 33 + } 17 34 18 35 async function fetchJson<T>(url: string): Promise<T> { 19 - const response = await fetch(url) 36 + let response: Response 37 + 38 + try { 39 + response = await fetchWithTimeout(url) 40 + } catch (error) { 41 + if (error instanceof DOMException && error.name === 'AbortError') { 42 + throw new Error('Request timed out') 43 + } 44 + 45 + throw error 46 + } 47 + 20 48 if (!response.ok) { 21 49 throw new Error(`Request failed (${response.status})`) 22 50 } ··· 24 52 return (await response.json()) as T 25 53 } 26 54 55 + async function mapWithConcurrency<T, R>( 56 + items: T[], 57 + limit: number, 58 + mapper: (item: T) => Promise<R>, 59 + ): Promise<R[]> { 60 + if (items.length === 0) { 61 + return [] 62 + } 63 + 64 + const results = new Array<R>(items.length) 65 + let nextIndex = 0 66 + 67 + async function worker() { 68 + while (nextIndex < items.length) { 69 + const index = nextIndex 70 + nextIndex += 1 71 + results[index] = await mapper(items[index]) 72 + } 73 + } 74 + 75 + await Promise.all( 76 + Array.from({ length: Math.min(limit, items.length) }, () => worker()), 77 + ) 78 + return results 79 + } 80 + 27 81 export function resolvePdsUrl(): Promise<string> { 28 82 if (pdsUrlPromise) { 29 83 return pdsUrlPromise ··· 88 142 ) 89 143 90 144 const uniqueCreators = Array.from(new Set(sorted.map((record) => record.value.creator))) 91 - const profiles = await Promise.all(uniqueCreators.map((did) => getCachedProfile(did))) 145 + const profiles = await mapWithConcurrency( 146 + uniqueCreators, 147 + PROFILE_CONCURRENCY, 148 + (did) => getCachedProfile(did), 149 + ) 92 150 const profileMap = new Map(uniqueCreators.map((did, index) => [did, profiles[index]])) 93 151 94 152 return sorted.map((record) => { ··· 104 162 creatorHandle: profile?.handle, 105 163 durationNs: record.value.duration, 106 164 createdAt: record.value.createdAt, 165 + sourceRef: record.value.source?.ref, 166 + sourceMimeType: record.value.source?.mimeType, 107 167 } 108 168 }) 109 169 } ··· 111 171 export async function fetchVideoPlaylist(uri: string): Promise<string> { 112 172 const query = new URLSearchParams({ uri }) 113 173 const playlistUrl = `${VOD_PLAYLIST_ENDPOINT}?${query.toString()}` 114 - const response = await fetch(playlistUrl) 174 + let response: Response 175 + 176 + try { 177 + response = await fetchWithTimeout(playlistUrl, undefined, 10_000) 178 + } catch (error) { 179 + if (error instanceof DOMException && error.name === 'AbortError') { 180 + throw new Error('Playlist request timed out') 181 + } 182 + 183 + throw error 184 + } 115 185 116 186 if (!response.ok) { 117 187 throw new Error(`Unable to load playlist (${response.status})`) ··· 125 195 126 196 return playlistUrl 127 197 } 198 + 199 + export function getArchiveBlobUrl(sourceRef: string): string { 200 + return `https://vod-beta.stream.place/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(REPO_DID)}&cid=${encodeURIComponent(sourceRef)}` 201 + }
+10 -2
src/lib/routes.ts
··· 2 2 return `/video/${encodeURIComponent(uri)}` 3 3 } 4 4 5 - export function fromVideoParam(param: string): string { 6 - return decodeURIComponent(param) 5 + export function fromVideoParam(param: string): string | undefined { 6 + try { 7 + const decoded = decodeURIComponent(param) 8 + if (!decoded.startsWith('at://')) { 9 + return undefined 10 + } 11 + return decoded 12 + } catch { 13 + return undefined 14 + } 7 15 }
+280
src/lib/thumbnails.ts
··· 1 + import { fetchVideoPlaylist } from './api' 2 + 3 + const THUMBNAIL_KEY_PREFIX = 'thumb:' 4 + const THUMBNAIL_QUALITY = 0.6 5 + const THUMBNAIL_SEEK_SECONDS = 15 6 + const THUMBNAIL_MAX_WIDTH = 480 7 + const THUMBNAIL_MAX_HEIGHT = 270 8 + const EXTRACTION_CONCURRENCY = 2 9 + const IDLE_EXTRACTION_TIMEOUT_MS = 2_000 10 + 11 + type ThumbnailResult = string | null 12 + 13 + type IdleCallback = (deadline: { didTimeout: boolean; timeRemaining: () => number }) => void 14 + 15 + type IdleWindow = Window & { 16 + requestIdleCallback?: (callback: IdleCallback, options?: { timeout: number }) => number 17 + } 18 + 19 + type NavigatorWithConnection = Navigator & { 20 + connection?: { 21 + saveData?: boolean 22 + } 23 + } 24 + 25 + const inflightExtractions = new Map<string, Promise<ThumbnailResult>>() 26 + const extractionQueue: Array<() => void> = [] 27 + let activeExtractions = 0 28 + 29 + function getStorageKey(uri: string): string { 30 + return `${THUMBNAIL_KEY_PREFIX}${uri}` 31 + } 32 + 33 + function canUseDom(): boolean { 34 + return typeof window !== 'undefined' && typeof document !== 'undefined' 35 + } 36 + 37 + function prefersReducedData(): boolean { 38 + if (!canUseDom()) { 39 + return false 40 + } 41 + 42 + const saveDataEnabled = Boolean((navigator as NavigatorWithConnection).connection?.saveData) 43 + const reducedDataMedia = 44 + typeof window.matchMedia === 'function' && 45 + window.matchMedia('(prefers-reduced-data: reduce)').matches 46 + 47 + return saveDataEnabled || reducedDataMedia 48 + } 49 + 50 + function runWhenBrowserIdle(task: () => Promise<ThumbnailResult>): Promise<ThumbnailResult> { 51 + if (!canUseDom()) { 52 + return task() 53 + } 54 + 55 + const idleWindow = window as IdleWindow 56 + return new Promise((resolve, reject) => { 57 + const execute: IdleCallback = () => { 58 + task().then(resolve).catch(reject) 59 + } 60 + 61 + if (typeof idleWindow.requestIdleCallback === 'function') { 62 + idleWindow.requestIdleCallback(execute, { timeout: IDLE_EXTRACTION_TIMEOUT_MS }) 63 + return 64 + } 65 + 66 + window.setTimeout(execute, 0) 67 + }) 68 + } 69 + 70 + function runWithConcurrencyLimit(task: () => Promise<ThumbnailResult>): Promise<ThumbnailResult> { 71 + return new Promise((resolve, reject) => { 72 + const run = () => { 73 + activeExtractions += 1 74 + task() 75 + .then(resolve) 76 + .catch(reject) 77 + .finally(() => { 78 + activeExtractions -= 1 79 + const next = extractionQueue.shift() 80 + if (next) { 81 + next() 82 + } 83 + }) 84 + } 85 + 86 + if (activeExtractions < EXTRACTION_CONCURRENCY) { 87 + run() 88 + return 89 + } 90 + 91 + extractionQueue.push(run) 92 + }) 93 + } 94 + 95 + function waitForVideoEvent( 96 + video: HTMLVideoElement, 97 + eventName: 'loadedmetadata' | 'seeked', 98 + timeoutMs: number = 12_000, 99 + ): Promise<void> { 100 + return new Promise((resolve, reject) => { 101 + const timeout = window.setTimeout(() => { 102 + cleanup() 103 + reject(new Error(`Video ${eventName} timed out`)) 104 + }, timeoutMs) 105 + 106 + function cleanup() { 107 + window.clearTimeout(timeout) 108 + video.removeEventListener(eventName, onSuccess) 109 + video.removeEventListener('error', onError) 110 + } 111 + 112 + function onSuccess() { 113 + cleanup() 114 + resolve() 115 + } 116 + 117 + function onError() { 118 + cleanup() 119 + reject(new Error('Video extraction failed')) 120 + } 121 + 122 + video.addEventListener(eventName, onSuccess, { once: true }) 123 + video.addEventListener('error', onError, { once: true }) 124 + }) 125 + } 126 + 127 + function saveThumbnail(uri: string, dataUrl: string) { 128 + try { 129 + localStorage.setItem(getStorageKey(uri), dataUrl) 130 + } catch { 131 + // no-op: storage quota or unavailable storage should not block UI 132 + } 133 + } 134 + 135 + export function getCachedThumbnail(uri: string): string | null { 136 + if (!canUseDom()) { 137 + return null 138 + } 139 + 140 + try { 141 + return localStorage.getItem(getStorageKey(uri)) 142 + } catch { 143 + return null 144 + } 145 + } 146 + 147 + async function extractThumbnail(uri: string): Promise<ThumbnailResult> { 148 + if (!canUseDom() || prefersReducedData()) { 149 + return null 150 + } 151 + 152 + const cached = getCachedThumbnail(uri) 153 + if (cached) { 154 + return cached 155 + } 156 + 157 + const playlistUrl = await fetchVideoPlaylist(uri) 158 + const video = document.createElement('video') 159 + 160 + video.crossOrigin = 'anonymous' 161 + video.muted = true 162 + video.preload = 'metadata' 163 + video.playsInline = true 164 + video.setAttribute('muted', '') 165 + video.setAttribute('aria-hidden', 'true') 166 + video.style.position = 'fixed' 167 + video.style.left = '-9999px' 168 + video.style.top = '-9999px' 169 + video.style.width = '1px' 170 + video.style.height = '1px' 171 + video.style.opacity = '0' 172 + 173 + document.body.appendChild(video) 174 + 175 + let hls: { destroy: () => void; loadSource: (source: string) => void; attachMedia: (media: HTMLMediaElement) => void } | null = null 176 + 177 + try { 178 + if (video.canPlayType('application/vnd.apple.mpegurl')) { 179 + video.src = playlistUrl 180 + } else { 181 + const { default: Hls } = await import('hls.js/light') 182 + if (!Hls.isSupported()) { 183 + return null 184 + } 185 + 186 + hls = new Hls({ 187 + maxBufferLength: 10, 188 + lowLatencyMode: true, 189 + }) 190 + hls.loadSource(playlistUrl) 191 + hls.attachMedia(video) 192 + } 193 + 194 + if (!(video.readyState >= 1 && Number.isFinite(video.duration) && video.duration > 0)) { 195 + await waitForVideoEvent(video, 'loadedmetadata') 196 + } 197 + 198 + if (!Number.isFinite(video.duration) || video.duration <= 0) { 199 + return null 200 + } 201 + 202 + const targetTime = video.duration >= THUMBNAIL_SEEK_SECONDS 203 + ? THUMBNAIL_SEEK_SECONDS 204 + : video.duration * 0.1 205 + 206 + if (targetTime > 0.01 && Math.abs(video.currentTime - targetTime) > 0.05) { 207 + const seekPromise = waitForVideoEvent(video, 'seeked') 208 + video.currentTime = Math.min(targetTime, Math.max(video.duration - 0.1, 0)) 209 + await seekPromise 210 + } 211 + 212 + if (video.videoWidth <= 0 || video.videoHeight <= 0) { 213 + return null 214 + } 215 + 216 + const scale = Math.min( 217 + 1, 218 + THUMBNAIL_MAX_WIDTH / video.videoWidth, 219 + THUMBNAIL_MAX_HEIGHT / video.videoHeight, 220 + ) 221 + const outputWidth = Math.max(1, Math.round(video.videoWidth * scale)) 222 + const outputHeight = Math.max(1, Math.round(video.videoHeight * scale)) 223 + 224 + const canvas = document.createElement('canvas') 225 + canvas.width = outputWidth 226 + canvas.height = outputHeight 227 + 228 + const ctx = canvas.getContext('2d') 229 + if (!ctx) { 230 + return null 231 + } 232 + 233 + ctx.imageSmoothingEnabled = true 234 + ctx.imageSmoothingQuality = 'high' 235 + ctx.drawImage(video, 0, 0, outputWidth, outputHeight) 236 + const dataUrl = canvas.toDataURL('image/jpeg', THUMBNAIL_QUALITY) 237 + 238 + if (!dataUrl.startsWith('data:image/jpeg')) { 239 + return null 240 + } 241 + 242 + saveThumbnail(uri, dataUrl) 243 + return dataUrl 244 + } catch { 245 + return null 246 + } finally { 247 + if (hls) { 248 + hls.destroy() 249 + } 250 + video.pause() 251 + video.removeAttribute('src') 252 + video.load() 253 + video.remove() 254 + } 255 + } 256 + 257 + export async function getOrCreateThumbnail(uri: string): Promise<ThumbnailResult> { 258 + const cached = getCachedThumbnail(uri) 259 + if (cached) { 260 + return cached 261 + } 262 + 263 + if (prefersReducedData()) { 264 + return null 265 + } 266 + 267 + const inflight = inflightExtractions.get(uri) 268 + if (inflight) { 269 + return inflight 270 + } 271 + 272 + const pending = runWithConcurrencyLimit(() => runWhenBrowserIdle(() => extractThumbnail(uri))).finally( 273 + () => { 274 + inflightExtractions.delete(uri) 275 + }, 276 + ) 277 + 278 + inflightExtractions.set(uri, pending) 279 + return pending 280 + }
+10 -1
src/lib/types.ts
··· 7 7 creator: string 8 8 duration: number 9 9 createdAt: string 10 - source?: unknown 10 + source?: { 11 + $type?: string 12 + ref?: string 13 + mimeType?: string 14 + size?: number 15 + start?: number 16 + end?: number 17 + } 11 18 livestream?: { 12 19 uri?: string 13 20 } ··· 43 50 creatorHandle?: string 44 51 durationNs: number 45 52 createdAt: string 53 + sourceRef?: string 54 + sourceMimeType?: string 46 55 }
+8 -1
src/main.tsx
··· 6 6 import App from './App.tsx' 7 7 import { VideosProvider } from './state/videos-context.tsx' 8 8 9 - registerSW({ immediate: true }) 9 + registerSW({ 10 + immediate: true, 11 + onRegisterError: (error) => { 12 + if (import.meta.env.DEV) { 13 + console.error('Service worker registration failed', error) 14 + } 15 + }, 16 + }) 10 17 11 18 createRoot(document.getElementById('root')!).render( 12 19 <StrictMode>
+3 -3
src/pages/about-page.tsx
··· 1 1 export function AboutPage() { 2 2 return ( 3 - <section className="glass-panel animate-rise rounded-2xl p-6 md:p-7"> 4 - <p className="text-xs uppercase tracking-[0.16em] text-muted">About</p> 5 - <h2 className="mt-2 text-xl font-semibold text-text md:text-2xl">Atmosphere VODs</h2> 3 + <section className="rounded-lg border border-line/45 bg-surface/80 p-5 md:p-6"> 4 + <h1 className="text-2xl font-semibold text-text">About Atmosphere VODs</h1> 5 + <p className="mt-2 text-sm font-medium text-muted">Open Source Conference Archive</p> 6 6 <p className="mt-4 max-w-[70ch] text-sm leading-relaxed text-muted"> 7 7 Atmosphere VODs is an open-source PWA for browsing ATmosphereConf 2026 talks, built for the 8 8 Streamplace VOD JAM. Built with React, Vite, and the AT Protocol. Created by Jack. AI-assisted
+30 -13
src/pages/browse-page.tsx
··· 5 5 6 6 export function BrowsePage() { 7 7 const { talks, loading, error, refresh } = useVideos() 8 + const [featuredTalk, ...remainingTalks] = talks 8 9 9 10 return ( 10 - <div className="space-y-5"> 11 - <header className="glass-panel animate-rise rounded-2xl p-5 md:p-6"> 12 - <p className="text-xs uppercase tracking-[0.16em] text-muted">ATmosphereConf 2026</p> 13 - <h2 className="mt-2 text-xl font-semibold text-text md:text-2xl">Browse Talks</h2> 14 - <p className="mt-3 max-w-[64ch] text-sm leading-relaxed text-muted"> 15 - Freshly sorted by newest publish date, with speaker names resolved when available. 16 - </p> 11 + <div className="space-y-7 md:space-y-10" aria-busy={loading}> 12 + <header className="space-y-2"> 13 + <h1 className="text-2xl font-semibold text-text">ATmosphereConf 2026 Talks</h1> 14 + <p className="text-sm text-muted">Newest first. Tap a talk to watch.</p> 17 15 </header> 18 16 19 - {loading ? <TalkGridSkeleton /> : null} 17 + {loading ? ( 18 + <div role="status" aria-live="polite"> 19 + <span className="sr-only">Loading talks</span> 20 + <TalkGridSkeleton /> 21 + </div> 22 + ) : null} 20 23 21 24 {!loading && error ? ( 22 25 <ErrorPanel ··· 27 30 ) : null} 28 31 29 32 {!loading && !error ? ( 30 - <section className="grid grid-cols-[repeat(auto-fit,minmax(260px,1fr))] gap-4 md:gap-5"> 31 - {talks.map((talk, index) => ( 32 - <TalkCard key={talk.uri} talk={talk} index={index} /> 33 - ))} 34 - </section> 33 + <> 34 + {featuredTalk ? ( 35 + <section className="space-y-3 md:space-y-4"> 36 + <h2 className="text-sm font-medium text-muted">Latest Upload</h2> 37 + <TalkCard talk={featuredTalk} featured /> 38 + </section> 39 + ) : null} 40 + 41 + {remainingTalks.length > 0 ? ( 42 + <section className="space-y-3"> 43 + <h2 className="text-sm font-medium text-muted">More Talks</h2> 44 + <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"> 45 + {remainingTalks.map((talk) => ( 46 + <TalkCard key={talk.uri} talk={talk} /> 47 + ))} 48 + </div> 49 + </section> 50 + ) : null} 51 + </> 35 52 ) : null} 36 53 </div> 37 54 )
+25 -13
src/pages/search-page.tsx
··· 9 9 export function SearchPage() { 10 10 const [query, setQuery] = useState<string>('') 11 11 const { talks, loading, error, refresh } = useVideos() 12 + const trimmedQuery = query.trim() 12 13 13 14 const filteredTalks = useMemo(() => { 14 - const normalized = query.trim().toLowerCase() 15 + const normalized = trimmedQuery.toLowerCase() 15 16 if (!normalized) { 16 17 return talks 17 18 } 18 19 19 20 return talks.filter((talk) => talk.title.toLowerCase().includes(normalized)) 20 - }, [talks, query]) 21 + }, [talks, trimmedQuery]) 21 22 22 23 return ( 23 - <div className="space-y-5"> 24 - <header className="glass-panel animate-rise rounded-2xl p-5 md:p-6"> 25 - <p className="text-xs uppercase tracking-[0.16em] text-muted">Instant Filter</p> 26 - <h2 className="mt-2 text-xl font-semibold text-text md:text-2xl">Search Talks</h2> 24 + <div className="space-y-7 md:space-y-10" aria-busy={loading}> 25 + <header className="space-y-4"> 26 + <h1 className="text-2xl font-semibold text-text">Search Talks</h1> 27 27 28 - <label className="glass-panel mt-4 flex min-h-11 items-center gap-3 rounded-xl border-line/70 px-3"> 28 + <label className="flex min-h-11 items-center gap-3 rounded-lg border border-line/45 bg-surface/80 px-3 focus-within:border-line/60 focus-within:ring-2 focus-within:ring-text/30"> 29 29 <Search className="h-4 w-4 text-muted" /> 30 30 <span className="sr-only">Search by title</span> 31 31 <input ··· 34 34 onChange={(event) => setQuery(event.target.value)} 35 35 placeholder="Search by title" 36 36 className="h-11 w-full bg-transparent text-sm text-text outline-none placeholder:text-muted" 37 + autoComplete="off" 37 38 /> 38 39 </label> 39 40 </header> 40 41 41 - {loading ? <TalkGridSkeleton /> : null} 42 + {loading ? ( 43 + <div role="status" aria-live="polite"> 44 + <span className="sr-only">Loading talks</span> 45 + <TalkGridSkeleton /> 46 + </div> 47 + ) : null} 42 48 43 49 {!loading && error ? ( 44 50 <ErrorPanel ··· 49 55 ) : null} 50 56 51 57 {!loading && !error && filteredTalks.length === 0 ? ( 52 - <section className="glass-panel rounded-2xl p-6"> 58 + <section className="rounded-lg border border-line/45 bg-surface/80 p-5"> 53 59 <h3 className="text-base font-semibold text-text">No results</h3> 54 60 <p className="mt-2 text-sm leading-relaxed text-muted"> 55 61 Try a different keyword or remove filters to explore all talks. ··· 58 64 ) : null} 59 65 60 66 {!loading && !error && filteredTalks.length > 0 ? ( 61 - <section className="grid grid-cols-[repeat(auto-fit,minmax(260px,1fr))] gap-4 md:gap-5"> 62 - {filteredTalks.map((talk, index) => ( 63 - <TalkCard key={talk.uri} talk={talk} index={index} /> 64 - ))} 67 + <section className="space-y-3"> 68 + <h2 className="text-sm font-medium text-muted">Results</h2> 69 + <p className="text-sm text-muted"> 70 + {filteredTalks.length} result{filteredTalks.length === 1 ? '' : 's'} 71 + </p> 72 + <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"> 73 + {filteredTalks.map((talk, index) => ( 74 + <TalkCard key={talk.uri} talk={talk} featured={index === 0 && trimmedQuery.length > 0} /> 75 + ))} 76 + </div> 65 77 </section> 66 78 ) : null} 67 79 </div>
+93 -140
src/pages/video-page.tsx
··· 1 1 import { 2 + ArrowDownToLine, 2 3 ArrowLeft, 3 - Maximize, 4 - Minimize, 5 - Pause, 6 - Play, 7 - Volume2, 8 - VolumeX, 9 4 } from 'lucide-react' 10 5 import { 6 + startTransition, 11 7 useCallback, 12 8 useEffect, 13 9 useMemo, ··· 19 15 20 16 import { ErrorPanel } from '@/components/error-panel' 21 17 import { Button } from '@/components/ui/button' 22 - import { fetchVideoPlaylist } from '@/lib/api' 18 + import { fetchVideoPlaylist, getArchiveBlobUrl } from '@/lib/api' 23 19 import { formatDate, formatDuration, truncateDid } from '@/lib/format' 24 - import { playHaptic } from '@/lib/haptics' 25 20 import { fromVideoParam } from '@/lib/routes' 26 21 import { useVideos } from '@/state/videos-context' 27 22 ··· 44 39 45 40 const [status, setStatus] = useState<PlaybackStatus>('idle') 46 41 const [error, setError] = useState<string | null>(null) 47 - const [isPlaying, setIsPlaying] = useState<boolean>(false) 48 42 const [currentTime, setCurrentTime] = useState<number>(0) 49 43 const [duration, setDuration] = useState<number>(0) 50 - const [volume, setVolume] = useState<number>(1) 51 - const [isFullscreen, setIsFullscreen] = useState<boolean>(false) 44 + const rafRef = useRef<number | null>(null) 45 + const [reloadToken, setReloadToken] = useState(0) 46 + const [playbackRate, setPlaybackRate] = useState<number>(1) 47 + const [playlistUrl, setPlaylistUrl] = useState<string | null>(null) 52 48 53 49 const resolvedUri = useMemo( 54 50 () => (encodedUri ? fromVideoParam(encodedUri) : undefined), ··· 57 53 58 54 const talk = useMemo(() => talks.find((item) => item.uri === resolvedUri), [talks, resolvedUri]) 59 55 60 - useEffect(() => { 61 - const onFullscreenChange = () => { 62 - setIsFullscreen(Boolean(document.fullscreenElement)) 63 - } 64 - 65 - document.addEventListener('fullscreenchange', onFullscreenChange) 66 - return () => { 67 - document.removeEventListener('fullscreenchange', onFullscreenChange) 68 - } 69 - }, []) 56 + const playbackElapsed = formatDuration(currentTime * 1_000_000_000) 57 + const playbackTotal = formatDuration(duration * 1_000_000_000 || talk?.durationNs || 0) 70 58 71 59 useEffect(() => { 72 60 if (!resolvedUri || !videoRef.current) { ··· 96 84 if (video.canPlayType('application/vnd.apple.mpegurl')) { 97 85 video.src = playlistUrl 98 86 } else { 99 - const { default: Hls } = await import('hls.js') 87 + const { default: Hls } = await import('hls.js/light') 100 88 if (!Hls.isSupported()) { 101 89 throw new Error('This browser does not support HLS playback.') 102 90 } ··· 110 98 hlsRef.current = hls 111 99 } 112 100 101 + setPlaylistUrl(playlistUrl) 113 102 setStatus('ready') 114 103 } catch (loadError) { 115 104 if (cancelled) { ··· 118 107 119 108 const message = loadError instanceof Error ? loadError.message : 'Failed to load video playlist.' 120 109 setError(message) 110 + setPlaylistUrl(null) 121 111 setStatus('error') 122 112 } 123 113 } ··· 133 123 video.pause() 134 124 video.removeAttribute('src') 135 125 video.load() 126 + setPlaylistUrl(null) 136 127 } 137 - }, [resolvedUri]) 128 + }, [resolvedUri, reloadToken]) 129 + 130 + const onRetryPlayback = useCallback(() => { 131 + setReloadToken((token) => token + 1) 132 + }, []) 138 133 139 - const onTogglePlayback = useCallback(async () => { 140 - playHaptic() 141 - const video = videoRef.current 142 - if (!video) { 134 + const syncPlaybackState = useCallback(() => { 135 + if (!videoRef.current) { 143 136 return 144 137 } 145 138 146 - if (video.paused) { 147 - try { 148 - await video.play() 149 - setIsPlaying(true) 150 - } catch { 151 - setError('Playback was blocked by the browser. Tap play again to retry.') 152 - } 153 - return 154 - } 139 + const current = videoRef.current.currentTime 140 + const total = Number.isFinite(videoRef.current.duration) ? videoRef.current.duration : 0 155 141 156 - video.pause() 157 - setIsPlaying(false) 142 + startTransition(() => { 143 + setCurrentTime(current) 144 + setDuration(total) 145 + }) 158 146 }, []) 159 147 160 148 const onTimeUpdate = useCallback(() => { 161 - if (!videoRef.current) { 162 - return 149 + if (rafRef.current) { 150 + cancelAnimationFrame(rafRef.current) 163 151 } 164 152 165 - setCurrentTime(videoRef.current.currentTime) 166 - setDuration(Number.isFinite(videoRef.current.duration) ? videoRef.current.duration : 0) 167 - setIsPlaying(!videoRef.current.paused) 168 - }, []) 153 + rafRef.current = requestAnimationFrame(() => { 154 + syncPlaybackState() 155 + rafRef.current = null 156 + }) 157 + }, [syncPlaybackState]) 169 158 170 - const onSeek = useCallback((event: ChangeEvent<HTMLInputElement>) => { 171 - const video = videoRef.current 172 - if (!video) { 173 - return 159 + useEffect(() => { 160 + return () => { 161 + if (rafRef.current) { 162 + cancelAnimationFrame(rafRef.current) 163 + } 174 164 } 175 - 176 - const seconds = Number(event.target.value) 177 - video.currentTime = seconds 178 - setCurrentTime(seconds) 179 165 }, []) 180 166 181 - const onVolume = useCallback((event: ChangeEvent<HTMLInputElement>) => { 167 + const onSpeedChange = useCallback((event: ChangeEvent<HTMLSelectElement>) => { 168 + const nextRate = Number(event.target.value) 182 169 const video = videoRef.current 183 170 if (!video) { 184 171 return 185 172 } 186 - 187 - const nextVolume = Number(event.target.value) 188 - video.volume = nextVolume 189 - video.muted = nextVolume === 0 190 - setVolume(nextVolume) 191 - }, []) 192 - 193 - const onToggleFullscreen = useCallback(async () => { 194 - const container = playerContainerRef.current 195 - if (!container) { 196 - return 197 - } 198 - 199 - if (!document.fullscreenElement) { 200 - await container.requestFullscreen() 201 - return 202 - } 203 - 204 - await document.exitFullscreen() 173 + video.playbackRate = nextRate 174 + setPlaybackRate(nextRate) 205 175 }, []) 206 176 207 177 useEffect(() => { ··· 252 222 253 223 if (!talk && talksLoading) { 254 224 return ( 255 - <section className="glass-panel rounded-2xl p-5"> 225 + <section className="rounded-lg border border-line/45 bg-surface/80 p-5"> 256 226 <p className="text-sm text-muted">Loading video metadata...</p> 257 227 </section> 258 228 ) ··· 269 239 } 270 240 271 241 return ( 272 - <div className="space-y-5"> 273 - <Button asChild variant="ghost" className="animate-rise"> 242 + <div className="space-y-7 md:space-y-10"> 243 + <Button asChild variant="ghost"> 274 244 <Link to="/"> 275 245 <ArrowLeft className="h-4 w-4" /> 276 - Back to browse 246 + Back to Browse 277 247 </Link> 278 248 </Button> 279 249 280 - <section className="glass-panel animate-rise rounded-2xl p-4 md:p-6" ref={playerContainerRef}> 281 - <div className="relative overflow-hidden rounded-xl border border-line bg-black/30"> 250 + <section className="space-y-5" ref={playerContainerRef}> 251 + <div className="relative overflow-hidden rounded-xl border border-line/45 bg-surface/80"> 282 252 <video 283 253 ref={videoRef} 284 254 className="aspect-video w-full" 285 - controls={false} 255 + controls 286 256 onTimeUpdate={onTimeUpdate} 287 257 onLoadedMetadata={onTimeUpdate} 288 - onPause={() => setIsPlaying(false)} 289 - onPlay={() => setIsPlaying(true)} 258 + playsInline 290 259 /> 291 260 292 261 {status === 'loading' ? ( 293 - <div className="absolute inset-0 flex items-center justify-center bg-black/30 backdrop-blur-sm"> 262 + <div className="absolute inset-0 flex items-center justify-center bg-bg/70"> 294 263 <p className="text-sm text-text">Loading stream...</p> 295 264 </div> 296 265 ) : null} ··· 301 270 <ErrorPanel 302 271 title="Playback failed" 303 272 message={error ?? 'The video playlist could not be loaded.'} 304 - onRetry={() => window.location.reload()} 273 + onRetry={onRetryPlayback} 305 274 /> 306 275 </div> 307 276 ) : null} 308 277 309 278 {status !== 'error' && error ? ( 310 - <p className="mt-3 text-xs text-accent/90" role="status"> 279 + <p className="mt-3 text-xs text-muted" role="status"> 311 280 {error} 312 281 </p> 313 282 ) : null} 314 283 315 - <div className="mt-4 space-y-4"> 316 - <div className="flex items-center gap-2"> 317 - <Button 318 - type="button" 319 - size="icon" 320 - variant="secondary" 321 - onClick={onTogglePlayback} 322 - aria-label={isPlaying ? 'Pause' : 'Play'} 323 - > 324 - {isPlaying ? <Pause /> : <Play />} 325 - </Button> 284 + <div className="flex flex-wrap items-center gap-3 text-sm text-muted"> 285 + <p> 286 + {playbackElapsed} / {playbackTotal} 287 + </p> 326 288 327 - <label className="flex-1"> 328 - <span className="sr-only">Seek</span> 329 - <input 330 - type="range" 331 - min={0} 332 - max={Math.max(duration, 1)} 333 - value={Math.min(currentTime, duration || 0)} 334 - onChange={onSeek} 335 - className="h-11 w-full accent-[oklch(0.78_0.12_205)]" 336 - /> 337 - </label> 338 - 339 - <Button 340 - type="button" 341 - size="icon" 342 - variant="secondary" 343 - onClick={onToggleFullscreen} 344 - aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'} 289 + <label className="flex min-h-11 items-center gap-2"> 290 + <span>Speed</span> 291 + <select 292 + value={playbackRate} 293 + onChange={onSpeedChange} 294 + className="h-11 rounded-lg border border-line/60 bg-surface/80 px-2 text-sm text-text" 345 295 > 346 - {isFullscreen ? <Minimize /> : <Maximize />} 347 - </Button> 348 - </div> 296 + <option value={0.5}>0.5x</option> 297 + <option value={1}>1x</option> 298 + <option value={1.25}>1.25x</option> 299 + <option value={1.5}>1.5x</option> 300 + <option value={2}>2x</option> 301 + </select> 302 + </label> 303 + </div> 349 304 350 - <div className="flex flex-wrap items-center justify-between gap-3 text-xs text-muted"> 351 - <p> 352 - {formatDuration(currentTime * 1_000_000_000)} /{' '} 353 - {formatDuration(duration * 1_000_000_000 || talk?.durationNs || 0)} 354 - </p> 355 - 356 - <div className="flex min-h-11 items-center gap-2"> 357 - {volume === 0 ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />} 358 - <label> 359 - <span className="sr-only">Volume</span> 360 - <input 361 - type="range" 362 - min={0} 363 - max={1} 364 - step={0.05} 365 - value={volume} 366 - onChange={onVolume} 367 - className="h-11 w-32 accent-[oklch(0.78_0.12_205)]" 368 - /> 369 - </label> 370 - </div> 305 + <div className="flex flex-wrap items-center gap-3"> 306 + {playlistUrl ? ( 307 + <a 308 + href={playlistUrl} 309 + download={`${talk?.title ?? 'talk'}.m3u8`} 310 + 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" 311 + > 312 + <ArrowDownToLine className="h-4 w-4" /> 313 + Download HLS Playlist 314 + </a> 315 + ) : null} 371 316 372 - <p className="text-xs text-muted md:hidden">Swipe down to dismiss</p> 373 - </div> 317 + {talk?.sourceRef ? ( 318 + <a 319 + href={getArchiveBlobUrl(talk.sourceRef)} 320 + download={`${talk.title}.mp4`} 321 + 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" 322 + > 323 + <ArrowDownToLine className="h-4 w-4" /> 324 + Download Source MP4 325 + </a> 326 + ) : null} 374 327 </div> 375 328 </section> 376 329 377 330 {talk ? ( 378 - <section className="glass-panel animate-rise rounded-2xl p-6" style={{ animationDelay: '80ms' }}> 331 + <section className="space-y-3 border-t border-line/40 pt-5"> 379 332 <h1 className="text-xl font-semibold leading-tight text-text md:text-2xl">{talk.title}</h1> 380 333 <p className="mt-3 text-sm text-muted">Speaker: {talk.creatorName || truncateDid(talk.creatorDid)}</p> 381 334 <p className="mt-2 text-sm text-muted">
+1
src/state/videos-context.tsx
··· 39 39 return 40 40 } 41 41 setTalks(nextTalks) 42 + setError(null) 42 43 } catch (fetchError) { 43 44 if (!active) { 44 45 return
+5
src/vite-env.d.ts
··· 1 1 /// <reference types="vite/client" /> 2 2 /// <reference types="vite-plugin-pwa/client" /> 3 + 4 + declare module 'hls.js/light' { 5 + import Hls from 'hls.js' 6 + export default Hls 7 + }
+12 -22
tailwind.config.js
··· 7 7 theme: { 8 8 extend: { 9 9 colors: { 10 - bg: 'oklch(0.13 0.03 247 / <alpha-value>)', 11 - surface: 'oklch(0.22 0.03 247 / <alpha-value>)', 12 - line: 'oklch(0.65 0.03 244 / <alpha-value>)', 13 - text: 'oklch(0.93 0.02 250 / <alpha-value>)', 14 - muted: 'oklch(0.8 0.02 248 / <alpha-value>)', 15 - accent: 'oklch(0.78 0.12 205 / <alpha-value>)', 10 + bg: 'oklch(0 0 0 / <alpha-value>)', 11 + surface: 'oklch(0.12 0 0 / <alpha-value>)', 12 + line: 'oklch(0.34 0 0 / <alpha-value>)', 13 + text: 'oklch(0.95 0 0 / <alpha-value>)', 14 + muted: 'oklch(0.72 0 0 / <alpha-value>)', 15 + accent: 'oklch(0.75 0.11 190 / <alpha-value>)', 16 + info: 'oklch(0.72 0 0 / <alpha-value>)', 17 + success: 'oklch(0.72 0 0 / <alpha-value>)', 18 + warning: 'oklch(0.72 0 0 / <alpha-value>)', 19 + danger: 'oklch(0.72 0 0 / <alpha-value>)', 16 20 }, 17 21 borderRadius: { 18 22 xl: '1rem', 19 23 '2xl': '1.25rem', 20 24 }, 21 25 boxShadow: { 22 - glass: '0 24px 70px oklch(0.03 0.02 250 / 0.5)', 23 - lift: '0 30px 60px oklch(0.03 0.02 250 / 0.45)', 26 + glass: '0 12px 28px oklch(0 0 0 / 0.45)', 27 + lift: '0 12px 28px oklch(0 0 0 / 0.45)', 24 28 }, 25 29 keyframes: { 26 - aurora: { 27 - '0%': { transform: 'translate3d(-8%, -6%, 0) scale(1)' }, 28 - '50%': { transform: 'translate3d(10%, 8%, 0) scale(1.08)' }, 29 - '100%': { transform: 'translate3d(-8%, -6%, 0) scale(1)' }, 30 - }, 31 - noise: { 32 - '0%': { transform: 'translate(0, 0)' }, 33 - '25%': { transform: 'translate(-4%, 3%)' }, 34 - '50%': { transform: 'translate(2%, -3%)' }, 35 - '75%': { transform: 'translate(3%, 2%)' }, 36 - '100%': { transform: 'translate(0, 0)' }, 37 - }, 38 30 rise: { 39 31 '0%': { opacity: '0', transform: 'translate3d(0, 10px, 0)' }, 40 32 '100%': { opacity: '1', transform: 'translate3d(0, 0, 0)' }, 41 33 }, 42 34 }, 43 35 animation: { 44 - aurora: 'aurora 34s ease-in-out infinite', 45 - noise: 'noise 800ms steps(6) infinite', 46 36 rise: 'rise 440ms cubic-bezier(0.22, 1, 0.36, 1) both', 47 37 }, 48 38 },
+9 -9
vite.config.ts
··· 15 15 VitePWA({ 16 16 registerType: 'autoUpdate', 17 17 includeAssets: ['favicon.svg'], 18 - manifest: { 19 - name: 'Atmosphere VODs', 20 - short_name: 'Atmosphere VODs', 21 - description: 22 - 'A minimalist glassy video browser for ATmosphereConf 2026 talks.', 23 - theme_color: '#0a1020', 24 - background_color: '#060a14', 25 - display: 'standalone', 26 - start_url: '/', 18 + manifest: { 19 + name: 'Atmosphere VODs', 20 + short_name: 'Atmosphere VODs', 21 + description: 22 + 'A minimalist glassy video browser for ATmosphereConf 2026 talks.', 23 + theme_color: '#000000', 24 + background_color: '#000000', 25 + display: 'standalone', 26 + start_url: '/', 27 27 icons: [ 28 28 { 29 29 src: '/favicon.svg',