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.

fix: harden repo/profile fetch and streamline shortcuts UI

jack 08de86ad 6de82296

+121 -174
+14 -57
src/components/layout/app-shell.tsx
··· 1 1 import { Film, Info, Search, Sparkles } from 'lucide-react' 2 - import { NavLink, type NavLinkProps, useLocation } from 'react-router-dom' 2 + import { NavLink, type NavLinkProps } from 'react-router-dom' 3 3 import { type PropsWithChildren, useEffect, useRef, useState } from 'react' 4 4 5 - import { ShortcutsHelp, type ShortcutItem } from '@/components/shortcuts-help' 6 5 import { cn } from '@/lib/utils' 7 6 8 7 const navItems = [ ··· 49 48 ) 50 49 } 51 50 52 - function getShortcuts(pathname: string): { title: string; items: ShortcutItem[] } { 53 - if (pathname.startsWith('/video/')) { 54 - return { 55 - title: 'Player shortcuts', 56 - items: [ 57 - { key: 'Space / K', description: 'Play or pause' }, 58 - { key: 'J / L', description: 'Seek back/forward 10s' }, 59 - { key: 'F', description: 'Toggle fullscreen' }, 60 - { key: 'M', description: 'Toggle mute' }, 61 - { key: '0-9', description: 'Seek to 0-90%' }, 62 - { key: '< / >', description: 'Change speed by 0.25x' }, 63 - { key: 'Esc', description: 'Back to browse' }, 64 - ], 65 - } 66 - } 67 - 68 - if (pathname === '/' || pathname === '/search') { 69 - return { 70 - title: 'Browse/search shortcuts', 71 - items: [ 72 - { key: 'J', description: 'Next video card' }, 73 - { key: 'K', description: 'Previous video card' }, 74 - { key: '/', description: 'Focus search box' }, 75 - { key: 'Enter', description: 'Open selected card' }, 76 - ], 77 - } 78 - } 79 - 80 - return { 81 - title: 'Page shortcuts', 82 - items: [{ key: 'None', description: 'No custom shortcuts on this page' }], 83 - } 84 - } 85 - 86 51 export function AppShell({ children }: PropsWithChildren) { 87 - const location = useLocation() 88 52 const [isHeaderHidden, setIsHeaderHidden] = useState(false) 89 53 const lastScrollYRef = useRef(0) 90 - const shortcuts = getShortcuts(location.pathname) 91 54 92 55 useEffect(() => { 93 56 const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches ··· 142 105 Skip to content 143 106 </a> 144 107 145 - <div className="flex items-center gap-2"> 146 - <nav className="hidden items-center gap-1.5 md:flex lg:gap-2" aria-label="Primary"> 147 - {navItems.map((item) => ( 148 - <NavItem key={item.to} label={item.label} to={item.to} end={item.end} icon={item.icon} /> 149 - ))} 150 - </nav> 151 - <ShortcutsHelp title={shortcuts.title} items={shortcuts.items} /> 152 - </div> 108 + <nav className="hidden items-center gap-1.5 md:flex lg:gap-2" aria-label="Primary"> 109 + {navItems.map((item) => ( 110 + <NavItem key={item.to} label={item.label} to={item.to} end={item.end} icon={item.icon} /> 111 + ))} 112 + </nav> 153 113 </div> 154 114 </header> 155 115 ··· 180 140 > 181 141 GitHub 182 142 </a> 183 - <span className="flex flex-col leading-tight"> 184 - <a 185 - href="https://vod.j4ck.xyz" 186 - target="_blank" 187 - rel="noreferrer" 188 - className="underline-offset-4 hover:text-text hover:underline" 189 - > 190 - iStream → 191 - </a> 192 - <span className="text-[11px] text-muted/90">Classic iCarly episodes</span> 193 - </span> 143 + <a 144 + href="https://vod.j4ck.xyz" 145 + target="_blank" 146 + rel="noreferrer" 147 + className="underline-offset-4 hover:text-text hover:underline" 148 + > 149 + iStream → 150 + </a> 194 151 </p> 195 152 <p> 196 153 Built for Streamplace VOD beta ·{' '}
-55
src/components/shortcuts-help.tsx
··· 1 - import { HelpCircle, X } from 'lucide-react' 2 - import { useState } from 'react' 3 - 4 - export interface ShortcutItem { 5 - key: string 6 - description: string 7 - } 8 - 9 - interface ShortcutsHelpProps { 10 - title: string 11 - items: ShortcutItem[] 12 - } 13 - 14 - export function ShortcutsHelp({ title, items }: ShortcutsHelpProps) { 15 - const [open, setOpen] = useState(false) 16 - 17 - return ( 18 - <> 19 - <button 20 - type="button" 21 - onClick={() => setOpen(true)} 22 - className="inline-flex min-h-11 items-center gap-2 rounded-md border border-line/45 bg-surface/70 px-3 text-xs text-muted transition hover:border-line/60 hover:text-text" 23 - aria-label="Show keyboard shortcuts" 24 - > 25 - <HelpCircle className="h-4 w-4" /> 26 - ? 27 - </button> 28 - 29 - {open ? ( 30 - <div className="fixed inset-0 z-40 flex items-center justify-center bg-bg/75 p-4" role="dialog" aria-modal="true"> 31 - <section className="w-full max-w-md rounded-xl border border-line/50 bg-surface/95 p-4 shadow-2xl supports-[backdrop-filter]:backdrop-blur-md"> 32 - <div className="flex items-center justify-between"> 33 - <h2 className="text-sm font-semibold text-text">{title}</h2> 34 - <button 35 - type="button" 36 - onClick={() => setOpen(false)} 37 - className="inline-flex min-h-11 items-center rounded-md border border-line/45 px-3 text-xs text-muted transition hover:text-text" 38 - > 39 - <X className="h-4 w-4" /> 40 - </button> 41 - </div> 42 - <ul className="mt-3 space-y-2"> 43 - {items.map((item) => ( 44 - <li key={`${item.key}-${item.description}`} className="flex items-start justify-between gap-4 text-xs"> 45 - <kbd className="rounded border border-line/50 bg-bg/70 px-2 py-1 text-text">{item.key}</kbd> 46 - <span className="text-muted">{item.description}</span> 47 - </li> 48 - ))} 49 - </ul> 50 - </section> 51 - </div> 52 - ) : null} 53 - </> 54 - ) 55 - }
+70 -32
src/lib/api.ts
··· 1 1 import { 2 2 ATMOSPHERE_REPO_DID, 3 3 BSKY_PUBLIC_API, 4 - BSKY_RELAY_SYNC_API, 4 + BSKY_RELAY_SYNC_APIS, 5 + FALLBACK_REPO_DIDS, 5 6 PLC_DIRECTORY_URL, 6 7 STREAMPLACE_VIDEO_COLLECTION, 7 8 VOD_PLAYLIST_ENDPOINT, ··· 98 99 } 99 100 100 101 async function fetchReposByCollection(collection: string): Promise<string[]> { 101 - const dids = new Set<string>() 102 - let cursor: string | undefined 102 + const errors: Error[] = [] 103 103 104 - do { 105 - const query = new URLSearchParams({ 106 - collection, 107 - limit: String(DISCOVERY_LIMIT), 108 - }) 104 + for (const relayBase of BSKY_RELAY_SYNC_APIS) { 105 + const dids = new Set<string>() 106 + let cursor: string | undefined 109 107 110 - if (cursor) { 111 - query.set('cursor', cursor) 112 - } 108 + try { 109 + do { 110 + const query = new URLSearchParams({ 111 + collection, 112 + limit: String(DISCOVERY_LIMIT), 113 + }) 113 114 114 - const data = await fetchJson<ListReposByCollectionResponse>( 115 - `${BSKY_RELAY_SYNC_API}/xrpc/com.atproto.sync.listReposByCollection?${query.toString()}`, 116 - ) 115 + if (cursor) { 116 + query.set('cursor', cursor) 117 + } 117 118 118 - for (const entry of data.repos ?? []) { 119 - if (entry.did) { 120 - dids.add(entry.did) 119 + const data = await fetchJson<ListReposByCollectionResponse>( 120 + `${relayBase}/xrpc/com.atproto.sync.listReposByCollection?${query.toString()}`, 121 + ) 122 + 123 + for (const entry of data.repos ?? []) { 124 + if (entry.did) { 125 + dids.add(entry.did) 126 + } 127 + } 128 + 129 + cursor = data.cursor 130 + } while (cursor) 131 + 132 + if (dids.size > 0) { 133 + return [...dids].sort((a, b) => a.localeCompare(b)) 134 + } 135 + } catch (error) { 136 + if (error instanceof Error) { 137 + errors.push(error) 121 138 } 122 139 } 140 + } 123 141 124 - cursor = data.cursor 125 - } while (cursor) 142 + if (errors.length > 0) { 143 + return [...new Set(FALLBACK_REPO_DIDS)].sort((a, b) => a.localeCompare(b)) 144 + } 126 145 127 - return [...dids].sort((a, b) => a.localeCompare(b)) 146 + return [...new Set(FALLBACK_REPO_DIDS)].sort((a, b) => a.localeCompare(b)) 128 147 } 129 148 130 149 export function resolvePdsUrl(did: string): Promise<string> { ··· 184 203 } 185 204 186 205 async function fetchProfile(did: string): Promise<ActorProfile | null> { 206 + if (!did) { 207 + return null 208 + } 209 + 187 210 const query = new URLSearchParams({ actor: did }) 188 211 189 212 try { ··· 195 218 } 196 219 } 197 220 221 + function getCreatorDid(record: { value: { creator?: string }; sourceRepoDid: string }): string { 222 + return record.value.creator?.trim() || record.sourceRepoDid 223 + } 224 + 225 + function byCreatedAtDesc( 226 + left: { value: { createdAt?: string } }, 227 + right: { value: { createdAt?: string } }, 228 + ): number { 229 + const leftTs = Date.parse(left.value.createdAt ?? '') 230 + const rightTs = Date.parse(right.value.createdAt ?? '') 231 + const safeLeft = Number.isFinite(leftTs) ? leftTs : 0 232 + const safeRight = Number.isFinite(rightTs) ? rightTs : 0 233 + return safeRight - safeLeft 234 + } 235 + 198 236 function getCachedProfile(did: string): Promise<ActorProfile | null> { 199 237 const cached = profileCache.get(did) 200 238 if (cached) { ··· 235 273 } 236 274 237 275 const merged = repoRecords.flat() 238 - const sorted = [...merged].sort( 239 - (a, b) => new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime(), 240 - ) 276 + const sorted = [...merged].sort(byCreatedAtDesc) 241 277 242 - const uniqueCreators = Array.from(new Set(sorted.map((record) => record.value.creator))) 278 + const uniqueCreators = Array.from(new Set(sorted.map((record) => getCreatorDid(record)))) 243 279 const profiles = await mapWithConcurrency( 244 280 uniqueCreators, 245 281 PROFILE_CONCURRENCY, ··· 249 285 250 286 return sorted.map((record) => { 251 287 const taxonomy = taxonomyByUri.get(record.uri) 252 - const profile = profileMap.get(record.value.creator) 253 - const creatorName = profile?.displayName?.trim() || profile?.handle || truncateDid(record.value.creator) 288 + const creatorDid = getCreatorDid(record) 289 + const profile = profileMap.get(creatorDid) 290 + const creatorName = profile?.displayName?.trim() || profile?.handle || truncateDid(creatorDid) 254 291 255 292 return { 256 293 uri: record.uri, ··· 258 295 sourceRepoDid: record.sourceRepoDid, 259 296 title: record.value.title, 260 297 description: record.value.description, 261 - creatorDid: record.value.creator, 298 + creatorDid, 262 299 creatorName, 263 300 creatorHandle: profile?.handle, 264 301 durationNs: record.value.duration, 265 - createdAt: record.value.createdAt, 302 + createdAt: record.value.createdAt ?? new Date(0).toISOString(), 266 303 sourceRef: record.value.source?.ref, 267 304 sourceMimeType: record.value.source?.mimeType, 268 305 taxonomyGroup: taxonomy?.group, ··· 293 330 } 294 331 295 332 const taxonomy = taxonomyByUri.get(record.uri) 296 - const profile = await getCachedProfile(record.value.creator) 297 - const creatorName = profile?.displayName?.trim() || profile?.handle || truncateDid(record.value.creator) 333 + const creatorDid = record.value.creator?.trim() || uriInfo.did 334 + const profile = await getCachedProfile(creatorDid) 335 + const creatorName = profile?.displayName?.trim() || profile?.handle || truncateDid(creatorDid) 298 336 299 337 return { 300 338 uri: record.uri, ··· 302 340 sourceRepoDid: uriInfo.did, 303 341 title: record.value.title, 304 342 description: record.value.description, 305 - creatorDid: record.value.creator, 343 + creatorDid, 306 344 creatorName, 307 345 creatorHandle: profile?.handle, 308 346 durationNs: record.value.duration, 309 - createdAt: record.value.createdAt, 347 + createdAt: record.value.createdAt ?? new Date(0).toISOString(), 310 348 sourceRef: record.value.source?.ref, 311 349 sourceMimeType: record.value.source?.mimeType, 312 350 taxonomyGroup: taxonomy?.group,
+5 -1
src/lib/constants.ts
··· 1 1 export const ATMOSPHERE_REPO_DID = 'did:plc:rbvrr34edl5ddpuwcubjiost' 2 2 export const STREAMPLACE_VIDEO_COLLECTION = 'place.stream.video' 3 + export const FALLBACK_REPO_DIDS = [ATMOSPHERE_REPO_DID] 3 4 4 5 export const PLC_DIRECTORY_URL = 'https://plc.directory' 5 6 export const BSKY_PUBLIC_API = 'https://public.api.bsky.app' 6 - export const BSKY_RELAY_SYNC_API = 'https://bsky.network' 7 + export const BSKY_RELAY_SYNC_APIS = [ 8 + 'https://bsky.network', 9 + 'https://relay1.us-west.bsky.network', 10 + ] 7 11 export const VOD_PLAYLIST_ENDPOINT = 8 12 'https://vod-beta.stream.place/xrpc/place.stream.playback.getVideoPlaylist'
+31 -16
src/pages/about-page.tsx
··· 1 1 export function AboutPage() { 2 2 return ( 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 Streamplace VOD Client</h1> 5 - <p className="mt-2 text-sm font-medium text-muted">Open source AT Protocol video browser</p> 6 - <p className="mt-4 max-w-[70ch] text-sm leading-relaxed text-muted"> 7 - Streamplace VOD Client discovers repos that publish <code>place.stream.video</code> records via 8 - Bluesky relay sync APIs, then loads records directly from each repo PDS. Playback uses the 9 - Streamplace VOD beta endpoint. 10 - </p> 11 - <p className="mt-4 max-w-[70ch] text-sm leading-relaxed text-muted"> 12 - Search supports all discovered VODs. AtmosphereConf 2026 records include richer OpenRouter 13 - tag/topic metadata, so Atmosphere queries are usually more precise than general VOD queries. 14 - </p> 15 - <p className="mt-4 max-w-[70ch] text-sm leading-relaxed text-muted"> 16 - Live deployment: <a href="https://vods.j4ck.xyz" className="underline-offset-4 hover:text-text hover:underline">vods.j4ck.xyz</a> 17 - </p> 18 - </section> 3 + <div className="space-y-4"> 4 + <section className="rounded-lg border border-line/45 bg-surface/80 p-5 md:p-6"> 5 + <h1 className="text-2xl font-semibold text-text">About Streamplace VOD Client</h1> 6 + <p className="mt-2 text-sm font-medium text-muted">Open source AT Protocol video browser</p> 7 + <p className="mt-4 max-w-[70ch] text-sm leading-relaxed text-muted"> 8 + Streamplace VOD Client discovers repos that publish <code>place.stream.video</code> records via 9 + Bluesky relay sync APIs, then loads records directly from each repo PDS. Playback uses the 10 + Streamplace VOD beta endpoint. 11 + </p> 12 + <p className="mt-4 max-w-[70ch] text-sm leading-relaxed text-muted"> 13 + Search supports all discovered VODs. AtmosphereConf 2026 records include richer OpenRouter 14 + tag/topic metadata, so Atmosphere queries are usually more precise than general VOD queries. 15 + </p> 16 + <p className="mt-4 max-w-[70ch] text-sm leading-relaxed text-muted"> 17 + Live deployment:{' '} 18 + <a href="https://vods.j4ck.xyz" className="underline-offset-4 hover:text-text hover:underline"> 19 + vods.j4ck.xyz 20 + </a> 21 + </p> 22 + </section> 23 + 24 + <section className="rounded-lg border border-line/45 bg-surface/80 p-5 md:p-6"> 25 + <h2 className="text-base font-semibold text-text">Keyboard shortcuts</h2> 26 + <ul className="mt-3 space-y-2 text-sm text-muted"> 27 + <li>Browse/Search: <code>J</code> next, <code>K</code> previous, <code>/</code> focus search, <code>Enter</code> open selected card.</li> 28 + <li>Video: <code>Space</code> or <code>K</code> play/pause, <code>J</code>/<code>L</code> seek ±10s.</li> 29 + <li>Video: <code>F</code> fullscreen, <code>M</code> mute, <code>0-9</code> seek 0%-90%.</li> 30 + <li>Video: <code>&lt;</code>/<code>&gt;</code> adjust speed by 0.25x, <code>Esc</code> back to browse.</li> 31 + </ul> 32 + </section> 33 + </div> 19 34 ) 20 35 }
+1 -13
src/pages/search-page.tsx
··· 3 3 import { Link, useSearchParams } from 'react-router-dom' 4 4 5 5 import { ErrorPanel } from '@/components/error-panel' 6 - import { ShortcutsHelp } from '@/components/shortcuts-help' 7 6 import { TalkCard } from '@/components/talk-card' 8 7 import { TalkGridSkeleton } from '@/components/talk-grid-skeleton' 9 8 import { searchTalkUris } from '@/lib/semantic-search' ··· 213 212 return ( 214 213 <div className="space-y-7 md:space-y-10" aria-busy={loading}> 215 214 <header className="space-y-4"> 216 - <div className="flex items-center justify-between gap-3"> 217 - <h1 className="text-2xl font-semibold text-text">Search Videos</h1> 218 - <ShortcutsHelp 219 - title="Search shortcuts" 220 - items={[ 221 - { key: 'J', description: 'Next video card' }, 222 - { key: 'K', description: 'Previous video card' }, 223 - { key: '/', description: 'Focus search input' }, 224 - { key: 'Enter', description: 'Open selected card' }, 225 - ]} 226 - /> 227 - </div> 215 + <h1 className="text-2xl font-semibold text-text">Search Videos</h1> 228 216 229 217 <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"> 230 218 <Search className="h-4 w-4 text-muted" />