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: deep-linkable URLs, keyboard shortcuts, SPA routing, footer links (AI-assisted)

jack 6de82296 afead88e

+1833 -115
+52 -7
README.md
··· 1 - # Atmosphere VODs 1 + # Streamplace VOD Client 2 2 3 - Atmosphere VODs is a minimalist glassy PWA for browsing ATmosphereConf 2026 talks from the 4 - Streamplace AT Protocol VOD beta API. 3 + Streamplace VOD Client is a minimalist glassy PWA for browsing `place.stream.video` records 4 + across discovered AT Protocol repos, with a dedicated AtmosphereConf 2026 page. 5 + 6 + Production URL: https://vods.j4ck.xyz 5 7 6 8 ## What it includes 7 9 8 10 - React + Vite + TypeScript app with Tailwind + shadcn-style UI primitives 9 - - PDS-aware data fetching: resolves the repo DID in PLC directory, then fetches records from that PDS 11 + - Relay + PDS-aware fetch flow: 12 + - discovers repos via `com.atproto.sync.listReposByCollection` on `bsky.network` 13 + - resolves each DID to its PDS via `plc.directory` 14 + - fetches `place.stream.video` records from each repo using `com.atproto.repo.listRecords` 10 15 - HLS playback via `hls.js` with native controls and mobile swipe-down dismiss 11 - - Search by title + AI-generated tags/topics with `/tag/{tag}` routes 16 + - Search across all discovered videos, with semantic ranking via Cloudflare Pages Function (`/api/search`) 17 + - AtmosphereConf-specific tag/topic enrichment from OpenRouter taxonomy for stronger conference query matching 18 + - `/atmosphereconf-2026` route with official conference videos only 12 19 - Mobile-first navigation: bottom tabs on mobile, sidebar on desktop 13 20 - PWA setup with `vite-plugin-pwa` and Workbox runtime caching 14 21 ··· 27 34 28 35 ## Generate AI taxonomy (one-time or refresh) 29 36 30 - The app can enrich talks with tags/topics generated through OpenRouter. 37 + The app can enrich AtmosphereConf talks with tags/topics generated through OpenRouter. 31 38 32 39 1. Put your key in `.env` as `OPENROUTER_API_KEY=...`. 33 40 2. Run: ··· 38 45 39 46 This updates `src/lib/video-taxonomy.json`, which is bundled into the app and used by search + tag routes. 40 47 48 + ## Generate semantic embeddings index (for `/api/search`) 49 + 50 + To support cheap server-assisted semantic search on Cloudflare Pages, generate a static embedding index: 51 + 52 + ```bash 53 + npm run embeddings:generate 54 + ``` 55 + 56 + This updates `public/video-embeddings.json`. 57 + 58 + ### Required env vars for embeddings generation 59 + 60 + - `OPENROUTER_API_KEY` 61 + - optional: `OPENROUTER_EMBEDDING_MODEL` (defaults to `openai/text-embedding-3-small`) 62 + 63 + ## Cloudflare Pages function search backend 64 + 65 + `functions/api/search.ts` provides semantic ranking using: 66 + 67 + - static embedding index from `public/video-embeddings.json` 68 + - OpenRouter query embeddings at request time 69 + - live repo discovery overlay so newly uploaded videos are included immediately 70 + - lexical + recency fallback for any videos not yet embedded 71 + 72 + Set this env var in Cloudflare Pages project settings for semantic mode: 73 + 74 + - `OPENROUTER_API_KEY` 75 + - optional: `OPENROUTER_EMBEDDING_MODEL` 76 + 77 + If the key is not set, search falls back gracefully to lexical title ranking. 78 + 79 + ### Freshness behavior 80 + 81 + - Newly uploaded VODs appear immediately in results because the search function overlays a live catalog 82 + from relay + PDS APIs. 83 + - Those fresh videos are lexical-ranked until you refresh `public/video-embeddings.json`. 84 + - The UI shows index snapshot time and how many videos are currently embedded. 85 + 41 86 ## Deploy to Vercel 42 87 43 88 1. Push this repo to GitHub. 44 89 2. In Vercel, import the repository. 45 90 3. Deploy with defaults, or run `vercel deploy` from your terminal. 46 91 47 - No environment variables are required because all APIs are public. 92 + For semantic `/api/search`, set `OPENROUTER_API_KEY` in Cloudflare Pages. 48 93 49 94 ## License 50 95
+489
functions/api/search.ts
··· 1 + interface EmbeddingEntry { 2 + uri: string 3 + sourceRepoDid?: string 4 + createdAt?: string 5 + title?: string 6 + embedding: number[] 7 + } 8 + 9 + interface EmbeddingIndex { 10 + version: number 11 + generatedAt: string 12 + model: string 13 + entries: EmbeddingEntry[] 14 + } 15 + 16 + interface MinimalTalkRecord { 17 + uri: string 18 + sourceRepoDid: string 19 + title: string 20 + createdAt?: string 21 + } 22 + 23 + interface PagesFunctionContextLike { 24 + request: Request 25 + env: { 26 + OPENROUTER_API_KEY?: string 27 + OPENROUTER_EMBEDDING_MODEL?: string 28 + ASSETS?: { 29 + fetch: (request: Request) => Promise<Response> 30 + } 31 + } 32 + } 33 + 34 + type RankedEntry = { 35 + uri: string 36 + score: number 37 + } 38 + 39 + let cachedIndex: { loadedAt: number; index: EmbeddingIndex; norms: number[] } | null = null 40 + let cachedLiveCatalog: { 41 + loadedAt: number 42 + talks: MinimalTalkRecord[] 43 + titleByUri: Map<string, string> 44 + recencyByUri: Map<string, string> 45 + } | null = null 46 + const INDEX_TTL_MS = 5 * 60 * 1000 47 + const LIVE_CATALOG_TTL_MS = 2 * 60 * 1000 48 + 49 + function normalizeVector(vector: number[]): number { 50 + let sum = 0 51 + for (const value of vector) { 52 + sum += value * value 53 + } 54 + return Math.sqrt(sum) 55 + } 56 + 57 + function cosineSimilarity(a: number[], b: number[], normA: number, normB: number): number { 58 + if (normA === 0 || normB === 0 || a.length !== b.length) { 59 + return 0 60 + } 61 + 62 + let dot = 0 63 + for (let i = 0; i < a.length; i += 1) { 64 + dot += a[i] * b[i] 65 + } 66 + 67 + return dot / (normA * normB) 68 + } 69 + 70 + function lexicalScore(query: string, title: string): number { 71 + const normalizedQuery = query.trim().toLowerCase() 72 + if (!normalizedQuery) { 73 + return 0 74 + } 75 + 76 + const normalizedTitle = title.toLowerCase() 77 + if (normalizedTitle.includes(normalizedQuery)) { 78 + return 1 79 + } 80 + 81 + const terms = normalizedQuery.split(/\s+/).filter(Boolean) 82 + if (terms.length === 0) { 83 + return 0 84 + } 85 + 86 + let matched = 0 87 + for (const term of terms) { 88 + if (normalizedTitle.includes(term)) { 89 + matched += 1 90 + } 91 + } 92 + 93 + return matched / terms.length 94 + } 95 + 96 + function parseLimit(value: string | null): number { 97 + if (!value) { 98 + return 100 99 + } 100 + 101 + const parsed = Number.parseInt(value, 10) 102 + if (!Number.isFinite(parsed) || parsed <= 0) { 103 + return 100 104 + } 105 + 106 + return Math.min(parsed, 500) 107 + } 108 + 109 + async function fetchJsonWithTimeout<T>(url: string, timeoutMs: number = 8_000): Promise<T> { 110 + const controller = new AbortController() 111 + const timeout = setTimeout(() => controller.abort(), timeoutMs) 112 + 113 + try { 114 + const response = await fetch(url, { signal: controller.signal }) 115 + if (!response.ok) { 116 + throw new Error(`Request failed (${response.status})`) 117 + } 118 + return (await response.json()) as T 119 + } finally { 120 + clearTimeout(timeout) 121 + } 122 + } 123 + 124 + async function resolvePdsUrl(did: string): Promise<string> { 125 + const didDoc = await fetchJsonWithTimeout<{ 126 + service?: Array<{ id?: string; serviceEndpoint?: string }> 127 + }>(`https://plc.directory/${did}`) 128 + const pdsService = didDoc.service?.find((entry) => entry.id === '#atproto_pds') 129 + if (!pdsService?.serviceEndpoint) { 130 + throw new Error(`PDS endpoint missing for ${did}`) 131 + } 132 + return pdsService.serviceEndpoint.replace(/\/$/, '') 133 + } 134 + 135 + async function fetchReposByCollection(collection: string): Promise<string[]> { 136 + const dids = new Set<string>() 137 + let cursor: string | undefined 138 + 139 + do { 140 + const query = new URLSearchParams({ 141 + collection, 142 + limit: '1000', 143 + }) 144 + if (cursor) { 145 + query.set('cursor', cursor) 146 + } 147 + 148 + const response = await fetchJsonWithTimeout<{ 149 + repos?: Array<{ did?: string }> 150 + cursor?: string 151 + }>(`https://bsky.network/xrpc/com.atproto.sync.listReposByCollection?${query.toString()}`) 152 + 153 + for (const entry of response.repos ?? []) { 154 + if (entry.did) { 155 + dids.add(entry.did) 156 + } 157 + } 158 + 159 + cursor = response.cursor 160 + } while (cursor) 161 + 162 + return [...dids] 163 + } 164 + 165 + async function fetchRecordsForRepo(did: string): Promise<MinimalTalkRecord[]> { 166 + const pdsUrl = await resolvePdsUrl(did) 167 + const talks: MinimalTalkRecord[] = [] 168 + let cursor: string | undefined 169 + 170 + do { 171 + const query = new URLSearchParams({ 172 + repo: did, 173 + collection: 'place.stream.video', 174 + limit: '100', 175 + }) 176 + if (cursor) { 177 + query.set('cursor', cursor) 178 + } 179 + 180 + const response = await fetchJsonWithTimeout<{ 181 + records?: Array<{ 182 + uri?: string 183 + value?: { 184 + title?: string 185 + createdAt?: string 186 + } 187 + }> 188 + cursor?: string 189 + }>(`${pdsUrl}/xrpc/com.atproto.repo.listRecords?${query.toString()}`) 190 + 191 + for (const record of response.records ?? []) { 192 + if (!record.uri) { 193 + continue 194 + } 195 + talks.push({ 196 + uri: record.uri, 197 + sourceRepoDid: did, 198 + title: record.value?.title ?? 'Untitled', 199 + createdAt: record.value?.createdAt, 200 + }) 201 + } 202 + 203 + cursor = response.cursor 204 + } while (cursor) 205 + 206 + return talks 207 + } 208 + 209 + async function loadLiveCatalog(): Promise<{ 210 + talks: MinimalTalkRecord[] 211 + titleByUri: Map<string, string> 212 + recencyByUri: Map<string, string> 213 + }> { 214 + if (cachedLiveCatalog && Date.now() - cachedLiveCatalog.loadedAt < LIVE_CATALOG_TTL_MS) { 215 + return { 216 + talks: cachedLiveCatalog.talks, 217 + titleByUri: cachedLiveCatalog.titleByUri, 218 + recencyByUri: cachedLiveCatalog.recencyByUri, 219 + } 220 + } 221 + 222 + const repoDids = await fetchReposByCollection('place.stream.video') 223 + const batches = await Promise.all( 224 + repoDids.map(async (did) => { 225 + try { 226 + return await fetchRecordsForRepo(did) 227 + } catch { 228 + return [] 229 + } 230 + }), 231 + ) 232 + 233 + const talks = batches.flat() 234 + const titleByUri = new Map(talks.map((talk) => [talk.uri, talk.title])) 235 + const recencyByUri = new Map( 236 + talks 237 + .filter((talk) => Boolean(talk.createdAt)) 238 + .map((talk) => [talk.uri, talk.createdAt as string]), 239 + ) 240 + 241 + cachedLiveCatalog = { 242 + loadedAt: Date.now(), 243 + talks, 244 + titleByUri, 245 + recencyByUri, 246 + } 247 + 248 + return { talks, titleByUri, recencyByUri } 249 + } 250 + 251 + async function loadEmbeddingIndex(context: PagesFunctionContextLike): Promise<{ index: EmbeddingIndex; norms: number[] }> { 252 + if (cachedIndex && Date.now() - cachedIndex.loadedAt < INDEX_TTL_MS) { 253 + return { index: cachedIndex.index, norms: cachedIndex.norms } 254 + } 255 + 256 + const assets = context.env.ASSETS 257 + if (!assets) { 258 + throw new Error('Cloudflare ASSETS binding unavailable') 259 + } 260 + 261 + const origin = new URL(context.request.url).origin 262 + const assetsRequest = new Request(`${origin}/video-embeddings.json`, { 263 + method: 'GET', 264 + headers: { 265 + Accept: 'application/json', 266 + }, 267 + }) 268 + const response = await assets.fetch(assetsRequest) 269 + 270 + if (!response.ok) { 271 + throw new Error(`Embeddings asset unavailable (${response.status})`) 272 + } 273 + 274 + const index = (await response.json()) as EmbeddingIndex 275 + const norms = (index.entries ?? []).map((entry) => normalizeVector(entry.embedding ?? [])) 276 + 277 + cachedIndex = { 278 + loadedAt: Date.now(), 279 + index, 280 + norms, 281 + } 282 + 283 + return { index, norms } 284 + } 285 + 286 + async function embedQuery(context: PagesFunctionContextLike, query: string): Promise<number[] | null> { 287 + const apiKey = context.env.OPENROUTER_API_KEY 288 + if (!apiKey) { 289 + return null 290 + } 291 + 292 + const model = context.env.OPENROUTER_EMBEDDING_MODEL || 'openai/text-embedding-3-small' 293 + const response = await fetch('https://openrouter.ai/api/v1/embeddings', { 294 + method: 'POST', 295 + headers: { 296 + Authorization: `Bearer ${apiKey}`, 297 + 'Content-Type': 'application/json', 298 + 'HTTP-Referer': new URL(context.request.url).origin, 299 + 'X-OpenRouter-Title': 'Streamplace VOD semantic search', 300 + }, 301 + body: JSON.stringify({ 302 + model, 303 + input: query, 304 + input_type: 'search_query', 305 + }), 306 + }) 307 + 308 + if (!response.ok) { 309 + throw new Error(`Embedding request failed (${response.status})`) 310 + } 311 + 312 + const payload = (await response.json()) as { data?: Array<{ embedding?: number[] }> } 313 + const vector = payload.data?.[0]?.embedding 314 + return Array.isArray(vector) ? vector : null 315 + } 316 + 317 + function recencyBounds(entries: EmbeddingEntry[]): { min: number; max: number } { 318 + let min = Number.POSITIVE_INFINITY 319 + let max = Number.NEGATIVE_INFINITY 320 + 321 + for (const entry of entries) { 322 + const timestamp = Date.parse(entry.createdAt ?? '') 323 + if (!Number.isFinite(timestamp)) { 324 + continue 325 + } 326 + if (timestamp < min) { 327 + min = timestamp 328 + } 329 + if (timestamp > max) { 330 + max = timestamp 331 + } 332 + } 333 + 334 + if (!Number.isFinite(min) || !Number.isFinite(max)) { 335 + return { min: 0, max: 0 } 336 + } 337 + 338 + return { min, max } 339 + } 340 + 341 + function recencyScore(createdAt: string | undefined, bounds: { min: number; max: number }): number { 342 + if (bounds.max <= bounds.min) { 343 + return 0 344 + } 345 + 346 + const timestamp = Date.parse(createdAt ?? '') 347 + if (!Number.isFinite(timestamp)) { 348 + return 0 349 + } 350 + 351 + return (timestamp - bounds.min) / (bounds.max - bounds.min) 352 + } 353 + 354 + function mapNormsByUri(entries: EmbeddingEntry[], norms: number[]): Map<string, number> { 355 + const normByUri = new Map<string, number>() 356 + for (let i = 0; i < entries.length; i += 1) { 357 + normByUri.set(entries[i].uri, norms[i] ?? 0) 358 + } 359 + return normByUri 360 + } 361 + 362 + function jsonResponse(body: unknown, init?: ResponseInit): Response { 363 + return new Response(JSON.stringify(body), { 364 + ...init, 365 + headers: { 366 + 'Content-Type': 'application/json; charset=utf-8', 367 + 'Cache-Control': 'public, max-age=60, s-maxage=300', 368 + ...(init?.headers ?? {}), 369 + }, 370 + }) 371 + } 372 + 373 + export const onRequestGet = async (context: PagesFunctionContextLike): Promise<Response> => { 374 + const url = new URL(context.request.url) 375 + const query = (url.searchParams.get('q') ?? '').trim() 376 + const limit = parseLimit(url.searchParams.get('limit')) 377 + 378 + if (!query) { 379 + return jsonResponse({ uris: [], mode: 'lexical', notice: 'Query is empty.' }) 380 + } 381 + 382 + try { 383 + const [{ index, norms }, liveCatalog] = await Promise.all([ 384 + loadEmbeddingIndex(context), 385 + loadLiveCatalog(), 386 + ]) 387 + const entries = index.entries ?? [] 388 + if (liveCatalog.talks.length === 0) { 389 + return jsonResponse({ uris: [], mode: 'lexical', notice: 'No videos discovered from repos.' }) 390 + } 391 + 392 + const liveUris = new Set(liveCatalog.talks.map((talk) => talk.uri)) 393 + const embeddedEntries = entries.filter((entry) => liveUris.has(entry.uri)) 394 + const embeddedUriSet = new Set(embeddedEntries.map((entry) => entry.uri)) 395 + const unembeddedTalks = liveCatalog.talks.filter((talk) => !embeddedUriSet.has(talk.uri)) 396 + 397 + if (embeddedEntries.length === 0) { 398 + const lexicalOnly = liveCatalog.talks 399 + .map((talk) => ({ 400 + uri: talk.uri, 401 + score: lexicalScore(query, talk.title), 402 + })) 403 + .filter((entry) => entry.score > 0) 404 + .sort((a, b) => b.score - a.score) 405 + .slice(0, limit) 406 + 407 + return jsonResponse({ 408 + uris: lexicalOnly.map((entry) => entry.uri), 409 + mode: 'lexical', 410 + notice: 'Embedding index is empty or stale; using lexical fallback over live catalog.', 411 + generatedAt: index.generatedAt, 412 + indexedCount: 0, 413 + }) 414 + } 415 + 416 + const normByUri = mapNormsByUri(entries, norms) 417 + const queryVector = await embedQuery(context, query) 418 + if (!queryVector) { 419 + const lexicalRanked = liveCatalog.talks 420 + .map((entry) => ({ 421 + uri: entry.uri, 422 + score: lexicalScore(query, entry.title ?? ''), 423 + })) 424 + .filter((entry) => entry.score > 0) 425 + .sort((a, b) => b.score - a.score) 426 + .slice(0, limit) 427 + 428 + return jsonResponse({ 429 + uris: lexicalRanked.map((entry) => entry.uri), 430 + mode: 'lexical', 431 + notice: 'OpenRouter key missing; using lexical title search fallback.', 432 + generatedAt: index.generatedAt, 433 + indexedCount: embeddedEntries.length, 434 + }) 435 + } 436 + 437 + const queryNorm = normalizeVector(queryVector) 438 + const bounds = recencyBounds(embeddedEntries) 439 + const ranked: RankedEntry[] = [] 440 + 441 + for (let i = 0; i < embeddedEntries.length; i += 1) { 442 + const entry = embeddedEntries[i] 443 + const similarity = cosineSimilarity(queryVector, entry.embedding ?? [], queryNorm, normByUri.get(entry.uri) ?? 0) 444 + const liveTitle = liveCatalog.titleByUri.get(entry.uri) ?? entry.title ?? '' 445 + const lexical = lexicalScore(query, liveTitle) 446 + const freshness = recencyScore(liveCatalog.recencyByUri.get(entry.uri) ?? entry.createdAt, bounds) 447 + const score = similarity * 0.82 + lexical * 0.13 + freshness * 0.05 448 + 449 + if (score > 0) { 450 + ranked.push({ uri: entry.uri, score }) 451 + } 452 + } 453 + 454 + const lexicalForUnembedded = unembeddedTalks 455 + .map((talk) => ({ 456 + uri: talk.uri, 457 + score: lexicalScore(query, talk.title) * 0.55, 458 + })) 459 + .filter((entry) => entry.score > 0) 460 + 461 + ranked.push(...lexicalForUnembedded) 462 + 463 + ranked.sort((a, b) => b.score - a.score) 464 + 465 + const stalenessNotice = 466 + unembeddedTalks.length > 0 467 + ? `${unembeddedTalks.length} newly discovered videos are currently lexical-ranked until the embedding index refreshes.` 468 + : 'All discovered videos are represented in the embedding index.' 469 + 470 + return jsonResponse({ 471 + uris: ranked.slice(0, limit).map((entry) => entry.uri), 472 + mode: 'semantic', 473 + notice: `${stalenessNotice} AtmosphereConf entries rank best where tags/topics are available.`, 474 + generatedAt: index.generatedAt, 475 + indexedCount: embeddedEntries.length, 476 + }) 477 + } catch (error) { 478 + return jsonResponse( 479 + { 480 + uris: [], 481 + mode: 'lexical', 482 + notice: error instanceof Error ? error.message : 'Search backend unavailable.', 483 + generatedAt: null, 484 + indexedCount: 0, 485 + }, 486 + { status: 200 }, 487 + ) 488 + } 489 + }
+8 -2
index.html
··· 18 18 <meta name="theme-color" content="#000000" /> 19 19 <meta 20 20 name="description" 21 - content="Atmosphere VODs is a glassy PWA for browsing ATmosphereConf 2026 talks." 21 + content="Streamplace VOD Client browses place.stream.video records across Atmosphere repos." 22 22 /> 23 - <title>Atmosphere VODs</title> 23 + <meta property="og:title" content="Streamplace VOD Client" /> 24 + <meta 25 + property="og:description" 26 + content="Browse Streamplace and AtmosphereConf VODs with deep links and fast playback." 27 + /> 28 + <meta property="og:url" content="https://vods.j4ck.xyz" /> 29 + <title>Streamplace VOD Client</title> 24 30 </head> 25 31 <body> 26 32 <div id="root"></div>
+2 -1
package.json
··· 8 8 "build": "tsc -b && vite build", 9 9 "lint": "eslint .", 10 10 "preview": "vite preview", 11 - "taxonomy:generate": "node ./scripts/generate-video-taxonomy.mjs" 11 + "taxonomy:generate": "node ./scripts/generate-video-taxonomy.mjs", 12 + "embeddings:generate": "node ./scripts/generate-video-embeddings.mjs" 12 13 }, 13 14 "dependencies": { 14 15 "@radix-ui/react-slot": "^1.2.4",
+1
public/_redirects
··· 1 + /* /index.html 200
+6
public/video-embeddings.json
··· 1 + { 2 + "version": 1, 3 + "generatedAt": "1970-01-01T00:00:00.000Z", 4 + "model": "openai/text-embedding-3-small", 5 + "entries": [] 6 + }
+245
scripts/generate-video-embeddings.mjs
··· 1 + import { readFile, writeFile } from 'node:fs/promises' 2 + import { existsSync } from 'node:fs' 3 + import path from 'node:path' 4 + 5 + const PLC_DIRECTORY_URL = 'https://plc.directory' 6 + const BSKY_RELAY_SYNC_API = 'https://bsky.network' 7 + const STREAMPLACE_VIDEO_COLLECTION = 'place.stream.video' 8 + const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1' 9 + const EMBEDDING_MODEL = process.env.OPENROUTER_EMBEDDING_MODEL ?? 'openai/text-embedding-3-small' 10 + const OUTPUT_PATH = path.resolve(process.cwd(), 'public/video-embeddings.json') 11 + const TAXONOMY_PATH = path.resolve(process.cwd(), 'src/lib/video-taxonomy.json') 12 + 13 + function parseEnvFile(content) { 14 + const vars = {} 15 + 16 + for (const rawLine of content.split(/\r?\n/)) { 17 + const line = rawLine.trim() 18 + if (!line || line.startsWith('#')) { 19 + continue 20 + } 21 + 22 + const idx = line.indexOf('=') 23 + if (idx <= 0) { 24 + continue 25 + } 26 + 27 + const key = line.slice(0, idx).trim() 28 + let value = line.slice(idx + 1).trim() 29 + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { 30 + value = value.slice(1, -1) 31 + } 32 + 33 + vars[key] = value 34 + } 35 + 36 + return vars 37 + } 38 + 39 + async function loadEnv() { 40 + const candidatePaths = [ 41 + path.resolve(process.cwd(), '.env'), 42 + path.resolve(process.cwd(), '..', '.env'), 43 + ] 44 + 45 + for (const filePath of candidatePaths) { 46 + if (!existsSync(filePath)) { 47 + continue 48 + } 49 + 50 + const content = await readFile(filePath, 'utf8') 51 + const parsed = parseEnvFile(content) 52 + 53 + for (const [key, value] of Object.entries(parsed)) { 54 + if (!process.env[key]) { 55 + process.env[key] = value 56 + } 57 + } 58 + } 59 + } 60 + 61 + async function fetchJson(url) { 62 + const response = await fetch(url) 63 + if (!response.ok) { 64 + throw new Error(`Request failed (${response.status}) for ${url}`) 65 + } 66 + return response.json() 67 + } 68 + 69 + async function callOpenRouter(pathname, body) { 70 + const apiKey = process.env.OPENROUTER_API_KEY 71 + if (!apiKey) { 72 + throw new Error('OPENROUTER_API_KEY is missing') 73 + } 74 + 75 + const response = await fetch(`${OPENROUTER_API_URL}${pathname}`, { 76 + method: 'POST', 77 + headers: { 78 + Authorization: `Bearer ${apiKey}`, 79 + 'Content-Type': 'application/json', 80 + 'HTTP-Referer': 'https://vods.j4ck.xyz', 81 + 'X-OpenRouter-Title': 'Streamplace VOD embeddings generation', 82 + }, 83 + body: JSON.stringify(body), 84 + }) 85 + 86 + if (!response.ok) { 87 + const errorText = await response.text() 88 + throw new Error(`OpenRouter request failed (${response.status}): ${errorText}`) 89 + } 90 + 91 + return response.json() 92 + } 93 + 94 + async function resolvePdsUrl(did) { 95 + const didDoc = await fetchJson(`${PLC_DIRECTORY_URL}/${did}`) 96 + const pdsService = didDoc.service?.find((entry) => entry.id === '#atproto_pds') 97 + 98 + if (!pdsService?.serviceEndpoint) { 99 + throw new Error(`Could not resolve PDS endpoint from DID document for ${did}`) 100 + } 101 + 102 + return pdsService.serviceEndpoint.replace(/\/$/, '') 103 + } 104 + 105 + async function fetchReposByCollection(collection) { 106 + const repos = [] 107 + let cursor = undefined 108 + 109 + do { 110 + const query = new URLSearchParams({ 111 + collection, 112 + limit: '1000', 113 + }) 114 + 115 + if (cursor) { 116 + query.set('cursor', cursor) 117 + } 118 + 119 + const page = await fetchJson( 120 + `${BSKY_RELAY_SYNC_API}/xrpc/com.atproto.sync.listReposByCollection?${query.toString()}`, 121 + ) 122 + 123 + for (const repo of page.repos ?? []) { 124 + if (repo.did) { 125 + repos.push(repo.did) 126 + } 127 + } 128 + 129 + cursor = page.cursor 130 + } while (cursor) 131 + 132 + return [...new Set(repos)] 133 + } 134 + 135 + async function fetchAllRecordsForRepo(repoDid) { 136 + const pdsUrl = await resolvePdsUrl(repoDid) 137 + const records = [] 138 + let cursor = undefined 139 + 140 + do { 141 + const query = new URLSearchParams({ 142 + repo: repoDid, 143 + collection: STREAMPLACE_VIDEO_COLLECTION, 144 + limit: '100', 145 + }) 146 + if (cursor) { 147 + query.set('cursor', cursor) 148 + } 149 + 150 + const page = await fetchJson(`${pdsUrl}/xrpc/com.atproto.repo.listRecords?${query.toString()}`) 151 + records.push(...(page.records ?? [])) 152 + cursor = page.cursor 153 + } while (cursor) 154 + 155 + return records 156 + } 157 + 158 + function toEmbeddingInput(record) { 159 + const title = record.value?.title ?? 'Untitled' 160 + const description = record.value?.description ?? '' 161 + const creator = record.value?.creator ?? '' 162 + const taxonomy = record.taxonomy 163 + const taxonomyLine = taxonomy 164 + ? [ 165 + ...(taxonomy.tags ?? []), 166 + ...(taxonomy.topics ?? []), 167 + ...(taxonomy.keywords ?? []), 168 + ] 169 + .filter(Boolean) 170 + .join(', ') 171 + : '' 172 + 173 + return [ 174 + `title: ${title}`, 175 + description ? `description: ${description}` : '', 176 + creator ? `creator: ${creator}` : '', 177 + taxonomyLine ? `atmosphere-taxonomy: ${taxonomyLine}` : '', 178 + ] 179 + .filter(Boolean) 180 + .join('\n') 181 + } 182 + 183 + async function main() { 184 + await loadEnv() 185 + 186 + const taxonomyRaw = await readFile(TAXONOMY_PATH, 'utf8') 187 + const taxonomyData = JSON.parse(taxonomyRaw) 188 + const taxonomyByUri = new Map((taxonomyData.entries ?? []).map((entry) => [entry.uri, entry])) 189 + 190 + const repoDids = await fetchReposByCollection(STREAMPLACE_VIDEO_COLLECTION) 191 + if (repoDids.length === 0) { 192 + throw new Error('No repos discovered for place.stream.video') 193 + } 194 + 195 + console.log(`Discovered ${repoDids.length} repos`) 196 + 197 + const allRecords = [] 198 + for (const repoDid of repoDids) { 199 + try { 200 + const records = await fetchAllRecordsForRepo(repoDid) 201 + console.log(`Fetched ${records.length} videos from ${repoDid}`) 202 + for (const record of records) { 203 + allRecords.push({ 204 + ...record, 205 + sourceRepoDid: repoDid, 206 + taxonomy: taxonomyByUri.get(record.uri), 207 + }) 208 + } 209 + } catch (error) { 210 + console.warn(`Skipping ${repoDid}: ${error instanceof Error ? error.message : 'failed'}`) 211 + } 212 + } 213 + 214 + if (allRecords.length === 0) { 215 + throw new Error('No video records available for embedding generation') 216 + } 217 + 218 + const inputs = allRecords.map((record) => toEmbeddingInput(record)) 219 + const embeddingResponse = await callOpenRouter('/embeddings', { 220 + model: EMBEDDING_MODEL, 221 + input: inputs, 222 + input_type: 'search_document', 223 + }) 224 + 225 + const output = { 226 + version: 1, 227 + generatedAt: new Date().toISOString(), 228 + model: EMBEDDING_MODEL, 229 + entries: allRecords.map((record, index) => ({ 230 + uri: record.uri, 231 + sourceRepoDid: record.sourceRepoDid, 232 + createdAt: record.value?.createdAt, 233 + title: record.value?.title ?? 'Untitled', 234 + embedding: embeddingResponse.data[index]?.embedding ?? [], 235 + })), 236 + } 237 + 238 + await writeFile(OUTPUT_PATH, `${JSON.stringify(output, null, 2)}\n`, 'utf8') 239 + console.log(`Wrote embeddings to ${OUTPUT_PATH}`) 240 + } 241 + 242 + main().catch((error) => { 243 + console.error(error) 244 + process.exit(1) 245 + })
+1 -1
scripts/generate-video-taxonomy.mjs
··· 165 165 headers: { 166 166 Authorization: `Bearer ${apiKey}`, 167 167 'Content-Type': 'application/json', 168 - 'HTTP-Referer': 'https://atmovods.j4ck.xyz', 168 + 'HTTP-Referer': 'https://vods.j4ck.xyz', 169 169 'X-OpenRouter-Title': 'Atmosphere VODs taxonomy generation', 170 170 }, 171 171 body: JSON.stringify(body),
+5 -1
src/App.tsx
··· 4 4 import { AppShell } from '@/components/layout/app-shell' 5 5 6 6 const BrowsePage = lazy(() => import('@/pages/browse-page').then((module) => ({ default: module.BrowsePage }))) 7 + const AtmosphereConfPage = lazy(() => 8 + import('@/pages/atmosphereconf-page').then((module) => ({ default: module.AtmosphereConfPage })), 9 + ) 7 10 const SearchPage = lazy(() => import('@/pages/search-page').then((module) => ({ default: module.SearchPage }))) 8 11 const AboutPage = lazy(() => import('@/pages/about-page').then((module) => ({ default: module.AboutPage }))) 9 12 const VideoPage = lazy(() => import('@/pages/video-page').then((module) => ({ default: module.VideoPage }))) ··· 23 26 <Suspense fallback={<RouteFallback />}> 24 27 <Routes> 25 28 <Route path="/" element={<BrowsePage />} /> 29 + <Route path="/atmosphereconf-2026" element={<AtmosphereConfPage />} /> 26 30 <Route path="/search" element={<SearchPage />} /> 27 31 <Route path="/about" element={<AboutPage />} /> 28 - <Route path="/video/:encodedUri" element={<VideoPage />} /> 32 + <Route path="/video/:didParam/:rkeyParam" element={<VideoPage />} /> 29 33 <Route path="/tag/:tagParam" element={<TagPage />} /> 30 34 <Route path="*" element={<Navigate to="/" replace />} /> 31 35 </Routes>
+75 -9
src/components/layout/app-shell.tsx
··· 1 - import { Film, Info, Search } from 'lucide-react' 2 - import { NavLink, type NavLinkProps } from 'react-router-dom' 1 + import { Film, Info, Search, Sparkles } from 'lucide-react' 2 + import { NavLink, type NavLinkProps, useLocation } 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' 5 6 import { cn } from '@/lib/utils' 6 7 7 8 const navItems = [ ··· 10 11 icon: Film, 11 12 to: '/', 12 13 end: true, 14 + }, 15 + { 16 + label: 'Atmosphere', 17 + icon: Sparkles, 18 + to: '/atmosphereconf-2026', 13 19 }, 14 20 { 15 21 label: 'Search', ··· 43 49 ) 44 50 } 45 51 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 + 46 86 export function AppShell({ children }: PropsWithChildren) { 87 + const location = useLocation() 47 88 const [isHeaderHidden, setIsHeaderHidden] = useState(false) 48 89 const lastScrollYRef = useRef(0) 90 + const shortcuts = getShortcuts(location.pathname) 49 91 50 92 useEffect(() => { 51 93 const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches ··· 94 136 onFocusCapture={() => setIsHeaderHidden(false)} 95 137 > 96 138 <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"> 97 - <p className="text-base font-bold tracking-[0.01em] text-text md:text-lg">Atmosphere VODs</p> 139 + <p className="text-base font-bold tracking-[0.01em] text-text md:text-lg">Streamplace VOD Client</p> 98 140 99 141 <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"> 100 142 Skip to content 101 143 </a> 102 144 103 - <nav className="hidden items-center gap-1.5 md:flex lg:gap-2" aria-label="Primary"> 104 - {navItems.map((item) => ( 105 - <NavItem key={item.to} label={item.label} to={item.to} end={item.end} icon={item.icon} /> 106 - ))} 107 - </nav> 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 153 </div> 109 154 </header> 110 155 ··· 135 180 > 136 181 GitHub 137 182 </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> 138 194 </p> 139 - <p>Built for the Streamplace VOD JAM</p> 195 + <p> 196 + Built for Streamplace VOD beta ·{' '} 197 + <a 198 + href="https://vods.j4ck.xyz" 199 + target="_blank" 200 + rel="noreferrer" 201 + className="underline-offset-4 hover:text-text hover:underline" 202 + > 203 + vods.j4ck.xyz 204 + </a> 205 + </p> 140 206 </div> 141 207 </footer> 142 208
+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 + }
+5 -1
src/components/talk-card.tsx
··· 12 12 interface TalkCardProps { 13 13 talk: AppTalk 14 14 featured?: boolean 15 + selected?: boolean 16 + cardId?: string 15 17 } 16 18 17 - export function TalkCard({ talk, featured = false }: TalkCardProps) { 19 + export function TalkCard({ talk, featured = false, selected = false, cardId }: TalkCardProps) { 18 20 const cardRef = useRef<HTMLAnchorElement | null>(null) 19 21 const [thumbnail, setThumbnail] = useState<string | null>(() => getCachedThumbnail(talk.uri)) 20 22 const [hasEnteredView, setHasEnteredView] = useState<boolean>(false) ··· 68 70 69 71 return ( 70 72 <Link 73 + id={cardId} 71 74 ref={cardRef} 72 75 to={toVideoPath(talk.uri)} 73 76 onClick={cardTapHaptic} ··· 76 79 featured 77 80 ? 'border-line/45 bg-surface/80 hover:border-line/60 supports-[backdrop-filter]:backdrop-blur-md' 78 81 : 'border-line/35 bg-surface/80 hover:border-line/50', 82 + selected && 'ring-2 ring-text/35', 79 83 !featured && 'perf-content-auto', 80 84 featured ? 'p-5 md:p-6' : 'p-4', 81 85 )}
+165 -20
src/lib/api.ts
··· 1 1 import { 2 + ATMOSPHERE_REPO_DID, 2 3 BSKY_PUBLIC_API, 4 + BSKY_RELAY_SYNC_API, 3 5 PLC_DIRECTORY_URL, 4 - REPO_DID, 6 + STREAMPLACE_VIDEO_COLLECTION, 5 7 VOD_PLAYLIST_ENDPOINT, 6 8 } from './constants' 7 9 import { truncateDid } from './format' ··· 9 11 import type { 10 12 ActorProfile, 11 13 AppTalk, 14 + GetRecordResponse, 12 15 ListRecordsResponse, 16 + ListReposByCollectionResponse, 13 17 PlcDidDocument, 14 18 } from './types' 15 19 ··· 22 26 } 23 27 24 28 const profileCache = new Map<string, Promise<ActorProfile | null>>() 25 - let pdsUrlPromise: Promise<string> | null = null 29 + const pdsUrlCache = new Map<string, Promise<string>>() 26 30 const REQUEST_TIMEOUT_MS = 8_000 27 31 const PROFILE_CONCURRENCY = 6 32 + const REPO_FETCH_CONCURRENCY = 4 33 + const DISCOVERY_LIMIT = 1_000 34 + const RECORDS_PAGE_LIMIT = 100 28 35 const taxonomyByUri = new Map( 29 36 (taxonomyData.entries as TaxonomyEntry[]).map((entry) => [entry.uri, entry]), 30 37 ) ··· 90 97 return results 91 98 } 92 99 93 - export function resolvePdsUrl(): Promise<string> { 94 - if (pdsUrlPromise) { 95 - return pdsUrlPromise 100 + async function fetchReposByCollection(collection: string): Promise<string[]> { 101 + const dids = new Set<string>() 102 + let cursor: string | undefined 103 + 104 + do { 105 + const query = new URLSearchParams({ 106 + collection, 107 + limit: String(DISCOVERY_LIMIT), 108 + }) 109 + 110 + if (cursor) { 111 + query.set('cursor', cursor) 112 + } 113 + 114 + const data = await fetchJson<ListReposByCollectionResponse>( 115 + `${BSKY_RELAY_SYNC_API}/xrpc/com.atproto.sync.listReposByCollection?${query.toString()}`, 116 + ) 117 + 118 + for (const entry of data.repos ?? []) { 119 + if (entry.did) { 120 + dids.add(entry.did) 121 + } 122 + } 123 + 124 + cursor = data.cursor 125 + } while (cursor) 126 + 127 + return [...dids].sort((a, b) => a.localeCompare(b)) 128 + } 129 + 130 + export function resolvePdsUrl(did: string): Promise<string> { 131 + const cached = pdsUrlCache.get(did) 132 + if (cached) { 133 + return cached 96 134 } 97 135 98 - pdsUrlPromise = (async () => { 136 + const pending = (async () => { 99 137 try { 100 - const didDoc = await fetchJson<PlcDidDocument>(`${PLC_DIRECTORY_URL}/${REPO_DID}`) 138 + const didDoc = await fetchJson<PlcDidDocument>(`${PLC_DIRECTORY_URL}/${did}`) 101 139 const pdsService = didDoc.service?.find((entry) => entry.id === '#atproto_pds') 102 140 103 141 if (!pdsService?.serviceEndpoint) { ··· 106 144 107 145 return pdsService.serviceEndpoint.replace(/\/$/, '') 108 146 } catch (error) { 109 - pdsUrlPromise = null 147 + pdsUrlCache.delete(did) 110 148 throw error 111 149 } 112 150 })() 113 151 114 - return pdsUrlPromise 152 + pdsUrlCache.set(did, pending) 153 + return pending 154 + } 155 + 156 + async function fetchRepoCollectionRecords( 157 + repoDid: string, 158 + collection: string, 159 + ): Promise<ListRecordsResponse['records']> { 160 + const pdsUrl = await resolvePdsUrl(repoDid) 161 + const records: ListRecordsResponse['records'] = [] 162 + let cursor: string | undefined 163 + 164 + do { 165 + const query = new URLSearchParams({ 166 + repo: repoDid, 167 + collection, 168 + limit: String(RECORDS_PAGE_LIMIT), 169 + }) 170 + 171 + if (cursor) { 172 + query.set('cursor', cursor) 173 + } 174 + 175 + const data = await fetchJson<ListRecordsResponse>( 176 + `${pdsUrl}/xrpc/com.atproto.repo.listRecords?${query.toString()}`, 177 + ) 178 + 179 + records.push(...(data.records ?? [])) 180 + cursor = data.cursor 181 + } while (cursor) 182 + 183 + return records 115 184 } 116 185 117 186 async function fetchProfile(did: string): Promise<ActorProfile | null> { ··· 138 207 } 139 208 140 209 export async function fetchTalks(): Promise<AppTalk[]> { 141 - const pdsUrl = await resolvePdsUrl() 142 - const query = new URLSearchParams({ 143 - repo: REPO_DID, 144 - collection: 'place.stream.video', 145 - limit: '100', 146 - }) 210 + const discoveredRepoDids = await fetchReposByCollection(STREAMPLACE_VIDEO_COLLECTION) 211 + if (discoveredRepoDids.length === 0) { 212 + return [] 213 + } 147 214 148 - const data = await fetchJson<ListRecordsResponse>( 149 - `${pdsUrl}/xrpc/com.atproto.repo.listRecords?${query.toString()}`, 215 + let successfulRepoCount = 0 216 + const repoRecords = await mapWithConcurrency( 217 + discoveredRepoDids, 218 + REPO_FETCH_CONCURRENCY, 219 + async (repoDid) => { 220 + try { 221 + const records = await fetchRepoCollectionRecords(repoDid, STREAMPLACE_VIDEO_COLLECTION) 222 + successfulRepoCount += 1 223 + return records.map((record) => ({ 224 + ...record, 225 + sourceRepoDid: repoDid, 226 + })) 227 + } catch { 228 + return [] 229 + } 230 + }, 150 231 ) 151 232 152 - const sorted = [...data.records].sort( 233 + if (successfulRepoCount === 0) { 234 + throw new Error('Unable to load video records from discovered repos') 235 + } 236 + 237 + const merged = repoRecords.flat() 238 + const sorted = [...merged].sort( 153 239 (a, b) => new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime(), 154 240 ) 155 241 ··· 169 255 return { 170 256 uri: record.uri, 171 257 cid: record.cid, 258 + sourceRepoDid: record.sourceRepoDid, 172 259 title: record.value.title, 173 260 description: record.value.description, 174 261 creatorDid: record.value.creator, ··· 186 273 }) 187 274 } 188 275 276 + export function isAtmosphereTalk(talk: AppTalk): boolean { 277 + return talk.sourceRepoDid === ATMOSPHERE_REPO_DID 278 + } 279 + 280 + function parseVideoUri(uri: string): { did: string } | null { 281 + const match = uri.match(/^at:\/\/(did:[^/]+)\/place\.stream\.video\/[^/]+$/) 282 + if (!match) { 283 + return null 284 + } 285 + 286 + return { did: match[1] } 287 + } 288 + 289 + async function toAppTalkFromRecord(record: GetRecordResponse): Promise<AppTalk> { 290 + const uriInfo = parseVideoUri(record.uri) 291 + if (!uriInfo) { 292 + throw new Error('Invalid video URI') 293 + } 294 + 295 + 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) 298 + 299 + return { 300 + uri: record.uri, 301 + cid: record.cid, 302 + sourceRepoDid: uriInfo.did, 303 + title: record.value.title, 304 + description: record.value.description, 305 + creatorDid: record.value.creator, 306 + creatorName, 307 + creatorHandle: profile?.handle, 308 + durationNs: record.value.duration, 309 + createdAt: record.value.createdAt, 310 + sourceRef: record.value.source?.ref, 311 + sourceMimeType: record.value.source?.mimeType, 312 + taxonomyGroup: taxonomy?.group, 313 + taxonomyTags: taxonomy?.tags ?? [], 314 + taxonomyTopics: taxonomy?.topics ?? [], 315 + taxonomyKeywords: taxonomy?.keywords ?? [], 316 + } 317 + } 318 + 319 + export async function fetchTalkByUri(uri: string): Promise<AppTalk> { 320 + const uriInfo = parseVideoUri(uri) 321 + if (!uriInfo) { 322 + throw new Error('Invalid video URI') 323 + } 324 + 325 + const pdsUrl = await resolvePdsUrl(uriInfo.did) 326 + const query = new URLSearchParams({ uri }) 327 + const record = await fetchJson<GetRecordResponse>( 328 + `${pdsUrl}/xrpc/com.atproto.repo.getRecord?${query.toString()}`, 329 + ) 330 + 331 + return toAppTalkFromRecord(record) 332 + } 333 + 189 334 export async function fetchVideoPlaylist(uri: string): Promise<string> { 190 335 const query = new URLSearchParams({ uri }) 191 336 const playlistUrl = `${VOD_PLAYLIST_ENDPOINT}?${query.toString()}` ··· 214 359 return playlistUrl 215 360 } 216 361 217 - export function getArchiveBlobUrl(sourceRef: string): string { 218 - return `https://vod-beta.stream.place/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(REPO_DID)}&cid=${encodeURIComponent(sourceRef)}` 362 + export function getArchiveBlobUrl(sourceRepoDid: string, sourceRef: string): string { 363 + return `https://vod-beta.stream.place/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(sourceRepoDid)}&cid=${encodeURIComponent(sourceRef)}` 219 364 }
+3 -1
src/lib/constants.ts
··· 1 - export const REPO_DID = 'did:plc:rbvrr34edl5ddpuwcubjiost' 1 + export const ATMOSPHERE_REPO_DID = 'did:plc:rbvrr34edl5ddpuwcubjiost' 2 + export const STREAMPLACE_VIDEO_COLLECTION = 'place.stream.video' 2 3 3 4 export const PLC_DIRECTORY_URL = 'https://plc.directory' 4 5 export const BSKY_PUBLIC_API = 'https://public.api.bsky.app' 6 + export const BSKY_RELAY_SYNC_API = 'https://bsky.network' 5 7 export const VOD_PLAYLIST_ENDPOINT = 6 8 'https://vod-beta.stream.place/xrpc/place.stream.playback.getVideoPlaylist'
+21 -22
src/lib/routes.ts
··· 1 - function toBase64Url(value: string): string { 2 - const bytes = new TextEncoder().encode(value) 3 - let binary = '' 1 + const VIDEO_URI_PATTERN = /^at:\/\/(did:[^/]+)\/place\.stream\.video\/([^/]+)$/ 4 2 5 - for (const byte of bytes) { 6 - binary += String.fromCharCode(byte) 3 + export function toVideoPath(uri: string): string { 4 + const parsed = parseVideoUri(uri) 5 + if (!parsed) { 6 + return '/' 7 7 } 8 - 9 - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '') 8 + return `/video/${parsed.did}/${parsed.rkey}` 10 9 } 11 10 12 - function fromBase64Url(value: string): string { 13 - const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (value.length % 4)) % 4) 14 - const binary = atob(padded) 15 - const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)) 16 - return new TextDecoder().decode(bytes) 17 - } 11 + function parseVideoUri(uri: string): { did: string; rkey: string } | null { 12 + const match = uri.match(VIDEO_URI_PATTERN) 13 + if (!match) { 14 + return null 15 + } 18 16 19 - export function toVideoPath(uri: string): string { 20 - return `/video/${toBase64Url(uri)}` 17 + return { 18 + did: match[1], 19 + rkey: match[2], 20 + } 21 21 } 22 22 23 - export function fromVideoParam(param: string): string | undefined { 23 + export function toVideoUriFromParams(didParam: string, rkeyParam: string): string | undefined { 24 24 try { 25 - const maybeLegacy = decodeURIComponent(param) 26 - if (maybeLegacy.startsWith('at://')) { 27 - return maybeLegacy 25 + const did = decodeURIComponent(didParam).trim() 26 + const rkey = decodeURIComponent(rkeyParam).trim() 27 + if (!did.startsWith('did:') || !rkey) { 28 + return undefined 28 29 } 29 - 30 - const decoded = fromBase64Url(param) 31 - return decoded.startsWith('at://') ? decoded : undefined 30 + return `at://${did}/place.stream.video/${rkey}` 32 31 } catch { 33 32 return undefined 34 33 }
+36
src/lib/semantic-search.ts
··· 1 + export interface RemoteSearchResult { 2 + uris: string[] 3 + mode: 'semantic' | 'lexical' 4 + notice?: string 5 + generatedAt?: string 6 + indexedCount?: number 7 + } 8 + 9 + export async function searchTalkUris( 10 + query: string, 11 + limit: number = 200, 12 + signal?: AbortSignal, 13 + ): Promise<RemoteSearchResult> { 14 + const params = new URLSearchParams({ 15 + q: query, 16 + limit: String(limit), 17 + }) 18 + 19 + const response = await fetch(`/api/search?${params.toString()}`, { 20 + method: 'GET', 21 + signal, 22 + }) 23 + 24 + if (!response.ok) { 25 + throw new Error(`Search request failed (${response.status})`) 26 + } 27 + 28 + const data = (await response.json()) as RemoteSearchResult 29 + return { 30 + uris: data.uris ?? [], 31 + mode: data.mode ?? 'lexical', 32 + notice: data.notice, 33 + generatedAt: data.generatedAt, 34 + indexedCount: data.indexedCount, 35 + } 36 + }
+37 -1
src/lib/taxonomy.ts
··· 1 1 import type { AppTalk } from './types' 2 2 3 + const FALLBACK_STOPWORDS = new Set([ 4 + 'the', 'and', 'for', 'with', 'from', 'that', 'this', 'your', 'into', 'about', 'what', 'when', 5 + 'where', 'have', 'will', 'just', 'talk', 'video', 'stream', 'conference', 'atmosphere', 'place', 6 + 'vod', 'beta', '2026', 'how', 'why', 'can', 'you', 'all', 'are', 'its', 'our', 'new', 'more', 7 + 'using', 'use', 'intro', 'introduction', 'deep', 'dive', 8 + ]) 9 + 3 10 export function normalizeSearchValue(value: string): string { 4 11 return value.trim().toLowerCase() 5 12 } ··· 25 32 return output 26 33 } 27 34 35 + function extractFallbackTokens(value: string, max: number = 6): string[] { 36 + const output: string[] = [] 37 + const seen = new Set<string>() 38 + const words = value.toLowerCase().match(/[a-z0-9][a-z0-9-]{2,}/g) ?? [] 39 + 40 + for (const word of words) { 41 + if (FALLBACK_STOPWORDS.has(word) || seen.has(word)) { 42 + continue 43 + } 44 + 45 + seen.add(word) 46 + output.push(word) 47 + if (output.length >= max) { 48 + break 49 + } 50 + } 51 + 52 + return output 53 + } 54 + 28 55 export function getTalkTaxonomyTokens(talk: AppTalk): string[] { 29 - return unique([ 56 + const explicitTokens = unique([ 30 57 talk.taxonomyGroup, 31 58 ...(talk.taxonomyTags ?? []), 32 59 ...(talk.taxonomyTopics ?? []), 33 60 ...(talk.taxonomyKeywords ?? []), 61 + ]) 62 + 63 + if (explicitTokens.length > 0) { 64 + return explicitTokens 65 + } 66 + 67 + return unique([ 68 + ...extractFallbackTokens(talk.title, 4), 69 + ...extractFallbackTokens(talk.description ?? '', 3), 34 70 ]) 35 71 } 36 72
+14
src/lib/types.ts
··· 27 27 cursor?: string 28 28 } 29 29 30 + export interface GetRecordResponse { 31 + uri: string 32 + cid: string 33 + value: StreamVideoRecord['value'] 34 + } 35 + 36 + export interface ListReposByCollectionResponse { 37 + repos: Array<{ 38 + did: string 39 + }> 40 + cursor?: string 41 + } 42 + 30 43 export interface PlcDidDocument { 31 44 service?: Array<{ 32 45 id?: string ··· 45 58 export interface AppTalk { 46 59 uri: string 47 60 cid: string 61 + sourceRepoDid: string 48 62 title: string 49 63 description?: string 50 64 creatorDid: string
+46
src/lib/use-keyboard.ts
··· 1 + import { useEffect } from 'react' 2 + 3 + interface UseKeyboardOptions { 4 + enabled?: boolean 5 + allowInInputs?: boolean 6 + } 7 + 8 + function isEditableElement(element: Element | null): boolean { 9 + if (!(element instanceof HTMLElement)) { 10 + return false 11 + } 12 + 13 + const tag = element.tagName.toLowerCase() 14 + if (tag === 'input' || tag === 'textarea' || tag === 'select') { 15 + return true 16 + } 17 + 18 + return element.isContentEditable 19 + } 20 + 21 + export function useKeyboard( 22 + onKeyDown: (event: KeyboardEvent) => void, 23 + options?: UseKeyboardOptions, 24 + ) { 25 + const enabled = options?.enabled ?? true 26 + const allowInInputs = options?.allowInInputs ?? false 27 + 28 + useEffect(() => { 29 + if (!enabled) { 30 + return 31 + } 32 + 33 + const handleKeyDown = (event: KeyboardEvent) => { 34 + if (!allowInInputs && isEditableElement(document.activeElement)) { 35 + return 36 + } 37 + 38 + onKeyDown(event) 39 + } 40 + 41 + window.addEventListener('keydown', handleKeyDown) 42 + return () => { 43 + window.removeEventListener('keydown', handleKeyDown) 44 + } 45 + }, [onKeyDown, enabled, allowInInputs]) 46 + }
+12 -5
src/pages/about-page.tsx
··· 1 1 export function AboutPage() { 2 2 return ( 3 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> 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> 6 15 <p className="mt-4 max-w-[70ch] text-sm leading-relaxed text-muted"> 7 - Atmosphere VODs is an open-source PWA for browsing ATmosphereConf 2026 talks, built for the 8 - Streamplace VOD JAM. Built with React, Vite, and the AT Protocol. Created by Jack. AI-assisted 9 - with Claude. 16 + Live deployment: <a href="https://vods.j4ck.xyz" className="underline-offset-4 hover:text-text hover:underline">vods.j4ck.xyz</a> 10 17 </p> 11 18 </section> 12 19 )
+57
src/pages/atmosphereconf-page.tsx
··· 1 + import { ErrorPanel } from '@/components/error-panel' 2 + import { TalkCard } from '@/components/talk-card' 3 + import { TalkGridSkeleton } from '@/components/talk-grid-skeleton' 4 + import { isAtmosphereTalk } from '@/lib/api' 5 + import { useVideos } from '@/state/videos-context' 6 + 7 + export function AtmosphereConfPage() { 8 + const { talks, loading, error, refresh } = useVideos() 9 + const filteredTalks = talks.filter((talk) => isAtmosphereTalk(talk)) 10 + const [featuredTalk, ...remainingTalks] = filteredTalks 11 + 12 + return ( 13 + <div className="space-y-7 md:space-y-10" aria-busy={loading}> 14 + <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> 17 + </header> 18 + 19 + {loading ? ( 20 + <div role="status" aria-live="polite"> 21 + <span className="sr-only">Loading talks</span> 22 + <TalkGridSkeleton /> 23 + </div> 24 + ) : null} 25 + 26 + {!loading && error ? ( 27 + <ErrorPanel 28 + title="Unable to load AtmosphereConf videos" 29 + message="The app could not load conference records right now. Check your connection and retry." 30 + onRetry={refresh} 31 + /> 32 + ) : null} 33 + 34 + {!loading && !error ? ( 35 + <> 36 + {featuredTalk ? ( 37 + <section className="space-y-3 md:space-y-4"> 38 + <h2 className="text-sm font-medium text-muted">Latest Upload</h2> 39 + <TalkCard talk={featuredTalk} featured /> 40 + </section> 41 + ) : null} 42 + 43 + {remainingTalks.length > 0 ? ( 44 + <section className="space-y-3"> 45 + <h2 className="text-sm font-medium text-muted">More Videos</h2> 46 + <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 + {remainingTalks.map((talk) => ( 48 + <TalkCard key={talk.uri} talk={talk} /> 49 + ))} 50 + </div> 51 + </section> 52 + ) : null} 53 + </> 54 + ) : null} 55 + </div> 56 + ) 57 + }
+89 -6
src/pages/browse-page.tsx
··· 1 + import { useMemo, useState } from 'react' 2 + import { useNavigate } from 'react-router-dom' 3 + 1 4 import { TalkCard } from '@/components/talk-card' 2 5 import { TalkGridSkeleton } from '@/components/talk-grid-skeleton' 3 6 import { ErrorPanel } from '@/components/error-panel' 7 + import { isAtmosphereTalk } from '@/lib/api' 8 + import { useKeyboard } from '@/lib/use-keyboard' 4 9 import { useVideos } from '@/state/videos-context' 5 10 6 11 export function BrowsePage() { 12 + const navigate = useNavigate() 7 13 const { talks, loading, error, refresh } = useVideos() 8 14 const [featuredTalk, ...remainingTalks] = talks 15 + const sourceRepos = Array.from(new Set(talks.map((talk) => talk.sourceRepoDid))).sort((a, b) => 16 + a.localeCompare(b), 17 + ) 18 + const atmosphereCount = talks.filter((talk) => isAtmosphereTalk(talk)).length 19 + const orderedTalks = useMemo(() => talks, [talks]) 20 + const [selectedIndex, setSelectedIndex] = useState(0) 21 + 22 + useKeyboard((event) => { 23 + if (event.metaKey || event.ctrlKey || event.altKey || orderedTalks.length === 0) { 24 + return 25 + } 26 + 27 + const key = event.key.toLowerCase() 28 + if (key === 'j') { 29 + event.preventDefault() 30 + setSelectedIndex((value) => Math.min(orderedTalks.length - 1, value + 1)) 31 + return 32 + } 33 + 34 + if (key === 'k') { 35 + event.preventDefault() 36 + setSelectedIndex((value) => Math.max(0, value - 1)) 37 + return 38 + } 39 + 40 + if (key === 'enter') { 41 + event.preventDefault() 42 + const selectedTalk = orderedTalks[selectedIndex] 43 + if (!selectedTalk) { 44 + return 45 + } 46 + const card = document.getElementById(`talk-card-${encodeURIComponent(selectedTalk.uri)}`) 47 + if (card instanceof HTMLAnchorElement) { 48 + card.click() 49 + } 50 + return 51 + } 52 + 53 + if (event.key === '/') { 54 + event.preventDefault() 55 + navigate('/search?focus=1') 56 + } 57 + }) 9 58 10 59 return ( 11 60 <div className="space-y-7 md:space-y-10" aria-busy={loading}> 12 61 <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> 62 + <h1 className="text-2xl font-semibold text-text">Streamplace VOD Browser</h1> 63 + <p className="text-sm text-muted">All discovered repos, newest first.</p> 15 64 </header> 16 65 17 66 {loading ? ( ··· 24 73 {!loading && error ? ( 25 74 <ErrorPanel 26 75 title="Unable to load talks" 27 - message="The app could not fetch records from the Streamplace repo PDS. Check your connection and retry." 76 + message="The app could not fetch records from discovered Streamplace repos. Check your connection and retry." 28 77 onRetry={refresh} 29 78 /> 30 79 ) : null} 31 80 32 81 {!loading && !error ? ( 33 82 <> 83 + {sourceRepos.length > 0 ? ( 84 + <section className="space-y-3 rounded-lg border border-line/45 bg-surface/80 p-4 md:p-5"> 85 + <h2 className="text-sm font-medium text-muted">Discovered repos</h2> 86 + <p className="text-sm text-muted"> 87 + {sourceRepos.length} repo{sourceRepos.length === 1 ? '' : 's'} with{' '} 88 + <code>place.stream.video</code> records. 89 + </p> 90 + <div className="flex flex-wrap gap-2"> 91 + {sourceRepos.map((did) => ( 92 + <span 93 + key={did} 94 + className="inline-flex min-h-11 items-center rounded-md border border-line/45 bg-surface/70 px-3 text-xs text-muted" 95 + > 96 + {did} 97 + </span> 98 + ))} 99 + </div> 100 + <p className="text-xs text-muted"> 101 + AtmosphereConf official repo contributes {atmosphereCount} video 102 + {atmosphereCount === 1 ? '' : 's'}. 103 + </p> 104 + </section> 105 + ) : null} 106 + 34 107 {featuredTalk ? ( 35 108 <section className="space-y-3 md:space-y-4"> 36 109 <h2 className="text-sm font-medium text-muted">Latest Upload</h2> 37 - <TalkCard talk={featuredTalk} featured /> 110 + <TalkCard 111 + talk={featuredTalk} 112 + featured 113 + selected={selectedIndex === 0} 114 + cardId={`talk-card-${encodeURIComponent(featuredTalk.uri)}`} 115 + /> 38 116 </section> 39 117 ) : null} 40 118 ··· 42 120 <section className="space-y-3"> 43 121 <h2 className="text-sm font-medium text-muted">More Talks</h2> 44 122 <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} /> 123 + {remainingTalks.map((talk, index) => ( 124 + <TalkCard 125 + key={talk.uri} 126 + talk={talk} 127 + selected={selectedIndex === index + 1} 128 + cardId={`talk-card-${encodeURIComponent(talk.uri)}`} 129 + /> 47 130 ))} 48 131 </div> 49 132 </section>
+233 -27
src/pages/search-page.tsx
··· 1 1 import { Search } from 'lucide-react' 2 - import { useMemo, useState } from 'react' 3 - import { Link } from 'react-router-dom' 2 + import { useEffect, useState, type ChangeEvent } from 'react' 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' 6 7 import { TalkCard } from '@/components/talk-card' 7 8 import { TalkGridSkeleton } from '@/components/talk-grid-skeleton' 9 + import { searchTalkUris } from '@/lib/semantic-search' 8 10 import { toTagPath } from '@/lib/routes' 9 11 import { getTalkTaxonomyTokens, scoreTalkForQuery } from '@/lib/taxonomy' 12 + import { useKeyboard } from '@/lib/use-keyboard' 10 13 import { useVideos } from '@/state/videos-context' 11 14 12 15 export function SearchPage() { 16 + const [searchParams] = useSearchParams() 13 17 const [query, setQuery] = useState<string>('') 18 + const [remoteQuery, setRemoteQuery] = useState<string>('') 19 + const [remoteUris, setRemoteUris] = useState<string[] | null>(null) 20 + const [remoteMode, setRemoteMode] = useState<'semantic' | 'lexical' | null>(null) 21 + const [remoteNotice, setRemoteNotice] = useState<string | null>(null) 22 + const [remoteGeneratedAt, setRemoteGeneratedAt] = useState<string | null>(null) 23 + const [remoteIndexedCount, setRemoteIndexedCount] = useState<number | null>(null) 24 + const [remoteError, setRemoteError] = useState<string | null>(null) 25 + const [remoteLoading, setRemoteLoading] = useState<boolean>(false) 26 + const [selectedIndex, setSelectedIndex] = useState(0) 14 27 const { talks, loading, error, refresh } = useVideos() 15 28 const trimmedQuery = query.trim() 16 29 17 - const filteredTalks = useMemo(() => { 18 - if (!trimmedQuery) { 19 - return talks 30 + const onQueryChange = (event: ChangeEvent<HTMLInputElement>) => { 31 + const nextValue = event.target.value 32 + const nextTrimmed = nextValue.trim() 33 + 34 + setQuery(nextValue) 35 + setSelectedIndex(0) 36 + 37 + if (!nextTrimmed) { 38 + setRemoteQuery('') 39 + setRemoteUris(null) 40 + setRemoteMode(null) 41 + setRemoteNotice(null) 42 + setRemoteGeneratedAt(null) 43 + setRemoteIndexedCount(null) 44 + setRemoteError(null) 45 + setRemoteLoading(false) 46 + return 20 47 } 21 48 22 - return talks 23 - .map((talk) => ({ 24 - talk, 25 - score: scoreTalkForQuery(talk, trimmedQuery), 26 - })) 27 - .filter((entry) => entry.score > 0) 28 - .sort((a, b) => b.score - a.score) 29 - .map((entry) => entry.talk) 30 - }, [talks, trimmedQuery]) 49 + setRemoteUris(null) 50 + setRemoteMode(null) 51 + setRemoteNotice(null) 52 + setRemoteGeneratedAt(null) 53 + setRemoteIndexedCount(null) 54 + setRemoteError(null) 55 + setRemoteLoading(true) 56 + } 31 57 32 - const popularTokens = useMemo(() => { 33 - const counts = new Map<string, number>() 58 + useEffect(() => { 59 + if (!trimmedQuery || talks.length === 0) { 60 + return 61 + } 34 62 35 - for (const talk of filteredTalks) { 36 - for (const token of getTalkTaxonomyTokens(talk)) { 37 - counts.set(token, (counts.get(token) ?? 0) + 1) 63 + const controller = new AbortController() 64 + let active = true 65 + 66 + const timeout = window.setTimeout(() => { 67 + searchTalkUris(trimmedQuery, 200, controller.signal) 68 + .then((result) => { 69 + if (!active) { 70 + return 71 + } 72 + setSelectedIndex(0) 73 + setRemoteQuery(trimmedQuery) 74 + setRemoteUris(result.uris) 75 + setRemoteMode(result.mode) 76 + setRemoteNotice(result.notice ?? null) 77 + setRemoteGeneratedAt(result.generatedAt ?? null) 78 + setRemoteIndexedCount(typeof result.indexedCount === 'number' ? result.indexedCount : null) 79 + setRemoteError(null) 80 + }) 81 + .catch((fetchError) => { 82 + if (!active) { 83 + return 84 + } 85 + if (fetchError instanceof DOMException && fetchError.name === 'AbortError') { 86 + return 87 + } 88 + const message = fetchError instanceof Error ? fetchError.message : 'Semantic search unavailable.' 89 + setSelectedIndex(0) 90 + setRemoteQuery(trimmedQuery) 91 + setRemoteError(message) 92 + setRemoteUris(null) 93 + setRemoteMode(null) 94 + setRemoteNotice(null) 95 + setRemoteGeneratedAt(null) 96 + setRemoteIndexedCount(null) 97 + }) 98 + .finally(() => { 99 + if (active) { 100 + setRemoteLoading(false) 101 + } 102 + }) 103 + }, 250) 104 + 105 + return () => { 106 + active = false 107 + window.clearTimeout(timeout) 108 + controller.abort() 109 + } 110 + }, [trimmedQuery, talks.length]) 111 + 112 + const filteredTalks = !trimmedQuery 113 + ? talks 114 + : (() => { 115 + const hasCurrentRemote = remoteQuery === trimmedQuery 116 + 117 + if (hasCurrentRemote && remoteUris && remoteUris.length > 0) { 118 + const talkByUri = new Map(talks.map((talk) => [talk.uri, talk])) 119 + const orderedFromRemote = remoteUris 120 + .map((uri) => talkByUri.get(uri)) 121 + .filter((talk): talk is NonNullable<typeof talk> => Boolean(talk)) 122 + 123 + if (orderedFromRemote.length > 0) { 124 + return orderedFromRemote 125 + } 126 + } 127 + 128 + return talks 129 + .map((talk) => ({ 130 + talk, 131 + score: scoreTalkForQuery(talk, trimmedQuery), 132 + })) 133 + .filter((entry) => entry.score > 0) 134 + .sort((a, b) => b.score - a.score) 135 + .map((entry) => entry.talk) 136 + })() 137 + 138 + const selectedTalkIndex = 139 + filteredTalks.length > 0 ? Math.min(selectedIndex, filteredTalks.length - 1) : 0 140 + 141 + useKeyboard((event) => { 142 + if (event.metaKey || event.ctrlKey || event.altKey) { 143 + return 144 + } 145 + 146 + const key = event.key 147 + const lower = key.toLowerCase() 148 + 149 + if (lower === 'j') { 150 + if (filteredTalks.length === 0) { 151 + return 38 152 } 153 + event.preventDefault() 154 + setSelectedIndex((value) => Math.min(filteredTalks.length - 1, value + 1)) 155 + return 39 156 } 40 157 41 - return [...counts.entries()] 42 - .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) 43 - .slice(0, 12) 44 - .map(([token]) => token) 45 - }, [filteredTalks]) 158 + if (lower === 'k') { 159 + if (filteredTalks.length === 0) { 160 + return 161 + } 162 + event.preventDefault() 163 + setSelectedIndex((value) => Math.max(0, value - 1)) 164 + return 165 + } 166 + 167 + if (key === '/') { 168 + event.preventDefault() 169 + const input = document.getElementById('search-input') 170 + if (input instanceof HTMLInputElement) { 171 + input.focus() 172 + } 173 + return 174 + } 175 + 176 + if (lower === 'enter') { 177 + if (filteredTalks.length === 0) { 178 + return 179 + } 180 + event.preventDefault() 181 + const selectedTalk = filteredTalks[selectedTalkIndex] 182 + if (!selectedTalk) { 183 + return 184 + } 185 + const card = document.getElementById(`talk-card-${encodeURIComponent(selectedTalk.uri)}`) 186 + if (card instanceof HTMLAnchorElement) { 187 + card.click() 188 + } 189 + } 190 + }) 191 + 192 + const counts = new Map<string, number>() 193 + for (const talk of filteredTalks) { 194 + for (const token of getTalkTaxonomyTokens(talk)) { 195 + counts.set(token, (counts.get(token) ?? 0) + 1) 196 + } 197 + } 198 + const popularTokens = [...counts.entries()] 199 + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) 200 + .slice(0, 12) 201 + .map(([token]) => token) 202 + 203 + useEffect(() => { 204 + if (searchParams.get('focus') !== '1') { 205 + return 206 + } 207 + const input = document.getElementById('search-input') 208 + if (input instanceof HTMLInputElement) { 209 + input.focus() 210 + } 211 + }, [searchParams]) 46 212 47 213 return ( 48 214 <div className="space-y-7 md:space-y-10" aria-busy={loading}> 49 215 <header className="space-y-4"> 50 - <h1 className="text-2xl font-semibold text-text">Search Talks</h1> 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> 51 228 52 229 <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"> 53 230 <Search className="h-4 w-4 text-muted" /> 54 231 <span className="sr-only">Search by title, tags, or topics</span> 55 232 <input 233 + id="search-input" 56 234 type="search" 57 235 value={query} 58 - onChange={(event) => setQuery(event.target.value)} 236 + onChange={onQueryChange} 59 237 placeholder="Search by title, tags, or topics" 60 238 className="h-11 w-full bg-transparent text-sm text-text outline-none placeholder:text-muted" 61 239 autoComplete="off" 62 240 /> 63 241 </label> 242 + 243 + {!loading && !error && trimmedQuery ? ( 244 + <section className="rounded-lg border border-line/45 bg-surface/80 p-4 text-xs text-muted"> 245 + <p> 246 + Search blends semantic ranking for all Streamplace VODs with richer AtmosphereConf tags/topics. 247 + </p> 248 + {remoteLoading ? <p className="mt-2">Ranking query...</p> : null} 249 + {!remoteLoading && remoteMode ? ( 250 + <p className="mt-2"> 251 + Mode: {remoteMode === 'semantic' ? 'semantic embeddings' : 'lexical fallback'} 252 + </p> 253 + ) : null} 254 + {!remoteLoading && remoteNotice ? <p className="mt-2">{remoteNotice}</p> : null} 255 + {!remoteLoading && remoteGeneratedAt ? ( 256 + <p className="mt-2"> 257 + Index snapshot: {new Date(remoteGeneratedAt).toLocaleString()} 258 + {remoteIndexedCount !== null ? ` (${remoteIndexedCount} embedded videos)` : ''} 259 + </p> 260 + ) : null} 261 + {!remoteLoading && remoteError ? <p className="mt-2">{remoteError}</p> : null} 262 + </section> 263 + ) : null} 64 264 65 265 {!loading && !error && popularTokens.length > 0 ? ( 66 266 <div className="flex flex-wrap gap-2"> ··· 109 309 </p> 110 310 <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"> 111 311 {filteredTalks.map((talk, index) => ( 112 - <TalkCard key={talk.uri} talk={talk} featured={index === 0 && trimmedQuery.length > 0} /> 312 + <TalkCard 313 + key={talk.uri} 314 + talk={talk} 315 + featured={index === 0 && trimmedQuery.length > 0} 316 + selected={selectedTalkIndex === index} 317 + cardId={`talk-card-${encodeURIComponent(talk.uri)}`} 318 + /> 113 319 ))} 114 320 </div> 115 321 </section>
+148 -8
src/pages/video-page.tsx
··· 15 15 16 16 import { ErrorPanel } from '@/components/error-panel' 17 17 import { Button } from '@/components/ui/button' 18 - import { fetchVideoPlaylist, getArchiveBlobUrl } from '@/lib/api' 18 + import { fetchTalkByUri, fetchVideoPlaylist, getArchiveBlobUrl } from '@/lib/api' 19 19 import { formatDate, formatDuration, truncateDid } from '@/lib/format' 20 - import { fromVideoParam, toTagPath } from '@/lib/routes' 20 + import { toTagPath, toVideoUriFromParams } from '@/lib/routes' 21 21 import { getTalkTaxonomyTokens } from '@/lib/taxonomy' 22 + import { useKeyboard } from '@/lib/use-keyboard' 23 + import type { AppTalk } from '@/lib/types' 22 24 import { useVideos } from '@/state/videos-context' 23 25 24 26 type PlaybackStatus = 'idle' | 'loading' | 'ready' | 'error' ··· 31 33 32 34 export function VideoPage() { 33 35 const navigate = useNavigate() 34 - const { encodedUri } = useParams<{ encodedUri: string }>() 36 + const { didParam, rkeyParam } = useParams<{ didParam: string; rkeyParam: string }>() 35 37 const { talks, loading: talksLoading } = useVideos() 36 38 37 39 const videoRef = useRef<HTMLVideoElement | null>(null) ··· 46 48 const [reloadToken, setReloadToken] = useState(0) 47 49 const [playbackRate, setPlaybackRate] = useState<number>(1) 48 50 const [playlistUrl, setPlaylistUrl] = useState<string | null>(null) 51 + const [resolvedTalk, setResolvedTalk] = useState<AppTalk | null>(null) 52 + const [metadataLoading, setMetadataLoading] = useState<boolean>(false) 49 53 50 54 const resolvedUri = useMemo( 51 - () => (encodedUri ? fromVideoParam(encodedUri) : undefined), 52 - [encodedUri], 55 + () => (didParam && rkeyParam ? toVideoUriFromParams(didParam, rkeyParam) : undefined), 56 + [didParam, rkeyParam], 53 57 ) 54 58 55 - const talk = useMemo(() => talks.find((item) => item.uri === resolvedUri), [talks, resolvedUri]) 59 + const talk = useMemo( 60 + () => talks.find((item) => item.uri === resolvedUri) ?? resolvedTalk, 61 + [talks, resolvedUri, resolvedTalk], 62 + ) 56 63 const talkTokens = useMemo(() => (talk ? getTalkTaxonomyTokens(talk).slice(0, 10) : []), [talk]) 57 64 58 65 const playbackElapsed = formatDuration(currentTime * 1_000_000_000) 59 66 const playbackTotal = formatDuration(duration * 1_000_000_000 || talk?.durationNs || 0) 67 + 68 + useEffect(() => { 69 + if (!resolvedUri) { 70 + return 71 + } 72 + 73 + const alreadyInCatalog = talks.some((item) => item.uri === resolvedUri) 74 + if (alreadyInCatalog) { 75 + setResolvedTalk(null) 76 + setMetadataLoading(false) 77 + return 78 + } 79 + 80 + let active = true 81 + setMetadataLoading(true) 82 + 83 + fetchTalkByUri(resolvedUri) 84 + .then((record) => { 85 + if (!active) { 86 + return 87 + } 88 + setResolvedTalk(record) 89 + }) 90 + .catch(() => { 91 + if (!active) { 92 + return 93 + } 94 + setResolvedTalk(null) 95 + }) 96 + .finally(() => { 97 + if (active) { 98 + setMetadataLoading(false) 99 + } 100 + }) 101 + 102 + return () => { 103 + active = false 104 + } 105 + }, [resolvedUri, talks]) 60 106 61 107 useEffect(() => { 62 108 if (!resolvedUri || !videoRef.current) { ··· 232 278 } 233 279 }, [navigate]) 234 280 281 + useKeyboard((event) => { 282 + const video = videoRef.current 283 + if (!video || event.metaKey || event.ctrlKey || event.altKey) { 284 + return 285 + } 286 + 287 + const key = event.key 288 + const lower = key.toLowerCase() 289 + 290 + if (key === ' ') { 291 + event.preventDefault() 292 + if (video.paused) { 293 + void video.play() 294 + } else { 295 + video.pause() 296 + } 297 + return 298 + } 299 + 300 + if (lower === 'k') { 301 + event.preventDefault() 302 + if (video.paused) { 303 + void video.play() 304 + } else { 305 + video.pause() 306 + } 307 + return 308 + } 309 + 310 + if (lower === 'j') { 311 + event.preventDefault() 312 + video.currentTime = Math.max(0, video.currentTime - 10) 313 + return 314 + } 315 + 316 + if (lower === 'l') { 317 + event.preventDefault() 318 + const duration = Number.isFinite(video.duration) ? video.duration : video.currentTime + 10 319 + video.currentTime = Math.min(duration, video.currentTime + 10) 320 + return 321 + } 322 + 323 + if (lower === 'f') { 324 + event.preventDefault() 325 + const container = playerContainerRef.current 326 + if (!container) { 327 + return 328 + } 329 + 330 + if (document.fullscreenElement) { 331 + void document.exitFullscreen() 332 + } else { 333 + void container.requestFullscreen() 334 + } 335 + return 336 + } 337 + 338 + if (lower === 'm') { 339 + event.preventDefault() 340 + video.muted = !video.muted 341 + return 342 + } 343 + 344 + if (/^[0-9]$/.test(key)) { 345 + event.preventDefault() 346 + const pct = Number(key) / 10 347 + if (Number.isFinite(video.duration) && video.duration > 0) { 348 + video.currentTime = video.duration * pct 349 + } 350 + return 351 + } 352 + 353 + if (key === '<' || key === ',') { 354 + event.preventDefault() 355 + const nextRate = Math.max(0.25, video.playbackRate - 0.25) 356 + video.playbackRate = nextRate 357 + setPlaybackRate(nextRate) 358 + return 359 + } 360 + 361 + if (key === '>' || key === '.') { 362 + event.preventDefault() 363 + const nextRate = Math.min(4, video.playbackRate + 0.25) 364 + video.playbackRate = nextRate 365 + setPlaybackRate(nextRate) 366 + return 367 + } 368 + 369 + if (key === 'Escape') { 370 + event.preventDefault() 371 + navigate('/') 372 + } 373 + }) 374 + 235 375 if (!resolvedUri) { 236 376 return ( 237 377 <ErrorPanel ··· 242 382 ) 243 383 } 244 384 245 - if (!talk && talksLoading) { 385 + if (!talk && (talksLoading || metadataLoading)) { 246 386 return ( 247 387 <section className="rounded-lg border border-line/45 bg-surface/80 p-5"> 248 388 <p className="text-sm text-muted">Loading video metadata...</p> ··· 338 478 339 479 {talk?.sourceRef ? ( 340 480 <a 341 - href={getArchiveBlobUrl(talk.sourceRef)} 481 + href={getArchiveBlobUrl(talk.sourceRepoDid, talk.sourceRef)} 342 482 download={`${talk.title}.mp4`} 343 483 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" 344 484 >
+28 -3
vite.config.ts
··· 16 16 registerType: 'autoUpdate', 17 17 includeAssets: ['favicon.svg'], 18 18 manifest: { 19 - name: 'Atmosphere VODs', 20 - short_name: 'Atmosphere VODs', 19 + name: 'Streamplace VOD Client', 20 + short_name: 'Streamplace VOD', 21 21 description: 22 - 'A minimalist glassy video browser for ATmosphereConf 2026 talks.', 22 + 'A minimalist glassy video browser for Streamplace and AtmosphereConf VODs.', 23 23 theme_color: '#000000', 24 24 background_color: '#000000', 25 25 display: 'standalone', ··· 50 50 }, 51 51 { 52 52 urlPattern: 53 + /^https:\/\/bsky\.network\/xrpc\/com\.atproto\.sync\.listReposByCollection.*/i, 54 + handler: 'NetworkFirst', 55 + options: { 56 + cacheName: 'relay-collection-discovery', 57 + networkTimeoutSeconds: 8, 58 + expiration: { 59 + maxEntries: 16, 60 + maxAgeSeconds: 60 * 15, 61 + }, 62 + }, 63 + }, 64 + { 65 + urlPattern: 53 66 /^https:\/\/[^/]+\/xrpc\/com\.atproto\.repo\.listRecords.*/i, 54 67 handler: 'NetworkFirst', 55 68 options: { ··· 84 97 expiration: { 85 98 maxEntries: 48, 86 99 maxAgeSeconds: 60 * 60, 100 + }, 101 + }, 102 + }, 103 + { 104 + urlPattern: /^\/api\/search.*/i, 105 + handler: 'NetworkFirst', 106 + options: { 107 + cacheName: 'semantic-search', 108 + networkTimeoutSeconds: 5, 109 + expiration: { 110 + maxEntries: 40, 111 + maxAgeSeconds: 60 * 5, 87 112 }, 88 113 }, 89 114 },