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: add AI taxonomy search and tag routes (AI-assisted)

j4ckxyz afead88e befacc57

+2672 -20
+15 -2
README.md
··· 7 7 8 8 - React + Vite + TypeScript app with Tailwind + shadcn-style UI primitives 9 9 - PDS-aware data fetching: resolves the repo DID in PLC directory, then fetches records from that PDS 10 - - HLS playback via `hls.js` with custom controls and mobile swipe-down dismiss 11 - - Search by talk title with instant client-side filtering 10 + - 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 12 12 - Mobile-first navigation: bottom tabs on mobile, sidebar on desktop 13 13 - PWA setup with `vite-plugin-pwa` and Workbox runtime caching 14 14 ··· 24 24 ```bash 25 25 npm run build 26 26 ``` 27 + 28 + ## Generate AI taxonomy (one-time or refresh) 29 + 30 + The app can enrich talks with tags/topics generated through OpenRouter. 31 + 32 + 1. Put your key in `.env` as `OPENROUTER_API_KEY=...`. 33 + 2. Run: 34 + 35 + ```bash 36 + npm run taxonomy:generate 37 + ``` 38 + 39 + This updates `src/lib/video-taxonomy.json`, which is bundled into the app and used by search + tag routes. 27 40 28 41 ## Deploy to Vercel 29 42
+9
eslint.config.js
··· 8 8 export default defineConfig([ 9 9 globalIgnores(['dist']), 10 10 { 11 + files: ['scripts/**/*.mjs'], 12 + extends: [js.configs.recommended], 13 + languageOptions: { 14 + ecmaVersion: 2023, 15 + sourceType: 'module', 16 + globals: globals.node, 17 + }, 18 + }, 19 + { 11 20 files: ['**/*.{ts,tsx}'], 12 21 extends: [ 13 22 js.configs.recommended,
+2 -1
package.json
··· 7 7 "dev": "vite", 8 8 "build": "tsc -b && vite build", 9 9 "lint": "eslint .", 10 - "preview": "vite preview" 10 + "preview": "vite preview", 11 + "taxonomy:generate": "node ./scripts/generate-video-taxonomy.mjs" 11 12 }, 12 13 "dependencies": { 13 14 "@radix-ui/react-slot": "^1.2.4",
+382
scripts/generate-video-taxonomy.mjs
··· 1 + import { readFile, writeFile } from 'node:fs/promises' 2 + import { existsSync } from 'node:fs' 3 + import path from 'node:path' 4 + 5 + const REPO_DID = 'did:plc:rbvrr34edl5ddpuwcubjiost' 6 + const PLC_DIRECTORY_URL = 'https://plc.directory' 7 + const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1' 8 + const CATEGORIZER_MODEL = process.env.OPENROUTER_TAXONOMY_MODEL ?? 'google/gemini-3.1-flash-lite-preview' 9 + const EMBEDDING_MODEL = process.env.OPENROUTER_EMBEDDING_MODEL ?? 'openai/text-embedding-3-small' 10 + const OUTPUT_PATH = path.resolve(process.cwd(), 'src/lib/video-taxonomy.json') 11 + 12 + const STOPWORDS = new Set([ 13 + 'the', 'and', 'for', 'with', 'from', 'that', 'this', 'your', 'into', 'about', 'what', 'when', 14 + 'where', 'have', 'will', 'just', 'talk', 'video', 'stream', 'conference', 'atmosphere', 'place', 15 + 'vod', 'beta', '2026', 'how', 'why', 'can', 'you', 'all', 'are', 'its', 'our', 'new', 'more', 16 + 'using', 'use', 'intro', 'introduction', 'deep', 'dive', 17 + ]) 18 + 19 + function parseEnvFile(content) { 20 + const vars = {} 21 + 22 + for (const rawLine of content.split(/\r?\n/)) { 23 + const line = rawLine.trim() 24 + if (!line || line.startsWith('#')) { 25 + continue 26 + } 27 + 28 + const idx = line.indexOf('=') 29 + if (idx <= 0) { 30 + continue 31 + } 32 + 33 + const key = line.slice(0, idx).trim() 34 + let value = line.slice(idx + 1).trim() 35 + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { 36 + value = value.slice(1, -1) 37 + } 38 + 39 + vars[key] = value 40 + } 41 + 42 + return vars 43 + } 44 + 45 + async function loadEnv() { 46 + const candidatePaths = [ 47 + path.resolve(process.cwd(), '.env'), 48 + path.resolve(process.cwd(), '..', '.env'), 49 + ] 50 + 51 + for (const filePath of candidatePaths) { 52 + if (!existsSync(filePath)) { 53 + continue 54 + } 55 + 56 + const content = await readFile(filePath, 'utf8') 57 + const parsed = parseEnvFile(content) 58 + 59 + for (const [key, value] of Object.entries(parsed)) { 60 + if (!process.env[key]) { 61 + process.env[key] = value 62 + } 63 + } 64 + } 65 + } 66 + 67 + function normalizeToken(value) { 68 + return value 69 + .toLowerCase() 70 + .replace(/&/g, ' and ') 71 + .replace(/[^a-z0-9\s-]+/g, ' ') 72 + .replace(/\s+/g, '-') 73 + .replace(/-+/g, '-') 74 + .replace(/^-|-$/g, '') 75 + } 76 + 77 + function uniqueTokens(values, max = 4) { 78 + const output = [] 79 + const seen = new Set() 80 + 81 + for (const raw of values) { 82 + const token = normalizeToken(raw) 83 + if (!token || seen.has(token)) { 84 + continue 85 + } 86 + 87 + seen.add(token) 88 + output.push(token) 89 + if (output.length >= max) { 90 + break 91 + } 92 + } 93 + 94 + return output 95 + } 96 + 97 + function extractJsonObject(text) { 98 + const fencedMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/i) 99 + const source = fencedMatch ? fencedMatch[1] : text 100 + const start = source.indexOf('{') 101 + const end = source.lastIndexOf('}') 102 + 103 + if (start < 0 || end < 0 || end <= start) { 104 + throw new Error('No JSON object found in model response') 105 + } 106 + 107 + return JSON.parse(source.slice(start, end + 1)) 108 + } 109 + 110 + async function fetchJson(url) { 111 + const response = await fetch(url) 112 + if (!response.ok) { 113 + throw new Error(`Request failed (${response.status}) for ${url}`) 114 + } 115 + return response.json() 116 + } 117 + 118 + async function resolvePdsUrl() { 119 + const didDoc = await fetchJson(`${PLC_DIRECTORY_URL}/${REPO_DID}`) 120 + const pdsService = didDoc.service?.find((entry) => entry.id === '#atproto_pds') 121 + 122 + if (!pdsService?.serviceEndpoint) { 123 + throw new Error('Could not resolve PDS endpoint from PLC DID document') 124 + } 125 + 126 + return pdsService.serviceEndpoint.replace(/\/$/, '') 127 + } 128 + 129 + async function fetchAllTalkRecords() { 130 + const pdsUrl = await resolvePdsUrl() 131 + const records = [] 132 + let cursor = undefined 133 + 134 + do { 135 + const query = new URLSearchParams({ 136 + repo: REPO_DID, 137 + collection: 'place.stream.video', 138 + limit: '100', 139 + }) 140 + if (cursor) { 141 + query.set('cursor', cursor) 142 + } 143 + 144 + const page = await fetchJson(`${pdsUrl}/xrpc/com.atproto.repo.listRecords?${query.toString()}`) 145 + records.push(...(page.records ?? [])) 146 + cursor = page.cursor 147 + } while (cursor) 148 + 149 + return records.map((record) => ({ 150 + uri: record.uri, 151 + title: record.value?.title ?? 'Untitled', 152 + description: record.value?.description, 153 + createdAt: record.value?.createdAt, 154 + })) 155 + } 156 + 157 + async function callOpenRouter(pathname, body) { 158 + const apiKey = process.env.OPENROUTER_API_KEY 159 + if (!apiKey) { 160 + throw new Error('OPENROUTER_API_KEY is missing') 161 + } 162 + 163 + const response = await fetch(`${OPENROUTER_API_URL}${pathname}`, { 164 + method: 'POST', 165 + headers: { 166 + Authorization: `Bearer ${apiKey}`, 167 + 'Content-Type': 'application/json', 168 + 'HTTP-Referer': 'https://atmovods.j4ck.xyz', 169 + 'X-OpenRouter-Title': 'Atmosphere VODs taxonomy generation', 170 + }, 171 + body: JSON.stringify(body), 172 + }) 173 + 174 + if (!response.ok) { 175 + const errorText = await response.text() 176 + throw new Error(`OpenRouter request failed (${response.status}): ${errorText}`) 177 + } 178 + 179 + return response.json() 180 + } 181 + 182 + async function buildEmbeddingVectors(talks) { 183 + const input = talks.map((talk) => [talk.title, talk.description].filter(Boolean).join('\n\n')) 184 + const response = await callOpenRouter('/embeddings', { 185 + model: EMBEDDING_MODEL, 186 + input, 187 + input_type: 'search_document', 188 + }) 189 + 190 + return response.data.map((entry) => entry.embedding) 191 + } 192 + 193 + function cosineSimilarity(a, b) { 194 + let dot = 0 195 + let magA = 0 196 + let magB = 0 197 + 198 + for (let i = 0; i < a.length; i += 1) { 199 + dot += a[i] * b[i] 200 + magA += a[i] * a[i] 201 + magB += b[i] * b[i] 202 + } 203 + 204 + if (magA === 0 || magB === 0) { 205 + return 0 206 + } 207 + 208 + return dot / (Math.sqrt(magA) * Math.sqrt(magB)) 209 + } 210 + 211 + function titleKeywords(title) { 212 + return (title.toLowerCase().match(/[a-z0-9][a-z0-9-]{2,}/g) ?? []) 213 + .map((word) => normalizeToken(word)) 214 + .filter((word) => word && !STOPWORDS.has(word)) 215 + } 216 + 217 + function enrichWithEmbeddingNeighbors(entriesByUri, talks, embeddings) { 218 + for (let i = 0; i < talks.length; i += 1) { 219 + const talk = talks[i] 220 + const entry = entriesByUri.get(talk.uri) 221 + if (!entry) { 222 + continue 223 + } 224 + 225 + const scores = [] 226 + for (let j = 0; j < talks.length; j += 1) { 227 + if (i === j) { 228 + continue 229 + } 230 + 231 + scores.push({ 232 + index: j, 233 + score: cosineSimilarity(embeddings[i], embeddings[j]), 234 + }) 235 + } 236 + 237 + scores.sort((a, b) => b.score - a.score) 238 + const neighbors = scores.slice(0, 3) 239 + const keywordCounts = new Map() 240 + 241 + for (const neighbor of neighbors) { 242 + for (const keyword of titleKeywords(talks[neighbor.index].title)) { 243 + keywordCounts.set(keyword, (keywordCounts.get(keyword) ?? 0) + 1) 244 + } 245 + } 246 + 247 + const neighborKeywords = [...keywordCounts.entries()] 248 + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) 249 + .map(([word]) => word) 250 + .slice(0, 2) 251 + 252 + entry.keywords = uniqueTokens([...(entry.keywords ?? []), ...neighborKeywords], 6) 253 + } 254 + } 255 + 256 + function fallbackEntry(uri, title) { 257 + const tokens = titleKeywords(title) 258 + return { 259 + uri, 260 + group: 'general', 261 + tags: uniqueTokens(tokens.slice(0, 2), 3), 262 + topics: uniqueTokens(tokens.slice(0, 1), 2), 263 + keywords: uniqueTokens(tokens.slice(0, 4), 6), 264 + } 265 + } 266 + 267 + async function categorizeTalks(talks) { 268 + const compactInput = talks.map((talk) => ({ 269 + uri: talk.uri, 270 + title: talk.title, 271 + description: talk.description ?? null, 272 + })) 273 + 274 + const response = await callOpenRouter('/chat/completions', { 275 + model: CATEGORIZER_MODEL, 276 + temperature: 0.2, 277 + max_tokens: 12_000, 278 + messages: [ 279 + { 280 + role: 'system', 281 + content: 282 + 'You classify conference talk metadata into a compact, useful taxonomy. Use lowercase kebab-case tokens only. Keep tags and topics broad and reusable. Output valid JSON only.', 283 + }, 284 + { 285 + role: 'user', 286 + content: `Classify each talk. Constraints:\n- 8-14 groups total across all talks\n- each talk: group (1), tags (2-4), topics (1-2), keywords (2-5)\n- tags/topics must be concise and reusable\n- if description is null, classify from title only\n\nTalks JSON:\n${JSON.stringify(compactInput)}`, 287 + }, 288 + ], 289 + response_format: { 290 + type: 'json_schema', 291 + json_schema: { 292 + name: 'video_taxonomy', 293 + strict: true, 294 + schema: { 295 + type: 'object', 296 + properties: { 297 + entries: { 298 + type: 'array', 299 + items: { 300 + type: 'object', 301 + properties: { 302 + uri: { type: 'string' }, 303 + group: { type: 'string' }, 304 + tags: { type: 'array', items: { type: 'string' } }, 305 + topics: { type: 'array', items: { type: 'string' } }, 306 + keywords: { type: 'array', items: { type: 'string' } }, 307 + }, 308 + required: ['uri', 'group', 'tags', 'topics', 'keywords'], 309 + additionalProperties: false, 310 + }, 311 + }, 312 + }, 313 + required: ['entries'], 314 + additionalProperties: false, 315 + }, 316 + }, 317 + }, 318 + }) 319 + 320 + const content = response.choices?.[0]?.message?.content 321 + if (typeof content !== 'string') { 322 + throw new Error('OpenRouter categorizer returned empty content') 323 + } 324 + 325 + const parsed = extractJsonObject(content) 326 + return parsed.entries 327 + } 328 + 329 + async function main() { 330 + await loadEnv() 331 + 332 + const talks = await fetchAllTalkRecords() 333 + talks.sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime()) 334 + 335 + if (talks.length === 0) { 336 + throw new Error('No talks found to categorize') 337 + } 338 + 339 + console.log(`Fetched ${talks.length} talks`) 340 + 341 + const embeddings = await buildEmbeddingVectors(talks) 342 + console.log(`Generated ${embeddings.length} embeddings with ${EMBEDDING_MODEL}`) 343 + 344 + const llmEntries = await categorizeTalks(talks) 345 + const entriesByUri = new Map() 346 + 347 + for (const entry of llmEntries) { 348 + entriesByUri.set(entry.uri, { 349 + uri: entry.uri, 350 + group: normalizeToken(entry.group) || 'general', 351 + tags: uniqueTokens(entry.tags ?? [], 4), 352 + topics: uniqueTokens(entry.topics ?? [], 3), 353 + keywords: uniqueTokens(entry.keywords ?? [], 6), 354 + }) 355 + } 356 + 357 + for (const talk of talks) { 358 + if (!entriesByUri.has(talk.uri)) { 359 + entriesByUri.set(talk.uri, fallbackEntry(talk.uri, talk.title)) 360 + } 361 + } 362 + 363 + enrichWithEmbeddingNeighbors(entriesByUri, talks, embeddings) 364 + 365 + const output = { 366 + version: 1, 367 + generatedAt: new Date().toISOString(), 368 + models: { 369 + categorizer: CATEGORIZER_MODEL, 370 + embeddings: EMBEDDING_MODEL, 371 + }, 372 + entries: talks.map((talk) => entriesByUri.get(talk.uri)), 373 + } 374 + 375 + await writeFile(OUTPUT_PATH, `${JSON.stringify(output, null, 2)}\n`, 'utf8') 376 + console.log(`Wrote taxonomy to ${OUTPUT_PATH}`) 377 + } 378 + 379 + main().catch((error) => { 380 + console.error(error) 381 + process.exit(1) 382 + })
+2
src/App.tsx
··· 7 7 const SearchPage = lazy(() => import('@/pages/search-page').then((module) => ({ default: module.SearchPage }))) 8 8 const AboutPage = lazy(() => import('@/pages/about-page').then((module) => ({ default: module.AboutPage }))) 9 9 const VideoPage = lazy(() => import('@/pages/video-page').then((module) => ({ default: module.VideoPage }))) 10 + const TagPage = lazy(() => import('@/pages/tag-page').then((module) => ({ default: module.TagPage }))) 10 11 11 12 function RouteFallback() { 12 13 return ( ··· 25 26 <Route path="/search" element={<SearchPage />} /> 26 27 <Route path="/about" element={<AboutPage />} /> 27 28 <Route path="/video/:encodedUri" element={<VideoPage />} /> 29 + <Route path="/tag/:tagParam" element={<TagPage />} /> 28 30 <Route path="*" element={<Navigate to="/" replace />} /> 29 31 </Routes> 30 32 </Suspense>
+18
src/lib/api.ts
··· 5 5 VOD_PLAYLIST_ENDPOINT, 6 6 } from './constants' 7 7 import { truncateDid } from './format' 8 + import taxonomyData from './video-taxonomy.json' 8 9 import type { 9 10 ActorProfile, 10 11 AppTalk, ··· 12 13 PlcDidDocument, 13 14 } from './types' 14 15 16 + interface TaxonomyEntry { 17 + uri: string 18 + group?: string 19 + tags?: string[] 20 + topics?: string[] 21 + keywords?: string[] 22 + } 23 + 15 24 const profileCache = new Map<string, Promise<ActorProfile | null>>() 16 25 let pdsUrlPromise: Promise<string> | null = null 17 26 const REQUEST_TIMEOUT_MS = 8_000 18 27 const PROFILE_CONCURRENCY = 6 28 + const taxonomyByUri = new Map( 29 + (taxonomyData.entries as TaxonomyEntry[]).map((entry) => [entry.uri, entry]), 30 + ) 19 31 20 32 async function fetchWithTimeout( 21 33 url: string, ··· 150 162 const profileMap = new Map(uniqueCreators.map((did, index) => [did, profiles[index]])) 151 163 152 164 return sorted.map((record) => { 165 + const taxonomy = taxonomyByUri.get(record.uri) 153 166 const profile = profileMap.get(record.value.creator) 154 167 const creatorName = profile?.displayName?.trim() || profile?.handle || truncateDid(record.value.creator) 155 168 ··· 157 170 uri: record.uri, 158 171 cid: record.cid, 159 172 title: record.value.title, 173 + description: record.value.description, 160 174 creatorDid: record.value.creator, 161 175 creatorName, 162 176 creatorHandle: profile?.handle, ··· 164 178 createdAt: record.value.createdAt, 165 179 sourceRef: record.value.source?.ref, 166 180 sourceMimeType: record.value.source?.mimeType, 181 + taxonomyGroup: taxonomy?.group, 182 + taxonomyTags: taxonomy?.tags ?? [], 183 + taxonomyTopics: taxonomy?.topics ?? [], 184 + taxonomyKeywords: taxonomy?.keywords ?? [], 167 185 } 168 186 }) 169 187 }
+38 -5
src/lib/routes.ts
··· 1 + function toBase64Url(value: string): string { 2 + const bytes = new TextEncoder().encode(value) 3 + let binary = '' 4 + 5 + for (const byte of bytes) { 6 + binary += String.fromCharCode(byte) 7 + } 8 + 9 + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '') 10 + } 11 + 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 + } 18 + 1 19 export function toVideoPath(uri: string): string { 2 - return `/video/${encodeURIComponent(uri)}` 20 + return `/video/${toBase64Url(uri)}` 3 21 } 4 22 5 23 export function fromVideoParam(param: string): string | undefined { 6 24 try { 7 - const decoded = decodeURIComponent(param) 8 - if (!decoded.startsWith('at://')) { 9 - return undefined 25 + const maybeLegacy = decodeURIComponent(param) 26 + if (maybeLegacy.startsWith('at://')) { 27 + return maybeLegacy 10 28 } 11 - return decoded 29 + 30 + const decoded = fromBase64Url(param) 31 + return decoded.startsWith('at://') ? decoded : undefined 32 + } catch { 33 + return undefined 34 + } 35 + } 36 + 37 + export function toTagPath(tag: string): string { 38 + return `/tag/${encodeURIComponent(tag)}` 39 + } 40 + 41 + export function fromTagParam(param: string): string | undefined { 42 + try { 43 + const decoded = decodeURIComponent(param).trim() 44 + return decoded ? decoded : undefined 12 45 } catch { 13 46 return undefined 14 47 }
+101
src/lib/taxonomy.ts
··· 1 + import type { AppTalk } from './types' 2 + 3 + export function normalizeSearchValue(value: string): string { 4 + return value.trim().toLowerCase() 5 + } 6 + 7 + function unique(values: Array<string | undefined>): string[] { 8 + const seen = new Set<string>() 9 + const output: string[] = [] 10 + 11 + for (const value of values) { 12 + if (!value) { 13 + continue 14 + } 15 + 16 + const normalized = normalizeSearchValue(value) 17 + if (!normalized || seen.has(normalized)) { 18 + continue 19 + } 20 + 21 + seen.add(normalized) 22 + output.push(normalized) 23 + } 24 + 25 + return output 26 + } 27 + 28 + export function getTalkTaxonomyTokens(talk: AppTalk): string[] { 29 + return unique([ 30 + talk.taxonomyGroup, 31 + ...(talk.taxonomyTags ?? []), 32 + ...(talk.taxonomyTopics ?? []), 33 + ...(talk.taxonomyKeywords ?? []), 34 + ]) 35 + } 36 + 37 + export function matchesTagRoute(talk: AppTalk, tag: string): boolean { 38 + const needle = normalizeSearchValue(tag) 39 + if (!needle) { 40 + return false 41 + } 42 + 43 + return getTalkTaxonomyTokens(talk).some((token) => token === needle) 44 + } 45 + 46 + export function scoreTalkForQuery(talk: AppTalk, query: string): number { 47 + const normalizedQuery = normalizeSearchValue(query) 48 + if (!normalizedQuery) { 49 + return 1 50 + } 51 + 52 + const terms = normalizedQuery.split(/\s+/).filter(Boolean) 53 + if (terms.length === 0) { 54 + return 1 55 + } 56 + 57 + const title = normalizeSearchValue(talk.title) 58 + const description = normalizeSearchValue(talk.description ?? '') 59 + const taxonomyTokens = getTalkTaxonomyTokens(talk) 60 + const taxonomyCorpus = taxonomyTokens.join(' ') 61 + 62 + let score = 0 63 + let allTermsPresent = true 64 + 65 + if (title.includes(normalizedQuery)) { 66 + score += 8 67 + } 68 + if (description.includes(normalizedQuery)) { 69 + score += 4 70 + } 71 + if (taxonomyCorpus.includes(normalizedQuery)) { 72 + score += 5 73 + } 74 + 75 + for (const term of terms) { 76 + const inTitle = title.includes(term) 77 + const inDescription = description.includes(term) 78 + const inTaxonomy = taxonomyTokens.some((token) => token.includes(term)) 79 + 80 + if (!(inTitle || inDescription || inTaxonomy)) { 81 + allTermsPresent = false 82 + continue 83 + } 84 + 85 + if (inTitle) { 86 + score += 3 87 + } 88 + if (inDescription) { 89 + score += 2 90 + } 91 + if (inTaxonomy) { 92 + score += 4 93 + } 94 + } 95 + 96 + if (!allTermsPresent) { 97 + return 0 98 + } 99 + 100 + return score 101 + }
+5 -4
src/lib/thumbnails.ts
··· 178 178 if (video.canPlayType('application/vnd.apple.mpegurl')) { 179 179 video.src = playlistUrl 180 180 } else { 181 - const { default: Hls } = await import('hls.js/light') 181 + const { default: Hls } = await import('hls.js') 182 182 if (!Hls.isSupported()) { 183 183 return null 184 184 } 185 185 186 - hls = new Hls({ 186 + const hlsInstance = new Hls({ 187 187 maxBufferLength: 10, 188 188 lowLatencyMode: true, 189 189 }) 190 - hls.loadSource(playlistUrl) 191 - hls.attachMedia(video) 190 + hlsInstance.loadSource(playlistUrl) 191 + hlsInstance.attachMedia(video) 192 + hls = hlsInstance 192 193 } 193 194 194 195 if (!(video.readyState >= 1 && Number.isFinite(video.duration) && video.duration > 0)) {
+6
src/lib/types.ts
··· 4 4 value: { 5 5 $type: 'place.stream.video' 6 6 title: string 7 + description?: string 7 8 creator: string 8 9 duration: number 9 10 createdAt: string ··· 45 46 uri: string 46 47 cid: string 47 48 title: string 49 + description?: string 48 50 creatorDid: string 49 51 creatorName: string 50 52 creatorHandle?: string ··· 52 54 createdAt: string 53 55 sourceRef?: string 54 56 sourceMimeType?: string 57 + taxonomyGroup?: string 58 + taxonomyTags?: string[] 59 + taxonomyTopics?: string[] 60 + taxonomyKeywords?: string[] 55 61 }
+1947
src/lib/video-taxonomy.json
··· 1 + { 2 + "version": 1, 3 + "generatedAt": "2026-04-13T01:00:16.084Z", 4 + "models": { 5 + "categorizer": "google/gemini-3.1-flash-lite-preview", 6 + "embeddings": "openai/text-embedding-3-small" 7 + }, 8 + "entries": [ 9 + { 10 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miieadxeqn22", 11 + "group": "conference-logistics", 12 + "tags": [ 13 + "event", 14 + "schedule" 15 + ], 16 + "topics": [ 17 + "conference-management" 18 + ], 19 + "keywords": [ 20 + "atmosphereconf", 21 + "day-2", 22 + "room-2301", 23 + "day" 24 + ] 25 + }, 26 + { 27 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miieadwqgy22", 28 + "group": "conference-logistics", 29 + "tags": [ 30 + "event", 31 + "schedule" 32 + ], 33 + "topics": [ 34 + "conference-management" 35 + ], 36 + "keywords": [ 37 + "atmosphereconf", 38 + "day-2", 39 + "performance-theater", 40 + "day" 41 + ] 42 + }, 43 + { 44 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miaef2b4mh2h", 45 + "group": "protocol-philosophy", 46 + "tags": [ 47 + "decentralization", 48 + "vision" 49 + ], 50 + "topics": [ 51 + "protocol-design" 52 + ], 53 + "keywords": [ 54 + "rewilding", 55 + "atproto", 56 + "internet", 57 + "bringing" 58 + ] 59 + }, 60 + { 61 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miaec7exqi25", 62 + "group": "community-culture", 63 + "tags": [ 64 + "creativity", 65 + "sustainability" 66 + ], 67 + "topics": [ 68 + "community-building" 69 + ], 70 + "keywords": [ 71 + "artist", 72 + "vision", 73 + "atmosphere", 74 + "affordances", 75 + "atmosphereconf" 76 + ] 77 + }, 78 + { 79 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miadvkvhfv2h", 80 + "group": "security-privacy", 81 + "tags": [ 82 + "security", 83 + "testing" 84 + ], 85 + "topics": [ 86 + "network-security" 87 + ], 88 + "keywords": [ 89 + "did-plc", 90 + "war-games", 91 + "identity", 92 + "atproto", 93 + "building" 94 + ] 95 + }, 96 + { 97 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miadd43al32c", 98 + "group": "science-research", 99 + "tags": [ 100 + "data", 101 + "publishing" 102 + ], 103 + "topics": [ 104 + "open-science" 105 + ], 106 + "keywords": [ 107 + "metadata", 108 + "scientific-data", 109 + "atproto", 110 + "atmosphereconf", 111 + "atscience" 112 + ] 113 + }, 114 + { 115 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miadb23fme2a", 116 + "group": "interoperability", 117 + "tags": [ 118 + "podcasts", 119 + "integration" 120 + ], 121 + "topics": [ 122 + "ecosystem-growth" 123 + ], 124 + "keywords": [ 125 + "interoperability", 126 + "atmosphere", 127 + "media", 128 + "affordances", 129 + "astronomy" 130 + ] 131 + }, 132 + { 133 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miacona6fc2e", 134 + "group": "security-privacy", 135 + "tags": [ 136 + "transparency", 137 + "accountability" 138 + ], 139 + "topics": [ 140 + "data-integrity" 141 + ], 142 + "keywords": [ 143 + "logs", 144 + "records", 145 + "collections", 146 + "atproto", 147 + "account" 148 + ] 149 + }, 150 + { 151 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miacmwnd2z2z", 152 + "group": "developer-tools", 153 + "tags": [ 154 + "rust", 155 + "development" 156 + ], 157 + "topics": [ 158 + "developer-experience" 159 + ], 160 + "keywords": [ 161 + "jacquard", 162 + "atproto", 163 + "sdk", 164 + "bringing" 165 + ] 166 + }, 167 + { 168 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miacmdq4sm2a", 169 + "group": "developer-tools", 170 + "tags": [ 171 + "product", 172 + "security" 173 + ], 174 + "topics": [ 175 + "software-lifecycle" 176 + ], 177 + "keywords": [ 178 + "bluesky", 179 + "preview", 180 + "deployment", 181 + "centralized" 182 + ] 183 + }, 184 + { 185 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miac5ez6a22r", 186 + "group": "user-experience", 187 + "tags": [ 188 + "client", 189 + "curation" 190 + ], 191 + "topics": [ 192 + "interface-design" 193 + ], 194 + "keywords": [ 195 + "skylimit", 196 + "web-client", 197 + "newspaper", 198 + "ai-saturated", 199 + "blacksky" 200 + ] 201 + }, 202 + { 203 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miac3urhgt23", 204 + "group": "developer-tools", 205 + "tags": [ 206 + "graphql", 207 + "api" 208 + ], 209 + "topics": [ 210 + "data-querying" 211 + ], 212 + "keywords": [ 213 + "atproto", 214 + "development", 215 + "applications" 216 + ] 217 + }, 218 + { 219 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miac22soys22", 220 + "group": "protocol-philosophy", 221 + "tags": [ 222 + "design", 223 + "theory" 224 + ], 225 + "topics": [ 226 + "protocol-design" 227 + ], 228 + "keywords": [ 229 + "affordances", 230 + "atmosphere", 231 + "media", 232 + "report" 233 + ] 234 + }, 235 + { 236 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miabh2g67c2c", 237 + "group": "infrastructure", 238 + "tags": [ 239 + "scaling", 240 + "performance" 241 + ], 242 + "topics": [ 243 + "system-architecture" 244 + ], 245 + "keywords": [ 246 + "atmosphere", 247 + "load", 248 + "affordances", 249 + "media" 250 + ] 251 + }, 252 + { 253 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miabfvrtei26", 254 + "group": "infrastructure", 255 + "tags": [ 256 + "abstraction", 257 + "architecture" 258 + ], 259 + "topics": [ 260 + "system-design" 261 + ], 262 + "keywords": [ 263 + "appview", 264 + "atproto", 265 + "viewsift", 266 + "blousques" 267 + ] 268 + }, 269 + { 270 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mia7iun75l2x", 271 + "group": "artificial-intelligence", 272 + "tags": [ 273 + "search", 274 + "algorithms" 275 + ], 276 + "topics": [ 277 + "information-retrieval" 278 + ], 279 + "keywords": [ 280 + "keywords", 281 + "embeddings", 282 + "data", 283 + "feeds", 284 + "accidentally" 285 + ] 286 + }, 287 + { 288 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mia7dt6fnp2m", 289 + "group": "protocol-philosophy", 290 + "tags": [ 291 + "design", 292 + "philosophy" 293 + ], 294 + "topics": [ 295 + "protocol-design" 296 + ], 297 + "keywords": [ 298 + "atproto", 299 + "bookhive", 300 + "chive" 301 + ] 302 + }, 303 + { 304 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mia74qg3622a", 305 + "group": "interoperability", 306 + "tags": [ 307 + "bridging", 308 + "connectivity" 309 + ], 310 + "topics": [ 311 + "ecosystem-growth" 312 + ], 313 + "keywords": [ 314 + "bridgy", 315 + "federation", 316 + "bluesky", 317 + "infrastructure" 318 + ] 319 + }, 320 + { 321 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mia5q5vu4q2h", 322 + "group": "user-experience", 323 + "tags": [ 324 + "showcase", 325 + "web" 326 + ], 327 + "topics": [ 328 + "interface-design" 329 + ], 330 + "keywords": [ 331 + "webtiles", 332 + "demo", 333 + "skysquare", 334 + "app" 335 + ] 336 + }, 337 + { 338 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mia5mbp4tf22", 339 + "group": "community-culture", 340 + "tags": [ 341 + "social", 342 + "future" 343 + ], 344 + "topics": [ 345 + "social-media-trends" 346 + ], 347 + "keywords": [ 348 + "weird", 349 + "social-media", 350 + "social", 351 + "future" 352 + ] 353 + }, 354 + { 355 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mia5khdija2h", 356 + "group": "protocol-philosophy", 357 + "tags": [ 358 + "governance", 359 + "decentralization" 360 + ], 361 + "topics": [ 362 + "protocol-design" 363 + ], 364 + "keywords": [ 365 + "governance", 366 + "hard-decentralization", 367 + "decentralized", 368 + "sovereignty" 369 + ] 370 + }, 371 + { 372 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mia3vqrg4r23", 373 + "group": "infrastructure", 374 + "tags": [ 375 + "gaming", 376 + "sovereignty" 377 + ], 378 + "topics": [ 379 + "decentralized-infrastructure" 380 + ], 381 + "keywords": [ 382 + "data-sovereignty", 383 + "atproto", 384 + "industry", 385 + "building" 386 + ] 387 + }, 388 + { 389 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mia3vm5oyd2u", 390 + "group": "security-privacy", 391 + "tags": [ 392 + "trust-and-safety", 393 + "open-source" 394 + ], 395 + "topics": [ 396 + "community-safety" 397 + ], 398 + "keywords": [ 399 + "coop", 400 + "infrastructure", 401 + "social", 402 + "open" 403 + ] 404 + }, 405 + { 406 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mia3svkvjw22", 407 + "group": "community-culture", 408 + "tags": [ 409 + "community", 410 + "decentralization" 411 + ], 412 + "topics": [ 413 + "community-building" 414 + ], 415 + "keywords": [ 416 + "furrylist", 417 + "landlords", 418 + "atproto", 419 + "building" 420 + ] 421 + }, 422 + { 423 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mia26ffz2j2c", 424 + "group": "security-privacy", 425 + "tags": [ 426 + "moderation", 427 + "community" 428 + ], 429 + "topics": [ 430 + "community-safety" 431 + ], 432 + "keywords": [ 433 + "skywatch", 434 + "lessons", 435 + "beyond", 436 + "blacksky" 437 + ] 438 + }, 439 + { 440 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7zxhekcu2d", 441 + "group": "artificial-intelligence", 442 + "tags": [ 443 + "ai", 444 + "decentralization" 445 + ], 446 + "topics": [ 447 + "emerging-tech" 448 + ], 449 + "keywords": [ 450 + "atproto", 451 + "development", 452 + "building" 453 + ] 454 + }, 455 + { 456 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7ztge2sf2h", 457 + "group": "user-experience", 458 + "tags": [ 459 + "design", 460 + "web" 461 + ], 462 + "topics": [ 463 + "interface-design" 464 + ], 465 + "keywords": [ 466 + "social-web", 467 + "ux", 468 + "social", 469 + "future" 470 + ] 471 + }, 472 + { 473 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7tqadgv22d", 474 + "group": "developer-tools", 475 + "tags": [ 476 + "components", 477 + "ui" 478 + ], 479 + "topics": [ 480 + "interface-design" 481 + ], 482 + "keywords": [ 483 + "social-components", 484 + "library", 485 + "community", 486 + "building" 487 + ] 488 + }, 489 + { 490 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7tcodsqu2t", 491 + "group": "security-privacy", 492 + "tags": [ 493 + "moderation", 494 + "safety" 495 + ], 496 + "topics": [ 497 + "community-safety" 498 + ], 499 + "keywords": [ 500 + "blacksky", 501 + "tools", 502 + "community", 503 + "beyond" 504 + ] 505 + }, 506 + { 507 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7tbwgymp25", 508 + "group": "community-culture", 509 + "tags": [ 510 + "cooperation", 511 + "strategy" 512 + ], 513 + "topics": [ 514 + "ecosystem-growth" 515 + ], 516 + "keywords": [ 517 + "competition", 518 + "success", 519 + "building", 520 + "community" 521 + ] 522 + }, 523 + { 524 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7rvqdpj722", 525 + "group": "protocol-philosophy", 526 + "tags": [ 527 + "computing", 528 + "manifesto" 529 + ], 530 + "topics": [ 531 + "future-tech" 532 + ], 533 + "keywords": [ 534 + "resonant-computing", 535 + "fireside", 536 + "artist", 537 + "atproto" 538 + ] 539 + }, 540 + { 541 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7rpxijg725", 542 + "group": "security-privacy", 543 + "tags": [ 544 + "privacy", 545 + "community" 546 + ], 547 + "topics": [ 548 + "data-protection" 549 + ], 550 + "keywords": [ 551 + "decentralized-network", 552 + "privacy", 553 + "atproto", 554 + "building" 555 + ] 556 + }, 557 + { 558 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7q5mjlbe2c", 559 + "group": "user-experience", 560 + "tags": [ 561 + "localization", 562 + "ui" 563 + ], 564 + "topics": [ 565 + "accessibility" 566 + ], 567 + "keywords": [ 568 + "blousques", 569 + "bluesky", 570 + "translation", 571 + "beyond" 572 + ] 573 + }, 574 + { 575 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7pxl6xkz22", 576 + "group": "community-culture", 577 + "tags": [ 578 + "moderation", 579 + "community" 580 + ], 581 + "topics": [ 582 + "community-safety" 583 + ], 584 + "keywords": [ 585 + "bluenotes", 586 + "atproto", 587 + "business" 588 + ] 589 + }, 590 + { 591 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7pjfdkhv2b", 592 + "group": "protocol-philosophy", 593 + "tags": [ 594 + "future", 595 + "vision" 596 + ], 597 + "topics": [ 598 + "future-tech" 599 + ], 600 + "keywords": [ 601 + "future", 602 + "loading", 603 + "social" 604 + ] 605 + }, 606 + { 607 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7ok5zmuk2k", 608 + "group": "community-culture", 609 + "tags": [ 610 + "accessibility", 611 + "globalization" 612 + ], 613 + "topics": [ 614 + "community-building" 615 + ], 616 + "keywords": [ 617 + "non-english", 618 + "users", 619 + "social", 620 + "000" 621 + ] 622 + }, 623 + { 624 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7ofq7ob72a", 625 + "group": "community-culture", 626 + "tags": [ 627 + "funding", 628 + "growth" 629 + ], 630 + "topics": [ 631 + "ecosystem-growth" 632 + ], 633 + "keywords": [ 634 + "graze", 635 + "lessons", 636 + "social", 637 + "000" 638 + ] 639 + }, 640 + { 641 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7o5amqqx2d", 642 + "group": "security-privacy", 643 + "tags": [ 644 + "identity", 645 + "sovereignty" 646 + ], 647 + "topics": [ 648 + "data-protection" 649 + ], 650 + "keywords": [ 651 + "ssi", 652 + "atproto", 653 + "crowdsourced" 654 + ] 655 + }, 656 + { 657 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7mb4jbag23", 658 + "group": "infrastructure", 659 + "tags": [ 660 + "tangled", 661 + "development" 662 + ], 663 + "topics": [ 664 + "system-architecture" 665 + ], 666 + "keywords": [ 667 + "lewis-end", 668 + "infrastructure", 669 + "blaine", 670 + "everything" 671 + ] 672 + }, 673 + { 674 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7ldfbjwe26", 675 + "group": "community-culture", 676 + "tags": [ 677 + "journalism", 678 + "media" 679 + ], 680 + "topics": [ 681 + "ecosystem-growth" 682 + ], 683 + "keywords": [ 684 + "news", 685 + "atproto", 686 + "building", 687 + "protocol" 688 + ] 689 + }, 690 + { 691 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7lapdbjc2t", 692 + "group": "community-culture", 693 + "tags": [ 694 + "organizing", 695 + "system-change" 696 + ], 697 + "topics": [ 698 + "community-building" 699 + ], 700 + "keywords": [ 701 + "roomy", 702 + "activism", 703 + "building", 704 + "community" 705 + ] 706 + }, 707 + { 708 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7kaogqjs22", 709 + "group": "developer-tools", 710 + "tags": [ 711 + "browser", 712 + "npm" 713 + ], 714 + "topics": [ 715 + "developer-experience" 716 + ], 717 + "keywords": [ 718 + "npmx", 719 + "tooling", 720 + "applications", 721 + "architecture" 722 + ] 723 + }, 724 + { 725 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7jorroaa2h", 726 + "group": "conference-logistics", 727 + "tags": [ 728 + "event", 729 + "opening" 730 + ], 731 + "topics": [ 732 + "conference-management" 733 + ], 734 + "keywords": [ 735 + "day-2", 736 + "remarks", 737 + "day", 738 + "atmosphereconf" 739 + ] 740 + }, 741 + { 742 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi7jhwzagl2n", 743 + "group": "conference-logistics", 744 + "tags": [ 745 + "event", 746 + "opening" 747 + ], 748 + "topics": [ 749 + "conference-management" 750 + ], 751 + "keywords": [ 752 + "day-2", 753 + "remarks", 754 + "day", 755 + "atmosphereconf" 756 + ] 757 + }, 758 + { 759 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miieadx2dj22", 760 + "group": "conference-logistics", 761 + "tags": [ 762 + "event", 763 + "schedule" 764 + ], 765 + "topics": [ 766 + "conference-management" 767 + ], 768 + "keywords": [ 769 + "atmosphereconf", 770 + "day-1", 771 + "room-2301", 772 + "day" 773 + ] 774 + }, 775 + { 776 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miieadwgvz22", 777 + "group": "conference-logistics", 778 + "tags": [ 779 + "event", 780 + "schedule" 781 + ], 782 + "topics": [ 783 + "conference-management" 784 + ], 785 + "keywords": [ 786 + "atmosphereconf", 787 + "day-1", 788 + "performance-theater", 789 + "day" 790 + ] 791 + }, 792 + { 793 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miighlz53o22", 794 + "group": "conference-logistics", 795 + "tags": [ 796 + "event", 797 + "schedule" 798 + ], 799 + "topics": [ 800 + "conference-management" 801 + ], 802 + "keywords": [ 803 + "atmosphereconf", 804 + "day-2", 805 + "great-hall", 806 + "day" 807 + ] 808 + }, 809 + { 810 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5wnpkchp2z", 811 + "group": "conference-logistics", 812 + "tags": [ 813 + "event", 814 + "bonus" 815 + ], 816 + "topics": [ 817 + "conference-management" 818 + ], 819 + "keywords": [ 820 + "bonus", 821 + "unscheduled", 822 + "blank", 823 + "creators" 824 + ] 825 + }, 826 + { 827 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5uqr2spg2l", 828 + "group": "science-research", 829 + "tags": [ 830 + "funding", 831 + "social-data" 832 + ], 833 + "topics": [ 834 + "open-science" 835 + ], 836 + "keywords": [ 837 + "hypercerts", 838 + "atproto", 839 + "ownership", 840 + "bringing" 841 + ] 842 + }, 843 + { 844 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5unzkbat27", 845 + "group": "community-culture", 846 + "tags": [ 847 + "journalism", 848 + "algorithms" 849 + ], 850 + "topics": [ 851 + "social-media-trends" 852 + ], 853 + "keywords": [ 854 + "media", 855 + "algorithms", 856 + "era", 857 + "aggregation" 858 + ] 859 + }, 860 + { 861 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5unhqisv22", 862 + "group": "conference-logistics", 863 + "tags": [ 864 + "event", 865 + "misc" 866 + ], 867 + "topics": [ 868 + "conference-management" 869 + ], 870 + "keywords": [ 871 + "blank", 872 + "placeholder", 873 + "blaine", 874 + "bonus" 875 + ] 876 + }, 877 + { 878 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5stzyxji2e", 879 + "group": "infrastructure", 880 + "tags": [ 881 + "vod", 882 + "streaming" 883 + ], 884 + "topics": [ 885 + "system-architecture" 886 + ], 887 + "keywords": [ 888 + "streamplace", 889 + "video", 890 + "atproto", 891 + "creator" 892 + ] 893 + }, 894 + { 895 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5spj6izy2a", 896 + "group": "infrastructure", 897 + "tags": [ 898 + "architecture", 899 + "design" 900 + ], 901 + "topics": [ 902 + "system-architecture" 903 + ], 904 + "keywords": [ 905 + "phoenix", 906 + "system", 907 + "atproto", 908 + "behind" 909 + ] 910 + }, 911 + { 912 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5sn7zmfy23", 913 + "group": "community-culture", 914 + "tags": [ 915 + "journalism", 916 + "freedom" 917 + ], 918 + "topics": [ 919 + "social-media-trends" 920 + ], 921 + "keywords": [ 922 + "press", 923 + "protocols", 924 + "algorithms", 925 + "atprotocol" 926 + ] 927 + }, 928 + { 929 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5s5mqmgj2r", 930 + "group": "artificial-intelligence", 931 + "tags": [ 932 + "journalism", 933 + "transparency" 934 + ], 935 + "topics": [ 936 + "community-safety" 937 + ], 938 + "keywords": [ 939 + "pollen", 940 + "ai", 941 + "feed", 942 + "algorithms", 943 + "atproto" 944 + ] 945 + }, 946 + { 947 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5s4foj7h2a", 948 + "group": "community-culture", 949 + "tags": [ 950 + "community", 951 + "calendar" 952 + ], 953 + "topics": [ 954 + "community-building" 955 + ], 956 + "keywords": [ 957 + "oaklog", 958 + "oakland", 959 + "community", 960 + "beyond" 961 + ] 962 + }, 963 + { 964 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5s2x6tkd2d", 965 + "group": "infrastructure", 966 + "tags": [ 967 + "data", 968 + "government" 969 + ], 970 + "topics": [ 971 + "decentralized-infrastructure" 972 + ], 973 + "keywords": [ 974 + "fire-service", 975 + "data-walls", 976 + "era", 977 + "aggregation" 978 + ] 979 + }, 980 + { 981 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5rnhjugb2h", 982 + "group": "security-privacy", 983 + "tags": [ 984 + "encryption", 985 + "messaging" 986 + ], 987 + "topics": [ 988 + "data-protection" 989 + ], 990 + "keywords": [ 991 + "e2ee", 992 + "solidarity", 993 + "social", 994 + "community" 995 + ] 996 + }, 997 + { 998 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5rm4y7ts2b", 999 + "group": "community-culture", 1000 + "tags": [ 1001 + "open-source", 1002 + "social" 1003 + ], 1004 + "topics": [ 1005 + "social-media-trends" 1006 + ], 1007 + "keywords": [ 1008 + "future", 1009 + "social", 1010 + "designing" 1011 + ] 1012 + }, 1013 + { 1014 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5rl4r4a725", 1015 + "group": "interoperability", 1016 + "tags": [ 1017 + "bridging", 1018 + "migration" 1019 + ], 1020 + "topics": [ 1021 + "ecosystem-growth" 1022 + ], 1023 + "keywords": [ 1024 + "sky-follower-bridge", 1025 + "bluesky", 1026 + "beyond" 1027 + ] 1028 + }, 1029 + { 1030 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5r2rp7a62e", 1031 + "group": "protocol-philosophy", 1032 + "tags": [ 1033 + "decentralization", 1034 + "analysis" 1035 + ], 1036 + "topics": [ 1037 + "protocol-design" 1038 + ], 1039 + "keywords": [ 1040 + "bluesky", 1041 + "decentralized", 1042 + "beyond" 1043 + ] 1044 + }, 1045 + { 1046 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5qzkwfe42z", 1047 + "group": "community-culture", 1048 + "tags": [ 1049 + "social", 1050 + "platform" 1051 + ], 1052 + "topics": [ 1053 + "community-building" 1054 + ], 1055 + "keywords": [ 1056 + "w-social", 1057 + "overview", 1058 + "social", 1059 + "designing" 1060 + ] 1061 + }, 1062 + { 1063 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5qywid6z25", 1064 + "group": "community-culture", 1065 + "tags": [ 1066 + "growth", 1067 + "social" 1068 + ], 1069 + "topics": [ 1070 + "ecosystem-growth" 1071 + ], 1072 + "keywords": [ 1073 + "open-social", 1074 + "users", 1075 + "social", 1076 + "app" 1077 + ] 1078 + }, 1079 + { 1080 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5q57diht27", 1081 + "group": "user-experience", 1082 + "tags": [ 1083 + "trails", 1084 + "discovery" 1085 + ], 1086 + "topics": [ 1087 + "interface-design" 1088 + ], 1089 + "keywords": [ 1090 + "semble", 1091 + "magic", 1092 + "lessons", 1093 + "atproto" 1094 + ] 1095 + }, 1096 + { 1097 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5q4ey4232y", 1098 + "group": "artificial-intelligence", 1099 + "tags": [ 1100 + "ai", 1101 + "documentation" 1102 + ], 1103 + "topics": [ 1104 + "developer-experience" 1105 + ], 1106 + "keywords": [ 1107 + "hallucination", 1108 + "atproto", 1109 + "agents", 1110 + "building" 1111 + ] 1112 + }, 1113 + { 1114 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5q3oibtu2i", 1115 + "group": "community-culture", 1116 + "tags": [ 1117 + "social", 1118 + "platform" 1119 + ], 1120 + "topics": [ 1121 + "community-building" 1122 + ], 1123 + "keywords": [ 1124 + "gander-social", 1125 + "why", 1126 + "social", 1127 + "000" 1128 + ] 1129 + }, 1130 + { 1131 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5mvmovsn2i", 1132 + "group": "conference-logistics", 1133 + "tags": [ 1134 + "report", 1135 + "summary" 1136 + ], 1137 + "topics": [ 1138 + "conference-management" 1139 + ], 1140 + "keywords": [ 1141 + "atmosphere", 1142 + "2026", 1143 + "atmosphereconf", 1144 + "atscience" 1145 + ] 1146 + }, 1147 + { 1148 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5lexqbqf2z", 1149 + "group": "infrastructure", 1150 + "tags": [ 1151 + "public-interest", 1152 + "infrastructure" 1153 + ], 1154 + "topics": [ 1155 + "decentralized-infrastructure" 1156 + ], 1157 + "keywords": [ 1158 + "atproto", 1159 + "public-good", 1160 + "build" 1161 + ] 1162 + }, 1163 + { 1164 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5ldvd46r2i", 1165 + "group": "developer-tools", 1166 + "tags": [ 1167 + "expo", 1168 + "application" 1169 + ], 1170 + "topics": [ 1171 + "developer-experience" 1172 + ], 1173 + "keywords": [ 1174 + "protocol", 1175 + "product", 1176 + "atproto", 1177 + "build" 1178 + ] 1179 + }, 1180 + { 1181 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5lb763vn2j", 1182 + "group": "security-privacy", 1183 + "tags": [ 1184 + "security", 1185 + "tee" 1186 + ], 1187 + "topics": [ 1188 + "data-protection" 1189 + ], 1190 + "keywords": [ 1191 + "account-logic", 1192 + "atproto", 1193 + "building" 1194 + ] 1195 + }, 1196 + { 1197 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5k3brfax2b", 1198 + "group": "user-experience", 1199 + "tags": [ 1200 + "client", 1201 + "user-choice" 1202 + ], 1203 + "topics": [ 1204 + "ecosystem-growth" 1205 + ], 1206 + "keywords": [ 1207 + "growth", 1208 + "atproto", 1209 + "build" 1210 + ] 1211 + }, 1212 + { 1213 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5jrjmk6y2n", 1214 + "group": "community-culture", 1215 + "tags": [ 1216 + "geopolitics", 1217 + "risk" 1218 + ], 1219 + "topics": [ 1220 + "social-media-trends" 1221 + ], 1222 + "keywords": [ 1223 + "open-social", 1224 + "geopolitical", 1225 + "open", 1226 + "social" 1227 + ] 1228 + }, 1229 + { 1230 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5jmsg6kn2m", 1231 + "group": "security-privacy", 1232 + "tags": [ 1233 + "consent", 1234 + "cryptography" 1235 + ], 1236 + "topics": [ 1237 + "data-protection" 1238 + ], 1239 + "keywords": [ 1240 + "privacy", 1241 + "ethics", 1242 + "atproto", 1243 + "account" 1244 + ] 1245 + }, 1246 + { 1247 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5itmi65s2z", 1248 + "group": "user-experience", 1249 + "tags": [ 1250 + "feeds", 1251 + "web" 1252 + ], 1253 + "topics": [ 1254 + "interface-design" 1255 + ], 1256 + "keywords": [ 1257 + "websites", 1258 + "feeds", 1259 + "era", 1260 + "aggregation" 1261 + ] 1262 + }, 1263 + { 1264 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5hza73vs2z", 1265 + "group": "security-privacy", 1266 + "tags": [ 1267 + "attestation", 1268 + "verification" 1269 + ], 1270 + "topics": [ 1271 + "data-integrity" 1272 + ], 1273 + "keywords": [ 1274 + "sattestations", 1275 + "security", 1276 + "everything", 1277 + "everywhere" 1278 + ] 1279 + }, 1280 + { 1281 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5hy457fu2r", 1282 + "group": "infrastructure", 1283 + "tags": [ 1284 + "pds", 1285 + "serverless" 1286 + ], 1287 + "topics": [ 1288 + "decentralized-infrastructure" 1289 + ], 1290 + "keywords": [ 1291 + "cirrus", 1292 + "single-user", 1293 + "infrastructure", 1294 + "atproto" 1295 + ] 1296 + }, 1297 + { 1298 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5hx4v6cr26", 1299 + "group": "protocol-philosophy", 1300 + "tags": [ 1301 + "sovereignty", 1302 + "policy" 1303 + ], 1304 + "topics": [ 1305 + "social-media-trends" 1306 + ], 1307 + "keywords": [ 1308 + "digital-sovereignty", 1309 + "global", 1310 + "atproto", 1311 + "building" 1312 + ] 1313 + }, 1314 + { 1315 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5c65iee42h", 1316 + "group": "user-experience", 1317 + "tags": [ 1318 + "feeds", 1319 + "bluesky" 1320 + ], 1321 + "topics": [ 1322 + "interface-design" 1323 + ], 1324 + "keywords": [ 1325 + "custom-feeds", 1326 + "landscape", 1327 + "bluesky", 1328 + "beyond" 1329 + ] 1330 + }, 1331 + { 1332 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5bya72ub2d", 1333 + "group": "community-culture", 1334 + "tags": [ 1335 + "journalism", 1336 + "discussion" 1337 + ], 1338 + "topics": [ 1339 + "social-media-trends" 1340 + ], 1341 + "keywords": [ 1342 + "news", 1343 + "creators", 1344 + "ai-saturated", 1345 + "algorithms" 1346 + ] 1347 + }, 1348 + { 1349 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5bcpyqg32a", 1350 + "group": "infrastructure", 1351 + "tags": [ 1352 + "community", 1353 + "infrastructure" 1354 + ], 1355 + "topics": [ 1356 + "decentralized-infrastructure" 1357 + ], 1358 + "keywords": [ 1359 + "bluesky", 1360 + "beyond", 1361 + "centralized" 1362 + ] 1363 + }, 1364 + { 1365 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5ag3scgk2k", 1366 + "group": "artificial-intelligence", 1367 + "tags": [ 1368 + "ai", 1369 + "future" 1370 + ], 1371 + "topics": [ 1372 + "emerging-tech" 1373 + ], 1374 + "keywords": [ 1375 + "at-protocol", 1376 + "ai", 1377 + "atproto", 1378 + "agents" 1379 + ] 1380 + }, 1381 + { 1382 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5aarspma27", 1383 + "group": "community-culture", 1384 + "tags": [ 1385 + "media", 1386 + "creator-economy" 1387 + ], 1388 + "topics": [ 1389 + "ecosystem-growth" 1390 + ], 1391 + "keywords": [ 1392 + "video", 1393 + "creators", 1394 + "atproto", 1395 + "building" 1396 + ] 1397 + }, 1398 + { 1399 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5a2y3tej2z", 1400 + "group": "community-culture", 1401 + "tags": [ 1402 + "culture", 1403 + "kpop" 1404 + ], 1405 + "topics": [ 1406 + "community-building" 1407 + ], 1408 + "keywords": [ 1409 + "kpop", 1410 + "listening", 1411 + "everything", 1412 + "everywhere" 1413 + ] 1414 + }, 1415 + { 1416 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi56pnvulh2o", 1417 + "group": "infrastructure", 1418 + "tags": [ 1419 + "lexicon", 1420 + "enterprise" 1421 + ], 1422 + "topics": [ 1423 + "system-architecture" 1424 + ], 1425 + "keywords": [ 1426 + "data", 1427 + "enterprise", 1428 + "building", 1429 + "atproto" 1430 + ] 1431 + }, 1432 + { 1433 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi56n6j2g22d", 1434 + "group": "community-culture", 1435 + "tags": [ 1436 + "collaboration", 1437 + "chat" 1438 + ], 1439 + "topics": [ 1440 + "community-building" 1441 + ], 1442 + "keywords": [ 1443 + "group-chat", 1444 + "atproto", 1445 + "building" 1446 + ] 1447 + }, 1448 + { 1449 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi56m3hnrq2z", 1450 + "group": "community-culture", 1451 + "tags": [ 1452 + "economics", 1453 + "media" 1454 + ], 1455 + "topics": [ 1456 + "ecosystem-growth" 1457 + ], 1458 + "keywords": [ 1459 + "sovereign-media", 1460 + "roadmap", 1461 + "atproto", 1462 + "atprotocol" 1463 + ] 1464 + }, 1465 + { 1466 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi56l4hpil2x", 1467 + "group": "community-culture", 1468 + "tags": [ 1469 + "collaboration", 1470 + "chat" 1471 + ], 1472 + "topics": [ 1473 + "community-building" 1474 + ], 1475 + "keywords": [ 1476 + "group-chat", 1477 + "atproto", 1478 + "building" 1479 + ] 1480 + }, 1481 + { 1482 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi54oonum62b", 1483 + "group": "community-culture", 1484 + "tags": [ 1485 + "community", 1486 + "lessons" 1487 + ], 1488 + "topics": [ 1489 + "community-building" 1490 + ], 1491 + "keywords": [ 1492 + "groundings", 1493 + "siblings", 1494 + "community", 1495 + "000" 1496 + ] 1497 + }, 1498 + { 1499 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi54nec66s2w", 1500 + "group": "infrastructure", 1501 + "tags": [ 1502 + "business", 1503 + "framework" 1504 + ], 1505 + "topics": [ 1506 + "ecosystem-growth" 1507 + ], 1508 + "keywords": [ 1509 + "sustainability", 1510 + "atproto", 1511 + "building" 1512 + ] 1513 + }, 1514 + { 1515 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi54lyc5ts25", 1516 + "group": "community-culture", 1517 + "tags": [ 1518 + "journalism", 1519 + "federation" 1520 + ], 1521 + "topics": [ 1522 + "social-media-trends" 1523 + ], 1524 + "keywords": [ 1525 + "aggregation", 1526 + "federated", 1527 + "ai-saturated", 1528 + "algorithms" 1529 + ] 1530 + }, 1531 + { 1532 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi54jqrm372z", 1533 + "group": "infrastructure", 1534 + "tags": [ 1535 + "landslide", 1536 + "holdfast" 1537 + ], 1538 + "topics": [ 1539 + "system-architecture" 1540 + ], 1541 + "keywords": [ 1542 + "infrastructure", 1543 + "tools", 1544 + "building", 1545 + "bridgy" 1546 + ] 1547 + }, 1548 + { 1549 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miieadw52j22", 1550 + "group": "conference-logistics", 1551 + "tags": [ 1552 + "event", 1553 + "schedule" 1554 + ], 1555 + "topics": [ 1556 + "conference-management" 1557 + ], 1558 + "keywords": [ 1559 + "atmosphereconf", 1560 + "day-1", 1561 + "great-hall", 1562 + "day" 1563 + ] 1564 + }, 1565 + { 1566 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi3cjemc6a27", 1567 + "group": "conference-logistics", 1568 + "tags": [ 1569 + "impromptu", 1570 + "session" 1571 + ], 1572 + "topics": [ 1573 + "conference-management" 1574 + ], 1575 + "keywords": [ 1576 + "panproto", 1577 + "everything", 1578 + "everywhere" 1579 + ] 1580 + }, 1581 + { 1582 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi3bf7s6zn27", 1583 + "group": "conference-logistics", 1584 + "tags": [ 1585 + "impromptu", 1586 + "session" 1587 + ], 1588 + "topics": [ 1589 + "conference-management" 1590 + ], 1591 + "keywords": [ 1592 + "everything", 1593 + "session", 1594 + "everywhere" 1595 + ] 1596 + }, 1597 + { 1598 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi3ar75zqs23", 1599 + "group": "conference-logistics", 1600 + "tags": [ 1601 + "impromptu", 1602 + "session" 1603 + ], 1604 + "topics": [ 1605 + "conference-management" 1606 + ], 1607 + "keywords": [ 1608 + "blaine", 1609 + "everything", 1610 + "everywhere" 1611 + ] 1612 + }, 1613 + { 1614 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi37ha3edb23", 1615 + "group": "protocol-philosophy", 1616 + "tags": [ 1617 + "decentralization", 1618 + "analysis" 1619 + ], 1620 + "topics": [ 1621 + "protocol-design" 1622 + ], 1623 + "keywords": [ 1624 + "bluesky", 1625 + "decentralized", 1626 + "beyond" 1627 + ] 1628 + }, 1629 + { 1630 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi36lcxgce2b", 1631 + "group": "science-research", 1632 + "tags": [ 1633 + "data", 1634 + "memetics" 1635 + ], 1636 + "topics": [ 1637 + "open-science" 1638 + ], 1639 + "keywords": [ 1640 + "community-archive", 1641 + "narrative", 1642 + "social", 1643 + "community" 1644 + ] 1645 + }, 1646 + { 1647 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mipbxjavnf2m", 1648 + "group": "science-research", 1649 + "tags": [ 1650 + "data", 1651 + "memetics" 1652 + ], 1653 + "topics": [ 1654 + "open-science" 1655 + ], 1656 + "keywords": [ 1657 + "community-archive", 1658 + "narrative", 1659 + "community", 1660 + "social" 1661 + ] 1662 + }, 1663 + { 1664 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi35eplsua23", 1665 + "group": "science-research", 1666 + "tags": [ 1667 + "research", 1668 + "social-media" 1669 + ], 1670 + "topics": [ 1671 + "open-science" 1672 + ], 1673 + "keywords": [ 1674 + "atmosphere", 1675 + "study", 1676 + "affordances", 1677 + "commons" 1678 + ] 1679 + }, 1680 + { 1681 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi34ps3oym2i", 1682 + "group": "science-research", 1683 + "tags": [ 1684 + "research", 1685 + "synthesis" 1686 + ], 1687 + "topics": [ 1688 + "open-science" 1689 + ], 1690 + "keywords": [ 1691 + "crowdsourced", 1692 + "atproto", 1693 + "research" 1694 + ] 1695 + }, 1696 + { 1697 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mipbwdhy3v2m", 1698 + "group": "science-research", 1699 + "tags": [ 1700 + "research", 1701 + "synthesis" 1702 + ], 1703 + "topics": [ 1704 + "open-science" 1705 + ], 1706 + "keywords": [ 1707 + "crowdsourced", 1708 + "atproto", 1709 + "research" 1710 + ] 1711 + }, 1712 + { 1713 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi33ycoo5d2a", 1714 + "group": "science-research", 1715 + "tags": [ 1716 + "science", 1717 + "social-media" 1718 + ], 1719 + "topics": [ 1720 + "open-science" 1721 + ], 1722 + "keywords": [ 1723 + "future", 1724 + "science", 1725 + "social" 1726 + ] 1727 + }, 1728 + { 1729 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mipbvfgsbv2m", 1730 + "group": "community-culture", 1731 + "tags": [ 1732 + "migration", 1733 + "lessons" 1734 + ], 1735 + "topics": [ 1736 + "ecosystem-growth" 1737 + ], 1738 + "keywords": [ 1739 + "bluesky", 1740 + "party", 1741 + "beyond" 1742 + ] 1743 + }, 1744 + { 1745 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mipbufh6gf2m", 1746 + "group": "science-research", 1747 + "tags": [ 1748 + "education", 1749 + "computation" 1750 + ], 1751 + "topics": [ 1752 + "open-science" 1753 + ], 1754 + "keywords": [ 1755 + "atmosphere", 1756 + "commons", 1757 + "affordances", 1758 + "atmosphereconf" 1759 + ] 1760 + }, 1761 + { 1762 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi2wu5u5rs2i", 1763 + "group": "science-research", 1764 + "tags": [ 1765 + "astronomy", 1766 + "ecosystem" 1767 + ], 1768 + "topics": [ 1769 + "open-science" 1770 + ], 1771 + "keywords": [ 1772 + "astrosky", 1773 + "home", 1774 + "bluesky", 1775 + "beyond" 1776 + ] 1777 + }, 1778 + { 1779 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi2u6pl6ah2z", 1780 + "group": "user-experience", 1781 + "tags": [ 1782 + "context", 1783 + "service" 1784 + ], 1785 + "topics": [ 1786 + "interface-design" 1787 + ], 1788 + "keywords": [ 1789 + "skysquare", 1790 + "context", 1791 + "viewsift", 1792 + "bluesky" 1793 + ] 1794 + }, 1795 + { 1796 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi2tfsbq4p25", 1797 + "group": "community-culture", 1798 + "tags": [ 1799 + "wisdom", 1800 + "collaboration" 1801 + ], 1802 + "topics": [ 1803 + "community-building" 1804 + ], 1805 + "keywords": [ 1806 + "seams", 1807 + "making", 1808 + "community", 1809 + "archive" 1810 + ] 1811 + }, 1812 + { 1813 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi2son7re62m", 1814 + "group": "community-culture", 1815 + "tags": [ 1816 + "intelligence", 1817 + "division" 1818 + ], 1819 + "topics": [ 1820 + "community-safety" 1821 + ], 1822 + "keywords": [ 1823 + "viewsift", 1824 + "collective", 1825 + "social", 1826 + "atproto" 1827 + ] 1828 + }, 1829 + { 1830 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi2ryxrnin2a", 1831 + "group": "science-research", 1832 + "tags": [ 1833 + "reproducibility", 1834 + "reviews" 1835 + ], 1836 + "topics": [ 1837 + "open-science" 1838 + ], 1839 + "keywords": [ 1840 + "automation", 1841 + "papers", 1842 + "ai-saturated", 1843 + "algorithms" 1844 + ] 1845 + }, 1846 + { 1847 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mipbt7pzjf2m", 1848 + "group": "science-research", 1849 + "tags": [ 1850 + "preprints", 1851 + "decentralization" 1852 + ], 1853 + "topics": [ 1854 + "open-science" 1855 + ], 1856 + "keywords": [ 1857 + "chive", 1858 + "atproto", 1859 + "building" 1860 + ] 1861 + }, 1862 + { 1863 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mipbrr6lfv2m", 1864 + "group": "science-research", 1865 + "tags": [ 1866 + "research", 1867 + "app" 1868 + ], 1869 + "topics": [ 1870 + "open-science" 1871 + ], 1872 + "keywords": [ 1873 + "lea", 1874 + "researchers", 1875 + "social", 1876 + "atproto" 1877 + ] 1878 + }, 1879 + { 1880 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mipbp6eycf2m", 1881 + "group": "science-research", 1882 + "tags": [ 1883 + "knowledge", 1884 + "network" 1885 + ], 1886 + "topics": [ 1887 + "open-science" 1888 + ], 1889 + "keywords": [ 1890 + "semble", 1891 + "research", 1892 + "atproto", 1893 + "crowdsourced" 1894 + ] 1895 + }, 1896 + { 1897 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi2jdevvu626", 1898 + "group": "science-research", 1899 + "tags": [ 1900 + "modular", 1901 + "science" 1902 + ], 1903 + "topics": [ 1904 + "open-science" 1905 + ], 1906 + "keywords": [ 1907 + "keynote", 1908 + "matsulab", 1909 + "future", 1910 + "social" 1911 + ] 1912 + }, 1913 + { 1914 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi2ikg6gij26", 1915 + "group": "science-research", 1916 + "tags": [ 1917 + "science", 1918 + "event" 1919 + ], 1920 + "topics": [ 1921 + "open-science" 1922 + ], 1923 + "keywords": [ 1924 + "atscience", 1925 + "atmosphereconf", 1926 + "day" 1927 + ] 1928 + }, 1929 + { 1930 + "uri": "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miieadvruo22", 1931 + "group": "science-research", 1932 + "tags": [ 1933 + "science", 1934 + "event" 1935 + ], 1936 + "topics": [ 1937 + "open-science" 1938 + ], 1939 + "keywords": [ 1940 + "atscience", 1941 + "full-day", 1942 + "atmosphereconf", 1943 + "day" 1944 + ] 1945 + } 1946 + ] 1947 + }
+43 -5
src/pages/search-page.tsx
··· 1 1 import { Search } from 'lucide-react' 2 2 import { useMemo, useState } from 'react' 3 + import { Link } from 'react-router-dom' 3 4 4 5 import { ErrorPanel } from '@/components/error-panel' 5 6 import { TalkCard } from '@/components/talk-card' 6 7 import { TalkGridSkeleton } from '@/components/talk-grid-skeleton' 8 + import { toTagPath } from '@/lib/routes' 9 + import { getTalkTaxonomyTokens, scoreTalkForQuery } from '@/lib/taxonomy' 7 10 import { useVideos } from '@/state/videos-context' 8 11 9 12 export function SearchPage() { ··· 12 15 const trimmedQuery = query.trim() 13 16 14 17 const filteredTalks = useMemo(() => { 15 - const normalized = trimmedQuery.toLowerCase() 16 - if (!normalized) { 18 + if (!trimmedQuery) { 17 19 return talks 18 20 } 19 21 20 - return talks.filter((talk) => talk.title.toLowerCase().includes(normalized)) 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) 21 30 }, [talks, trimmedQuery]) 22 31 32 + const popularTokens = useMemo(() => { 33 + const counts = new Map<string, number>() 34 + 35 + for (const talk of filteredTalks) { 36 + for (const token of getTalkTaxonomyTokens(talk)) { 37 + counts.set(token, (counts.get(token) ?? 0) + 1) 38 + } 39 + } 40 + 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]) 46 + 23 47 return ( 24 48 <div className="space-y-7 md:space-y-10" aria-busy={loading}> 25 49 <header className="space-y-4"> ··· 27 51 28 52 <label className="flex min-h-11 items-center gap-3 rounded-lg border border-line/45 bg-surface/80 px-3 focus-within:border-line/60 focus-within:ring-2 focus-within:ring-text/30"> 29 53 <Search className="h-4 w-4 text-muted" /> 30 - <span className="sr-only">Search by title</span> 54 + <span className="sr-only">Search by title, tags, or topics</span> 31 55 <input 32 56 type="search" 33 57 value={query} 34 58 onChange={(event) => setQuery(event.target.value)} 35 - placeholder="Search by title" 59 + placeholder="Search by title, tags, or topics" 36 60 className="h-11 w-full bg-transparent text-sm text-text outline-none placeholder:text-muted" 37 61 autoComplete="off" 38 62 /> 39 63 </label> 64 + 65 + {!loading && !error && popularTokens.length > 0 ? ( 66 + <div className="flex flex-wrap gap-2"> 67 + {popularTokens.map((token) => ( 68 + <Link 69 + key={token} 70 + to={toTagPath(token)} 71 + className="inline-flex min-h-11 items-center rounded-md border border-line/45 bg-surface/80 px-3 text-xs text-muted transition hover:border-line/60 hover:text-text" 72 + > 73 + #{token} 74 + </Link> 75 + ))} 76 + </div> 77 + ) : null} 40 78 </header> 41 79 42 80 {loading ? (
+78
src/pages/tag-page.tsx
··· 1 + import { Link, useParams } from 'react-router-dom' 2 + 3 + import { ErrorPanel } from '@/components/error-panel' 4 + import { TalkCard } from '@/components/talk-card' 5 + import { TalkGridSkeleton } from '@/components/talk-grid-skeleton' 6 + import { fromTagParam } from '@/lib/routes' 7 + import { matchesTagRoute, normalizeSearchValue } from '@/lib/taxonomy' 8 + import { useVideos } from '@/state/videos-context' 9 + 10 + export function TagPage() { 11 + const { tagParam } = useParams<{ tagParam: string }>() 12 + const { talks, loading, error, refresh } = useVideos() 13 + 14 + const tag = tagParam ? fromTagParam(tagParam) : undefined 15 + const normalizedTag = tag ? normalizeSearchValue(tag) : '' 16 + const filteredTalks = talks.filter((talk) => matchesTagRoute(talk, normalizedTag)) 17 + 18 + if (!tag) { 19 + return ( 20 + <ErrorPanel 21 + title="Invalid tag" 22 + message="This tag route is not valid." 23 + onRetry={refresh} 24 + /> 25 + ) 26 + } 27 + 28 + return ( 29 + <div className="space-y-7 md:space-y-10" aria-busy={loading}> 30 + <header className="space-y-2"> 31 + <p className="text-sm text-muted"> 32 + <Link to="/search" className="underline-offset-4 hover:text-text hover:underline"> 33 + Search 34 + </Link>{' '} 35 + / #{normalizedTag} 36 + </p> 37 + <h1 className="text-2xl font-semibold text-text">Tag: #{normalizedTag}</h1> 38 + </header> 39 + 40 + {loading ? ( 41 + <div role="status" aria-live="polite"> 42 + <span className="sr-only">Loading talks</span> 43 + <TalkGridSkeleton /> 44 + </div> 45 + ) : null} 46 + 47 + {!loading && error ? ( 48 + <ErrorPanel 49 + title="Tag view unavailable" 50 + message="Talk metadata failed to load, so tag filtering is temporarily unavailable." 51 + onRetry={refresh} 52 + /> 53 + ) : null} 54 + 55 + {!loading && !error && filteredTalks.length === 0 ? ( 56 + <section className="rounded-lg border border-line/45 bg-surface/80 p-5"> 57 + <h2 className="text-base font-semibold text-text">No talks for this tag</h2> 58 + <p className="mt-2 text-sm leading-relaxed text-muted"> 59 + Try a different tag from the search page. 60 + </p> 61 + </section> 62 + ) : null} 63 + 64 + {!loading && !error && filteredTalks.length > 0 ? ( 65 + <section className="space-y-3"> 66 + <p className="text-sm text-muted"> 67 + {filteredTalks.length} result{filteredTalks.length === 1 ? '' : 's'} 68 + </p> 69 + <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"> 70 + {filteredTalks.map((talk, index) => ( 71 + <TalkCard key={talk.uri} talk={talk} featured={index === 0} /> 72 + ))} 73 + </div> 74 + </section> 75 + ) : null} 76 + </div> 77 + ) 78 + }
+24 -2
src/pages/video-page.tsx
··· 17 17 import { Button } from '@/components/ui/button' 18 18 import { fetchVideoPlaylist, getArchiveBlobUrl } from '@/lib/api' 19 19 import { formatDate, formatDuration, truncateDid } from '@/lib/format' 20 - import { fromVideoParam } from '@/lib/routes' 20 + import { fromVideoParam, toTagPath } from '@/lib/routes' 21 + import { getTalkTaxonomyTokens } from '@/lib/taxonomy' 21 22 import { useVideos } from '@/state/videos-context' 22 23 23 24 type PlaybackStatus = 'idle' | 'loading' | 'ready' | 'error' ··· 52 53 ) 53 54 54 55 const talk = useMemo(() => talks.find((item) => item.uri === resolvedUri), [talks, resolvedUri]) 56 + const talkTokens = useMemo(() => (talk ? getTalkTaxonomyTokens(talk).slice(0, 10) : []), [talk]) 55 57 56 58 const playbackElapsed = formatDuration(currentTime * 1_000_000_000) 57 59 const playbackTotal = formatDuration(duration * 1_000_000_000 || talk?.durationNs || 0) ··· 93 95 hlsRef.current = null 94 96 } 95 97 96 - const { default: Hls } = await import('hls.js/light') 98 + const { default: Hls } = await import('hls.js') 97 99 if (cancelled) { 98 100 return 99 101 } ··· 112 114 } else { 113 115 throw new Error('This browser does not support HLS playback.') 114 116 } 117 + 118 + video.muted = false 119 + video.volume = Math.max(video.volume || 1, 0.75) 115 120 116 121 setPlaylistUrl(playlistUrl) 117 122 setError(null) ··· 342 347 </a> 343 348 ) : null} 344 349 </div> 350 + 351 + {talkTokens.length > 0 ? ( 352 + <section className="space-y-2"> 353 + <h2 className="text-sm font-medium text-muted">Topics</h2> 354 + <div className="flex flex-wrap gap-2"> 355 + {talkTokens.map((token) => ( 356 + <Link 357 + key={token} 358 + to={toTagPath(token)} 359 + className="inline-flex min-h-11 items-center rounded-md border border-line/45 bg-surface/80 px-3 text-xs text-muted transition hover:border-line/60 hover:text-text" 360 + > 361 + #{token} 362 + </Link> 363 + ))} 364 + </div> 365 + </section> 366 + ) : null} 345 367 </section> 346 368 347 369 {talk ? (
+1 -1
src/vite-env.d.ts
··· 1 1 /// <reference types="vite/client" /> 2 2 /// <reference types="vite-plugin-pwa/client" /> 3 3 4 - declare module 'hls.js/light' { 4 + declare module 'hls.js' { 5 5 import Hls from 'hls.js' 6 6 export default Hls 7 7 }
+1
tsconfig.app.json
··· 10 10 11 11 /* Bundler mode */ 12 12 "moduleResolution": "bundler", 13 + "resolveJsonModule": true, 13 14 "baseUrl": ".", 14 15 "paths": { 15 16 "@/*": ["./src/*"]