The AtmosphereConf talks your skyline missed
0
fork

Configure Feed

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

feat: add talk page with video, transcript, and search

Route /talk/[rkey] with:
- HLS video player (hls.js + Safari fallback)
- Sentence-level transcript with click-to-seek and active highlighting
- In-transcript search with match highlighting
- Auto-scroll with scroll lock toggle
- Static generation for all 115 talks with transcripts
- Handles null utterances gracefully

Closes #6, #7, #8, #9

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+1275
+927
docs/superpowers/plans/2026-04-06-talk-page.md
··· 1 + # Talk Page Implementation Plan 2 + 3 + > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. 4 + 5 + **Goal:** Build the talk page (`/talk/[rkey]`) with HLS video player, synchronized transcript with click-to-seek, and in-transcript search. 6 + 7 + **Architecture:** Server component loads talk + transcript data from `data/` at build time, splits utterances into sentence segments, passes to a client component that coordinates video playback time with transcript highlighting and seek. Static generation via `generateStaticParams` for all 115 talks with transcripts. 8 + 9 + **Tech Stack:** Next.js 16 (App Router), hls.js, TypeScript, Tailwind CSS v4 10 + 11 + **Spec:** `docs/superpowers/specs/2026-04-06-talk-page.md` 12 + 13 + **Chainlink Issues:** #6, #7, #8, #9 14 + 15 + --- 16 + 17 + ## File Structure 18 + 19 + | File | Action | Responsibility | 20 + |------|--------|----------------| 21 + | `src/lib/types.ts` | Create | Shared TypeScript interfaces (TalkEntry, Utterance, Word, TranscriptSegment) | 22 + | `src/lib/transcript.ts` | Create | Sentence splitting logic — pure function, no React | 23 + | `src/lib/format.ts` | Create | Formatting helpers (duration, timestamp, speaker name) | 24 + | `src/components/video-player.tsx` | Create | HLS.js video player with time reporting and seek control | 25 + | `src/components/transcript-panel.tsx` | Create | Transcript display with active segment, click-to-seek, auto-scroll, search | 26 + | `src/components/talk-page-client.tsx` | Create | Client wrapper coordinating video ↔ transcript state | 27 + | `src/app/talk/[rkey]/page.tsx` | Create | Server component — data loading, sentence splitting, static generation | 28 + 29 + --- 30 + 31 + ## Chunk 1: Data Layer & Utilities 32 + 33 + ### Task 1: Install hls.js 34 + 35 + **Files:** 36 + - Modify: `package.json` 37 + 38 + - [ ] **Step 1: Install hls.js** 39 + 40 + Run: `npm install hls.js` 41 + 42 + - [ ] **Step 2: Verify it installed** 43 + 44 + Run: `npm ls hls.js` 45 + Expected: Shows `hls.js@x.x.x` 46 + 47 + - [ ] **Step 3: Commit** 48 + 49 + ```bash 50 + git add package.json package-lock.json 51 + git commit -m "deps: add hls.js for adaptive video streaming" 52 + ``` 53 + 54 + --- 55 + 56 + ### Task 2: Shared types 57 + 58 + **Files:** 59 + - Create: `src/lib/types.ts` 60 + 61 + - [ ] **Step 1: Create the types file** 62 + 63 + ```ts 64 + // Talk entry from data/talks.json 65 + export interface Speaker { 66 + id: string; 67 + name: string; 68 + } 69 + 70 + export interface TalkEntry { 71 + rkey: string; 72 + title: string; 73 + vodUri: string; 74 + vodCid: string; 75 + hlsUrl: string; 76 + durationMs: number; 77 + createdAt: string; 78 + eventUri: string | null; 79 + description: string | null; 80 + speakers: Speaker[]; 81 + room: string | null; 82 + talkType: string | null; 83 + category: string | null; 84 + startsAt: string | null; 85 + endsAt: string | null; 86 + transcriptFile: string | null; 87 + } 88 + 89 + // From data/transcripts/[rkey].json 90 + export interface Word { 91 + text: string; 92 + start: number; 93 + end: number; 94 + speaker: string; 95 + confidence?: number; 96 + } 97 + 98 + export interface Utterance { 99 + speaker: string; 100 + text: string; 101 + start: number; 102 + end: number; 103 + words: Word[]; 104 + confidence?: number; 105 + } 106 + 107 + export interface TranscriptData { 108 + uri: string; 109 + cid: string; 110 + title: string; 111 + creator: string; 112 + duration: number; 113 + createdAt: string; 114 + transcription: { 115 + id: string; 116 + status: string; 117 + text: string; 118 + utterances: Utterance[]; 119 + words: Word[]; 120 + audio_duration: number; 121 + }; 122 + } 123 + 124 + // Processed segment for the transcript UI 125 + export interface TranscriptSegment { 126 + id: string; 127 + speaker: string; 128 + text: string; 129 + startMs: number; 130 + endMs: number; 131 + } 132 + 133 + // Seek state with counter for re-triggering 134 + export interface SeekTarget { 135 + timeMs: number; 136 + id: number; 137 + } 138 + ``` 139 + 140 + - [ ] **Step 2: Verify it compiles** 141 + 142 + Run: `npx tsc --noEmit` 143 + Expected: No errors. 144 + 145 + - [ ] **Step 3: Commit** 146 + 147 + ```bash 148 + git add src/lib/types.ts 149 + git commit -m "feat: add shared TypeScript types for talk page" 150 + ``` 151 + 152 + --- 153 + 154 + ### Task 3: Formatting helpers 155 + 156 + **Files:** 157 + - Create: `src/lib/format.ts` 158 + 159 + - [ ] **Step 1: Write the formatting functions** 160 + 161 + ```ts 162 + import type { Speaker } from "./types"; 163 + 164 + /** 165 + * Format milliseconds as "MM:SS". 166 + */ 167 + export function formatTimestamp(ms: number): string { 168 + const totalSeconds = Math.floor(ms / 1000); 169 + const minutes = Math.floor(totalSeconds / 60); 170 + const seconds = totalSeconds % 60; 171 + return `${minutes}:${seconds.toString().padStart(2, "0")}`; 172 + } 173 + 174 + /** 175 + * Format duration in ms as "X min". Under 1 minute shows "< 1 min". 176 + */ 177 + export function formatDuration(ms: number): string { 178 + const minutes = Math.round(ms / 60000); 179 + if (minutes < 1) return "< 1 min"; 180 + return `${minutes} min`; 181 + } 182 + 183 + /** 184 + * Resolve a diarization speaker label ("A", "B") to a speaker name. 185 + * Falls back to "Speaker A" if no match. 186 + */ 187 + export function resolveSpeaker( 188 + label: string, 189 + speakers: Speaker[], 190 + ): string { 191 + const index = label.charCodeAt(0) - "A".charCodeAt(0); 192 + if (index >= 0 && index < speakers.length && speakers[index].name) { 193 + return speakers[index].name; 194 + } 195 + return `Speaker ${label}`; 196 + } 197 + 198 + /** 199 + * Format a date string as "Mar 27, 4:15 PM". 200 + */ 201 + export function formatDate(iso: string): string { 202 + const date = new Date(iso); 203 + return date.toLocaleString("en-US", { 204 + month: "short", 205 + day: "numeric", 206 + hour: "numeric", 207 + minute: "2-digit", 208 + hour12: true, 209 + timeZone: "America/Vancouver", 210 + }); 211 + } 212 + ``` 213 + 214 + - [ ] **Step 2: Verify it compiles** 215 + 216 + Run: `npx tsc --noEmit` 217 + Expected: No errors. 218 + 219 + - [ ] **Step 3: Commit** 220 + 221 + ```bash 222 + git add src/lib/format.ts 223 + git commit -m "feat: add formatting helpers for timestamps, durations, speakers" 224 + ``` 225 + 226 + --- 227 + 228 + ### Task 4: Sentence splitting 229 + 230 + **Files:** 231 + - Create: `src/lib/transcript.ts` 232 + 233 + - [ ] **Step 1: Write the sentence splitting function** 234 + 235 + ```ts 236 + import type { Utterance, TranscriptSegment } from "./types"; 237 + 238 + /** 239 + * Strip trailing punctuation for word comparison. 240 + */ 241 + function normalize(s: string): string { 242 + return s.replace(/[.,!?;:'")\]]+$/, "").replace(/^['"(\[]+/, "").toLowerCase(); 243 + } 244 + 245 + /** 246 + * Split utterances into sentence-level segments with interpolated timestamps. 247 + * Walks the words array sequentially, matching tokens to derive accurate timing. 248 + */ 249 + export function splitUtterances( 250 + utterances: Utterance[], 251 + ): TranscriptSegment[] { 252 + const segments: TranscriptSegment[] = []; 253 + 254 + for (let uIdx = 0; uIdx < utterances.length; uIdx++) { 255 + const utterance = utterances[uIdx]; 256 + const sentences = utterance.text.split(/(?<=[.!?])\s+/).filter(Boolean); 257 + 258 + if (sentences.length === 0) continue; 259 + 260 + // If only one sentence or no words, treat entire utterance as one segment 261 + if (sentences.length === 1 || !utterance.words || utterance.words.length === 0) { 262 + segments.push({ 263 + id: `u${uIdx}-s0`, 264 + speaker: utterance.speaker, 265 + text: utterance.text, 266 + startMs: utterance.start, 267 + endMs: utterance.end, 268 + }); 269 + continue; 270 + } 271 + 272 + let wordPtr = 0; 273 + const words = utterance.words; 274 + 275 + for (let sIdx = 0; sIdx < sentences.length; sIdx++) { 276 + const sentence = sentences[sIdx]; 277 + const tokens = sentence.split(/\s+/).filter(Boolean); 278 + 279 + // Try to match tokens to words sequentially 280 + const startWord = wordPtr; 281 + let matched = 0; 282 + 283 + for (const token of tokens) { 284 + if (wordPtr >= words.length) break; 285 + const normalizedToken = normalize(token); 286 + const normalizedWord = normalize(words[wordPtr].text); 287 + 288 + if (normalizedToken === normalizedWord || normalizedWord.startsWith(normalizedToken) || normalizedToken.startsWith(normalizedWord)) { 289 + matched++; 290 + wordPtr++; 291 + } else { 292 + // Skip ahead up to 2 words to handle minor mismatches 293 + let found = false; 294 + for (let skip = 1; skip <= 2 && wordPtr + skip < words.length; skip++) { 295 + if (normalize(words[wordPtr + skip].text) === normalizedToken) { 296 + wordPtr += skip + 1; 297 + matched++; 298 + found = true; 299 + break; 300 + } 301 + } 302 + if (!found) { 303 + wordPtr++; 304 + matched++; 305 + } 306 + } 307 + } 308 + 309 + // Derive timestamps from matched words 310 + let startMs: number; 311 + let endMs: number; 312 + 313 + if (startWord < words.length && wordPtr > startWord) { 314 + startMs = words[startWord].start; 315 + endMs = words[Math.min(wordPtr - 1, words.length - 1)].end; 316 + } else { 317 + // Fallback: interpolate proportionally 318 + const charStart = sentences.slice(0, sIdx).join(" ").length; 319 + const charEnd = charStart + sentence.length; 320 + const totalChars = utterance.text.length; 321 + const duration = utterance.end - utterance.start; 322 + startMs = utterance.start + (charStart / totalChars) * duration; 323 + endMs = utterance.start + (charEnd / totalChars) * duration; 324 + } 325 + 326 + segments.push({ 327 + id: `u${uIdx}-s${sIdx}`, 328 + speaker: utterance.speaker, 329 + text: sentence, 330 + startMs: Math.round(startMs), 331 + endMs: Math.round(endMs), 332 + }); 333 + } 334 + } 335 + 336 + return segments; 337 + } 338 + ``` 339 + 340 + - [ ] **Step 2: Verify it compiles** 341 + 342 + Run: `npx tsc --noEmit` 343 + Expected: No errors. 344 + 345 + - [ ] **Step 3: Quick smoke test with real data** 346 + 347 + Run: 348 + ```bash 349 + node -e " 350 + const { splitUtterances } = require('./src/lib/transcript'); 351 + const data = require('./data/transcripts/3mi2jdevvu626.json'); 352 + const segs = splitUtterances(data.transcription.utterances); 353 + console.log('segments:', segs.length); 354 + console.log('first:', segs[0]); 355 + console.log('last:', segs[segs.length-1]); 356 + console.log('all have startMs:', segs.every(s => typeof s.startMs === 'number')); 357 + console.log('all have endMs:', segs.every(s => typeof s.endMs === 'number')); 358 + " 359 + ``` 360 + 361 + This will fail because Node can't import TS directly. Instead use: 362 + 363 + ```bash 364 + npx tsx -e " 365 + import { splitUtterances } from './src/lib/transcript'; 366 + import data from './data/transcripts/3mi2jdevvu626.json'; 367 + const segs = splitUtterances((data as any).transcription.utterances); 368 + console.log('segments:', segs.length); 369 + console.log('first:', JSON.stringify(segs[0])); 370 + console.log('all have timing:', segs.every(s => s.startMs >= 0 && s.endMs > s.startMs)); 371 + " 372 + ``` 373 + 374 + Expected: Prints segment count (should be > 25, since 25 utterances are split into sentences), first segment with valid timing, and `true` for timing validation. 375 + 376 + - [ ] **Step 4: Commit** 377 + 378 + ```bash 379 + git add src/lib/transcript.ts 380 + git commit -m "feat: add sentence-level transcript splitting with word-level timing" 381 + ``` 382 + 383 + --- 384 + 385 + ## Chunk 2: Video Player & Transcript Components 386 + 387 + ### Task 5: VideoPlayer component 388 + 389 + **Files:** 390 + - Create: `src/components/video-player.tsx` 391 + 392 + - [ ] **Step 1: Write the VideoPlayer component** 393 + 394 + ```tsx 395 + "use client"; 396 + 397 + import { useRef, useEffect, useCallback } from "react"; 398 + import Hls from "hls.js"; 399 + import type { SeekTarget } from "@/lib/types"; 400 + 401 + interface VideoPlayerProps { 402 + hlsUrl: string; 403 + onTimeUpdate: (timeMs: number) => void; 404 + seekTo: SeekTarget | null; 405 + } 406 + 407 + export function VideoPlayer({ hlsUrl, onTimeUpdate, seekTo }: VideoPlayerProps) { 408 + const videoRef = useRef<HTMLVideoElement>(null); 409 + const hlsRef = useRef<Hls | null>(null); 410 + const lastSeekId = useRef<number>(-1); 411 + 412 + // Initialize HLS 413 + useEffect(() => { 414 + const video = videoRef.current; 415 + if (!video) return; 416 + 417 + if (Hls.isSupported()) { 418 + const hls = new Hls(); 419 + hls.loadSource(hlsUrl); 420 + hls.attachMedia(video); 421 + hlsRef.current = hls; 422 + 423 + hls.on(Hls.Events.ERROR, (_event, data) => { 424 + if (data.fatal) { 425 + console.error("HLS fatal error:", data.type, data.details); 426 + } 427 + }); 428 + 429 + return () => { 430 + hls.destroy(); 431 + hlsRef.current = null; 432 + }; 433 + } else if (video.canPlayType("application/vnd.apple.mpegurl")) { 434 + // Safari native HLS 435 + video.src = hlsUrl; 436 + } 437 + }, [hlsUrl]); 438 + 439 + // Time update handler 440 + const handleTimeUpdate = useCallback(() => { 441 + const video = videoRef.current; 442 + if (video) { 443 + onTimeUpdate(Math.round(video.currentTime * 1000)); 444 + } 445 + }, [onTimeUpdate]); 446 + 447 + // Seek when seekTo changes 448 + useEffect(() => { 449 + const video = videoRef.current; 450 + if (!video || !seekTo || seekTo.id === lastSeekId.current) return; 451 + lastSeekId.current = seekTo.id; 452 + video.currentTime = seekTo.timeMs / 1000; 453 + if (video.paused) { 454 + video.play().catch(() => {}); 455 + } 456 + }, [seekTo]); 457 + 458 + return ( 459 + <div className="rounded-lg overflow-hidden bg-surface-container-lowest"> 460 + <video 461 + ref={videoRef} 462 + controls 463 + className="w-full aspect-video" 464 + onTimeUpdate={handleTimeUpdate} 465 + playsInline 466 + > 467 + Your browser does not support video playback. 468 + </video> 469 + </div> 470 + ); 471 + } 472 + ``` 473 + 474 + - [ ] **Step 2: Verify it compiles** 475 + 476 + Run: `npx tsc --noEmit` 477 + Expected: No errors. 478 + 479 + - [ ] **Step 3: Commit** 480 + 481 + ```bash 482 + git add src/components/video-player.tsx 483 + git commit -m "feat: add HLS video player component with seek control" 484 + ``` 485 + 486 + --- 487 + 488 + ### Task 6: TranscriptPanel component 489 + 490 + **Files:** 491 + - Create: `src/components/transcript-panel.tsx` 492 + 493 + - [ ] **Step 1: Write the TranscriptPanel component** 494 + 495 + ```tsx 496 + "use client"; 497 + 498 + import { useState, useRef, useEffect, useCallback, useMemo } from "react"; 499 + import type { TranscriptSegment, Speaker } from "@/lib/types"; 500 + import { formatTimestamp, resolveSpeaker } from "@/lib/format"; 501 + 502 + interface TranscriptPanelProps { 503 + segments: TranscriptSegment[]; 504 + speakers: Speaker[]; 505 + currentTimeMs: number; 506 + onSeek: (timeMs: number) => void; 507 + } 508 + 509 + export function TranscriptPanel({ 510 + segments, 511 + speakers, 512 + currentTimeMs, 513 + onSeek, 514 + }: TranscriptPanelProps) { 515 + const [search, setSearch] = useState(""); 516 + const [userScrolled, setUserScrolled] = useState(false); 517 + const scrollRef = useRef<HTMLDivElement>(null); 518 + const activeRef = useRef<HTMLButtonElement>(null); 519 + const programmaticScroll = useRef(false); 520 + 521 + // Find active segment 522 + const activeIndex = useMemo(() => { 523 + for (let i = segments.length - 1; i >= 0; i--) { 524 + if (currentTimeMs >= segments[i].startMs) return i; 525 + } 526 + return -1; 527 + }, [segments, currentTimeMs]); 528 + 529 + // Filter by search 530 + const searchLower = search.toLowerCase(); 531 + const filteredSegments = useMemo(() => { 532 + if (!search) return segments; 533 + return segments.filter((s) => s.text.toLowerCase().includes(searchLower)); 534 + }, [segments, search, searchLower]); 535 + 536 + const matchCount = search ? filteredSegments.length : 0; 537 + 538 + // Auto-scroll to active segment 539 + useEffect(() => { 540 + if (userScrolled || search || !activeRef.current) return; 541 + programmaticScroll.current = true; 542 + activeRef.current.scrollIntoView({ behavior: "smooth", block: "center" }); 543 + // Reset programmatic flag after scroll completes 544 + setTimeout(() => { 545 + programmaticScroll.current = false; 546 + }, 500); 547 + }, [activeIndex, userScrolled, search]); 548 + 549 + // Detect manual scroll 550 + const handleScroll = useCallback(() => { 551 + if (programmaticScroll.current) return; 552 + setUserScrolled(true); 553 + }, []); 554 + 555 + // Click a segment to seek 556 + const handleSegmentClick = (startMs: number) => { 557 + setUserScrolled(false); 558 + onSeek(startMs); 559 + }; 560 + 561 + // Highlight matching text 562 + function highlightText(text: string): React.ReactNode { 563 + if (!search) return text; 564 + const regex = new RegExp(`(${search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi"); 565 + const parts = text.split(regex); 566 + return parts.map((part, i) => 567 + regex.test(part) ? ( 568 + <mark key={i} className="bg-primary-fixed/20 text-on-surface rounded-sm px-0.5"> 569 + {part} 570 + </mark> 571 + ) : ( 572 + part 573 + ), 574 + ); 575 + } 576 + 577 + return ( 578 + <div className="flex flex-col bg-surface-container-low lg:h-[calc(100vh-5rem)] rounded-lg"> 579 + {/* Search */} 580 + <div className="sticky top-0 z-10 p-3 bg-surface-container-low"> 581 + <div className="relative"> 582 + <input 583 + type="text" 584 + value={search} 585 + onChange={(e) => setSearch(e.target.value)} 586 + placeholder="Search transcript..." 587 + aria-label="Search transcript" 588 + className="w-full rounded-lg bg-surface-container-highest px-4 py-2 text-body-md text-on-surface placeholder:text-on-surface-variant/50 focus:outline-none focus:ring-2 focus:ring-primary-fixed focus:ring-offset-0" 589 + /> 590 + {search && ( 591 + <span className="absolute right-3 top-1/2 -translate-y-1/2 text-label-sm text-on-surface-variant" aria-live="polite"> 592 + {matchCount} match{matchCount !== 1 ? "es" : ""} 593 + </span> 594 + )} 595 + </div> 596 + </div> 597 + 598 + {/* Segments */} 599 + <div 600 + ref={scrollRef} 601 + className="flex-1 overflow-y-auto px-3 pb-3" 602 + onScroll={handleScroll} 603 + > 604 + {filteredSegments.length === 0 && search && ( 605 + <p className="text-body-md text-on-surface-variant py-8 text-center"> 606 + No matches 607 + </p> 608 + )} 609 + {filteredSegments.map((segment, idx) => { 610 + const isActive = segments[activeIndex]?.id === segment.id; 611 + const prevSegment = idx > 0 ? filteredSegments[idx - 1] : null; 612 + const showSpeaker = !prevSegment || prevSegment.speaker !== segment.speaker; 613 + 614 + return ( 615 + <button 616 + key={segment.id} 617 + ref={isActive ? activeRef : undefined} 618 + onClick={() => handleSegmentClick(segment.startMs)} 619 + aria-label={`${resolveSpeaker(segment.speaker, speakers)}, ${formatTimestamp(segment.startMs)}: ${segment.text.slice(0, 50)}`} 620 + className={[ 621 + "w-full text-left px-3 py-2 rounded-lg transition-all duration-150 cursor-pointer", 622 + "hover:bg-surface-container-high", 623 + isActive 624 + ? "border-l-2 border-primary-fixed bg-surface-container-high" 625 + : "border-l-2 border-transparent", 626 + ].join(" ")} 627 + > 628 + {showSpeaker && ( 629 + <span className="text-body-md font-bold text-primary-fixed-dim block mb-0.5"> 630 + {resolveSpeaker(segment.speaker, speakers)} 631 + </span> 632 + )} 633 + <span className="text-label-sm text-on-surface-variant mr-2"> 634 + {formatTimestamp(segment.startMs)} 635 + </span> 636 + <span className="text-body-lg text-on-surface"> 637 + {highlightText(segment.text)} 638 + </span> 639 + </button> 640 + ); 641 + })} 642 + </div> 643 + 644 + {/* Follow button */} 645 + {userScrolled && !search && ( 646 + <button 647 + onClick={() => setUserScrolled(false)} 648 + aria-label="Resume auto-scroll" 649 + className="absolute bottom-4 right-4 rounded-full bg-primary-fixed text-on-primary px-3 py-1.5 text-label-sm biolume-glow hover:biolume-glow-strong transition-shadow" 650 + > 651 + ↓ Follow 652 + </button> 653 + )} 654 + </div> 655 + ); 656 + } 657 + ``` 658 + 659 + - [ ] **Step 2: Verify it compiles** 660 + 661 + Run: `npx tsc --noEmit` 662 + Expected: No errors. 663 + 664 + - [ ] **Step 3: Commit** 665 + 666 + ```bash 667 + git add src/components/transcript-panel.tsx 668 + git commit -m "feat: add transcript panel with search, auto-scroll, click-to-seek" 669 + ``` 670 + 671 + --- 672 + 673 + ### Task 7: TalkPageClient coordinator 674 + 675 + **Files:** 676 + - Create: `src/components/talk-page-client.tsx` 677 + 678 + - [ ] **Step 1: Write the client coordinator component** 679 + 680 + ```tsx 681 + "use client"; 682 + 683 + import { useState, useCallback, useRef } from "react"; 684 + import { VideoPlayer } from "./video-player"; 685 + import { TranscriptPanel } from "./transcript-panel"; 686 + import type { TranscriptSegment, Speaker, SeekTarget } from "@/lib/types"; 687 + 688 + interface TalkPageClientProps { 689 + hlsUrl: string; 690 + segments: TranscriptSegment[]; 691 + speakers: Speaker[]; 692 + } 693 + 694 + export function TalkPageClient({ hlsUrl, segments, speakers }: TalkPageClientProps) { 695 + const [currentTimeMs, setCurrentTimeMs] = useState(0); 696 + const [seekTo, setSeekTo] = useState<SeekTarget | null>(null); 697 + const seekCounter = useRef(0); 698 + 699 + const handleTimeUpdate = useCallback((timeMs: number) => { 700 + setCurrentTimeMs(timeMs); 701 + }, []); 702 + 703 + const handleSeek = useCallback((timeMs: number) => { 704 + seekCounter.current += 1; 705 + setSeekTo({ timeMs, id: seekCounter.current }); 706 + }, []); 707 + 708 + return ( 709 + <div className="flex flex-col lg:flex-row gap-6"> 710 + {/* Video — sticky on desktop */} 711 + <div className="w-full lg:w-[60%] lg:sticky lg:top-20 lg:self-start"> 712 + <VideoPlayer 713 + hlsUrl={hlsUrl} 714 + onTimeUpdate={handleTimeUpdate} 715 + seekTo={seekTo} 716 + /> 717 + </div> 718 + 719 + {/* Transcript */} 720 + <div className="w-full lg:w-[40%] relative"> 721 + <TranscriptPanel 722 + segments={segments} 723 + speakers={speakers} 724 + currentTimeMs={currentTimeMs} 725 + onSeek={handleSeek} 726 + /> 727 + </div> 728 + </div> 729 + ); 730 + } 731 + ``` 732 + 733 + - [ ] **Step 2: Verify it compiles** 734 + 735 + Run: `npx tsc --noEmit` 736 + Expected: No errors. 737 + 738 + - [ ] **Step 3: Commit** 739 + 740 + ```bash 741 + git add src/components/talk-page-client.tsx 742 + git commit -m "feat: add talk page client coordinator (video ↔ transcript)" 743 + ``` 744 + 745 + --- 746 + 747 + ## Chunk 3: Page Route & Integration 748 + 749 + ### Task 8: Talk page server component 750 + 751 + **Files:** 752 + - Create: `src/app/talk/[rkey]/page.tsx` 753 + 754 + - [ ] **Step 1: Create the directory** 755 + 756 + Run: `mkdir -p src/app/talk/\[rkey\]` 757 + 758 + - [ ] **Step 2: Write the talk page** 759 + 760 + ```tsx 761 + import { notFound } from "next/navigation"; 762 + import * as fs from "fs"; 763 + import * as path from "path"; 764 + import { Nav } from "@/components/ui/nav"; 765 + import { Chip } from "@/components/ui/chip"; 766 + import { TalkPageClient } from "@/components/talk-page-client"; 767 + import { splitUtterances } from "@/lib/transcript"; 768 + import { formatDuration, formatDate, resolveSpeaker } from "@/lib/format"; 769 + import type { TalkEntry, TranscriptData } from "@/lib/types"; 770 + 771 + const DATA_DIR = path.resolve(process.cwd(), "data"); 772 + 773 + function loadTalks(): TalkEntry[] { 774 + const raw = fs.readFileSync(path.join(DATA_DIR, "talks.json"), "utf-8"); 775 + return JSON.parse(raw); 776 + } 777 + 778 + function loadTranscript(transcriptFile: string): TranscriptData { 779 + const raw = fs.readFileSync(path.join(DATA_DIR, transcriptFile), "utf-8"); 780 + return JSON.parse(raw); 781 + } 782 + 783 + export async function generateStaticParams() { 784 + const talks = loadTalks(); 785 + return talks 786 + .filter((t) => t.transcriptFile) 787 + .map((t) => ({ rkey: t.rkey })); 788 + } 789 + 790 + export async function generateMetadata({ params }: { params: Promise<{ rkey: string }> }) { 791 + const { rkey } = await params; 792 + const talks = loadTalks(); 793 + const talk = talks.find((t) => t.rkey === rkey); 794 + if (!talk) return { title: "Talk not found" }; 795 + return { 796 + title: `${talk.title} — Understory`, 797 + description: talk.description ?? `Watch ${talk.title} from ATmosphereConf 2026`, 798 + }; 799 + } 800 + 801 + export default async function TalkPage({ params }: { params: Promise<{ rkey: string }> }) { 802 + const { rkey } = await params; 803 + const talks = loadTalks(); 804 + const talk = talks.find((t) => t.rkey === rkey); 805 + 806 + if (!talk) notFound(); 807 + 808 + // Load and split transcript 809 + let segments: ReturnType<typeof splitUtterances> = []; 810 + if (talk.transcriptFile) { 811 + const transcript = loadTranscript(talk.transcriptFile); 812 + segments = splitUtterances(transcript.transcription.utterances); 813 + } 814 + 815 + return ( 816 + <> 817 + <Nav /> 818 + <main className="mx-auto max-w-7xl px-6 pt-24 pb-16"> 819 + {/* Header */} 820 + <header className="mb-8"> 821 + <h1 className="text-headline-md text-on-surface mb-3"> 822 + {talk.title} 823 + </h1> 824 + 825 + {/* Speakers */} 826 + {talk.speakers.length > 0 && ( 827 + <p className="text-label-md text-on-surface-variant mb-3"> 828 + {talk.speakers 829 + .map((s) => `${s.name} @${s.id}`) 830 + .join(" · ")} 831 + </p> 832 + )} 833 + 834 + {/* Metadata chips */} 835 + <div className="flex flex-wrap gap-2 mb-4"> 836 + {talk.room && <Chip>{talk.room}</Chip>} 837 + {talk.talkType && <Chip>{talk.talkType}</Chip>} 838 + <Chip>{formatDuration(talk.durationMs)}</Chip> 839 + {talk.startsAt && <Chip>{formatDate(talk.startsAt)}</Chip>} 840 + </div> 841 + 842 + {/* Description */} 843 + {talk.description && ( 844 + <p className="text-body-md text-on-surface-variant max-w-3xl"> 845 + {talk.description} 846 + </p> 847 + )} 848 + </header> 849 + 850 + {/* Video + Transcript */} 851 + {segments.length > 0 ? ( 852 + <TalkPageClient 853 + hlsUrl={talk.hlsUrl} 854 + segments={segments} 855 + speakers={talk.speakers} 856 + /> 857 + ) : ( 858 + <div className="rounded-lg overflow-hidden bg-surface-container-lowest"> 859 + <video 860 + controls 861 + className="w-full aspect-video" 862 + src={talk.hlsUrl} 863 + playsInline 864 + > 865 + Your browser does not support video playback. 866 + </video> 867 + </div> 868 + )} 869 + </main> 870 + </> 871 + ); 872 + } 873 + ``` 874 + 875 + - [ ] **Step 3: Verify the build compiles** 876 + 877 + Run: `npm run build` 878 + Expected: Build succeeds. Should show `/talk/[rkey]` route with 115 static params generated. 879 + 880 + - [ ] **Step 4: Test in dev mode** 881 + 882 + Run: `npm run dev` 883 + Open: http://localhost:3000/talk/3mi2jdevvu626 884 + 885 + Verify: 886 + - Page loads with talk title "Keynote: Towards Modular Open Science..." 887 + - Speaker names and metadata chips display 888 + - Video player loads and plays HLS stream 889 + - Transcript panel shows sentence-level segments 890 + - Clicking a transcript segment seeks the video 891 + - Active segment highlights as video plays 892 + - Search filters transcript segments and highlights matches 893 + - Auto-scroll follows playback 894 + - Manual scrolling pauses auto-scroll, "Follow" button appears 895 + 896 + - [ ] **Step 5: Commit** 897 + 898 + ```bash 899 + git add src/app/talk/ 900 + git commit -m "feat: add talk page with video player, transcript, and search" 901 + ``` 902 + 903 + --- 904 + 905 + ### Task 9: Lint and type check 906 + 907 + - [ ] **Step 1: Run eslint** 908 + 909 + Run: `npx eslint src/` 910 + Fix any issues that appear. 911 + 912 + - [ ] **Step 2: Run type check** 913 + 914 + Run: `npx tsc --noEmit` 915 + Expected: No errors. 916 + 917 + - [ ] **Step 3: Run production build** 918 + 919 + Run: `npm run build` 920 + Expected: Build succeeds with all talk pages statically generated. 921 + 922 + - [ ] **Step 4: Commit fixes if any** 923 + 924 + ```bash 925 + git add src/ 926 + git commit -m "fix: resolve lint and type issues in talk page" 927 + ```
+224
docs/superpowers/specs/2026-04-06-talk-page.md
··· 1 + # Talk Page Spec 2 + 3 + **Date:** 2026-04-06 4 + **Issues:** Chainlink #6, #7, #8, #9 5 + **Status:** Approved 6 + 7 + --- 8 + 9 + ## Overview 10 + 11 + The talk page (`/talk/[rkey]`) is the core content experience for Understory. It displays an ATmosphereConf talk with HLS video playback, a synchronized scrolling transcript with click-to-seek, and in-transcript search. The page is statically generated from local data files. 12 + 13 + --- 14 + 15 + ## 1. Page Layout 16 + 17 + **Route:** `/talk/[rkey]` — dynamic route, statically generated via `generateStaticParams`. 18 + 19 + ### Structure (top to bottom) 20 + 21 + 1. **Top Nav** — existing `Nav` component (misty glass, fixed) 22 + 2. **Header** — full-width, below nav 23 + - Talk title: `headline-md` (Newsreader) 24 + - Speakers: `label-md` (Space Grotesk) — "Speaker Name @handle" for each 25 + - Metadata chips: room, talk type, formatted duration — using `Chip` component 26 + - Description: `body-md` (Work Sans), `on-surface-variant` color 27 + 3. **Content Split** — two-column layout below header 28 + - **Left column (~60%)**: HLS video player, sticky-positioned 29 + - **Right column (~40%)**: Transcript panel with search, scrollable 30 + 31 + ### Responsive 32 + 33 + - Desktop (>1024px): side-by-side video + transcript 34 + - Tablet/Mobile (<=1024px): stacks vertically — header → video (not sticky) → transcript 35 + 36 + --- 37 + 38 + ## 2. Data Loading 39 + 40 + ### Sources 41 + 42 + | Data | File | Shape | 43 + |------|------|-------| 44 + | Talk metadata | `data/talks.json` | Array of `TalkEntry` objects (see `scripts/build-talk-index.ts`) | 45 + | Transcript | `data/transcripts/[rkey].json` | `{ uri, cid, title, creator, duration, createdAt, transcription: { id, status, text, utterances, words, audio_duration } }` | 46 + 47 + ### Utterance Shape 48 + 49 + These interfaces are the subset needed for the talk page. The full transcript JSON also has `confidence` on both utterances and words (not used in the UI). 50 + 51 + ```ts 52 + interface Utterance { 53 + speaker: string; // "A", "B", "C" 54 + text: string; // Full utterance text 55 + start: number; // Start time in ms 56 + end: number; // End time in ms 57 + words: Word[]; // Word-level timing 58 + confidence?: number; // 0-1 (not displayed) 59 + } 60 + 61 + interface Word { 62 + text: string; 63 + start: number; // Start time in ms 64 + end: number; // End time in ms 65 + speaker: string; 66 + confidence?: number; // 0-1 (not displayed) 67 + } 68 + ``` 69 + 70 + ### Static Generation 71 + 72 + - `generateStaticParams`: reads `data/talks.json`, returns `{ rkey }` for each talk with a `transcriptFile` 73 + - Page component: reads talk entry from `talks.json` by rkey, reads transcript file, passes both as props to client components 74 + 75 + --- 76 + 77 + ## 3. HLS Video Player (Issue #7) 78 + 79 + ### Dependencies 80 + 81 + - `hls.js` — HLS adaptive streaming library 82 + 83 + ### Component: `VideoPlayer` 84 + 85 + **Props:** 86 + - `hlsUrl: string` — HLS playlist URL 87 + - `onTimeUpdate: (timeMs: number) => void` — callback with current playback position in ms 88 + - `seekTo: { timeMs: number; id: number } | null` — when set, seeks the video to `timeMs`. The `id` is an incrementing counter so re-clicking the same segment triggers a new seek. 89 + 90 + **Behavior:** 91 + - Initializes HLS.js, attaches to a `<video>` element 92 + - Native `<video>` controls (no custom controls for now — simpler, accessible, functional) 93 + - Fires `onTimeUpdate` on the video's `timeupdate` event (~4x/sec) 94 + - When `seekTo.id` changes, seeks the video to `seekTo.timeMs` 95 + - Falls back to native HLS on Safari (no hls.js needed) 96 + - Player container: `rounded-lg overflow-hidden` with `surface-container-lowest` background 97 + 98 + **Sticky behavior (desktop only):** 99 + - `position: sticky; top: 5rem` (below the fixed nav) 100 + - `self-start` to prevent stretching in the flex layout 101 + 102 + --- 103 + 104 + ## 4. Transcript Panel (Issue #8) 105 + 106 + ### Sentence Splitting 107 + 108 + Utterances are split into sentence-level segments for better click-to-seek precision: 109 + 110 + 1. Split utterance `text` on sentence boundaries: `/(?<=[.!?])\s+/` 111 + 2. Walk the utterance's `words` array sequentially with a pointer. For each sentence: 112 + a. Split the sentence into tokens on whitespace 113 + b. Match tokens against consecutive words starting from the current pointer position. Strip trailing punctuation from both sides for comparison (e.g., `"it's,"` → `"it's"`). 114 + c. Record the first matched word's `start` as the segment's `startMs` 115 + d. Record the last matched word's `end` as the segment's `endMs` 116 + e. Advance the pointer past all consumed words 117 + 3. If word matching fails for a sentence (e.g., text normalization differences), fall back to interpolating the time range proportionally based on character offset within the utterance. 118 + 4. Preserve the utterance's `speaker` label 119 + 120 + ```ts 121 + interface TranscriptSegment { 122 + id: string; // "u0-s0", "u0-s1", etc. 123 + speaker: string; // "A", "B" 124 + text: string; // Sentence text 125 + startMs: number; // Derived from word timing 126 + endMs: number; // Derived from word timing 127 + } 128 + ``` 129 + 130 + This splitting happens once at build time (in the page's server component), not at runtime. 131 + 132 + ### Component: `TranscriptPanel` 133 + 134 + **Props:** 135 + - `segments: TranscriptSegment[]` — pre-split sentence segments 136 + - `currentTimeMs: number` — current video playback position 137 + - `onSeek: (timeMs: number) => void` — callback when user clicks a segment 138 + 139 + **Behavior:** 140 + - Scrollable panel with `surface-container-low` background 141 + - Height on desktop: `height: calc(100vh - 5rem)` with `overflow-y: auto` (5rem = nav height + padding). On mobile, natural flow (no fixed height). 142 + - Each segment rendered as a row: 143 + - Speaker label: `body-md` **bold**, `primary-fixed-dim` color (only shown when speaker changes from previous segment). Uses resolved speaker name where possible (see Section 7), not uppercase. 144 + - Timestamp: `label-sm`, `on-surface-variant`, formatted as `MM:SS` — clickable 145 + - Text: `body-lg`, `on-surface` color 146 + - **Active segment** (where `currentTimeMs` falls between `startMs` and `endMs`): 147 + - Left border: 2px `primary-fixed` 148 + - Background: `surface-container-high` 149 + - Transition: 150ms ease 150 + - **Click-to-seek**: clicking anywhere on a segment calls `onSeek(segment.startMs)` 151 + - **Auto-scroll**: when the active segment changes, scroll it into view using `scrollIntoView({ behavior: 'smooth', block: 'center' })` 152 + - **Scroll lock**: if the user manually scrolls, pause auto-scroll. Resume when the user clicks a segment or presses a "resume follow" button. 153 + 154 + ### Scroll Lock Logic 155 + 156 + - Track `userScrolled` state 157 + - Set `userScrolled = true` on manual scroll events (distinguish from programmatic scroll) 158 + - Set `userScrolled = false` when user clicks a segment 159 + - Show a small "↓ Follow" button (floating, bottom-right of transcript panel) when `userScrolled` is true 160 + 161 + --- 162 + 163 + ## 5. Transcript Search (Issue #9) 164 + 165 + ### Component: Integrated into `TranscriptPanel` 166 + 167 + **Behavior:** 168 + - Search input at the top of the transcript panel 169 + - Styled per design system: `surface-container-highest` background, `primary-fixed` focus glow 170 + - As user types, filter segments to those containing the search term (case-insensitive) 171 + - Highlighted matches: wrap matching text in a `<mark>` with `bg-primary-fixed/20 text-on-surface` styling 172 + - Match count shown as `label-sm` to the right of the input: "12 matches" 173 + - Clicking a filtered segment still triggers seek 174 + - Empty search shows all segments (default state) 175 + - Auto-scroll is paused while a search filter is active (the active segment may not be in the filtered results). Auto-scroll resumes when the search is cleared. 176 + 177 + --- 178 + 179 + ## 6. Component Architecture 180 + 181 + ``` 182 + /talk/[rkey]/page.tsx (Server Component — data loading, sentence splitting) 183 + └─ TalkPageClient.tsx (Client Component — state coordination) 184 + ├─ VideoPlayer.tsx (HLS.js player, exposes time + seek) 185 + └─ TranscriptPanel.tsx (Segments, search, auto-scroll, click-to-seek) 186 + ``` 187 + 188 + **State lives in `TalkPageClient`:** 189 + - `currentTimeMs: number` — from video's timeupdate 190 + - `seekTo: { timeMs: number; id: number } | null` — set when user clicks a transcript segment. `id` increments on each click to allow re-seeking to the same timestamp. 191 + - `seekCounter: number` — ref that increments on each seek request 192 + 193 + **Data flow:** 194 + - Video → `onTimeUpdate` → `TalkPageClient` updates `currentTimeMs` → passed to `TranscriptPanel` 195 + - Transcript click → `onSeek` → `TalkPageClient` sets `seekTo` → passed to `VideoPlayer` 196 + 197 + --- 198 + 199 + ## 7. Formatting Helpers 200 + 201 + - **Duration**: `durationMs` → `"45 min"` or `"10 min"` (rounded to nearest minute). For durations under 1 minute, show `"< 1 min"`. 202 + - **Timestamp**: `startMs` → `"MM:SS"` format 203 + - **Speaker**: positional mapping from diarization labels to talk speaker list: "A" = `speakers[0]`, "B" = `speakers[1]`, etc. Show resolved name if available, fallback to "Speaker A". Note: this mapping is a best-effort assumption from AssemblyAI's diarization — labels are arbitrary and may not match speaker order perfectly, especially for 3+ speaker panels. 204 + - **Date/time**: `startsAt` → formatted as "Mar 27, 4:15 PM PDT" 205 + 206 + --- 207 + 208 + ## 8. Accessibility 209 + 210 + - Transcript segments are `<button>` elements (not clickable divs) for native keyboard support and screen reader semantics 211 + - Each segment button has `aria-label`: "[Speaker Name], [timestamp]: [first 50 chars of text]..." 212 + - Transcript panel has `role="region"` with `aria-label="Transcript"` 213 + - Search input has `aria-label="Search transcript"` and the match count uses `aria-live="polite"` to announce updates 214 + - The "Follow" button (scroll lock resume) has `aria-label="Resume auto-scroll"` 215 + - Active segment change does NOT trigger `aria-live` announcements (too frequent, would overwhelm screen readers) 216 + 217 + --- 218 + 219 + ## 9. Error & Edge Cases 220 + 221 + - **No transcript**: if `transcriptFile` is null, hide the transcript panel entirely. Show video full-width with metadata. 222 + - **Invalid rkey**: Next.js `notFound()` — returns 404. 223 + - **HLS load failure**: show error message in the video container: "Video unavailable. Try refreshing." 224 + - **Empty search results**: show "No matches" message in transcript panel.
+122
src/app/talk/[rkey]/page.tsx
··· 1 + import { notFound } from "next/navigation"; 2 + import * as fs from "fs"; 3 + import * as path from "path"; 4 + import { Nav } from "@/components/ui/nav"; 5 + import { Chip } from "@/components/ui/chip"; 6 + import { TalkPageClient } from "@/components/talk-page-client"; 7 + import { splitUtterances } from "@/lib/transcript"; 8 + import { formatDuration, formatDate } from "@/lib/format"; 9 + import type { TalkEntry, TranscriptData } from "@/lib/types"; 10 + 11 + const DATA_DIR = path.resolve(process.cwd(), "data"); 12 + 13 + function loadTalks(): TalkEntry[] { 14 + const raw = fs.readFileSync(path.join(DATA_DIR, "talks.json"), "utf-8"); 15 + return JSON.parse(raw); 16 + } 17 + 18 + function loadTranscript(transcriptFile: string): TranscriptData { 19 + const raw = fs.readFileSync(path.join(DATA_DIR, transcriptFile), "utf-8"); 20 + return JSON.parse(raw); 21 + } 22 + 23 + export async function generateStaticParams() { 24 + const talks = loadTalks(); 25 + return talks 26 + .filter((t) => t.transcriptFile) 27 + .map((t) => ({ rkey: t.rkey })); 28 + } 29 + 30 + export async function generateMetadata({ 31 + params, 32 + }: { 33 + params: Promise<{ rkey: string }>; 34 + }) { 35 + const { rkey } = await params; 36 + const talks = loadTalks(); 37 + const talk = talks.find((t) => t.rkey === rkey); 38 + if (!talk) return { title: "Talk not found" }; 39 + return { 40 + title: `${talk.title} — Understory`, 41 + description: 42 + talk.description ?? 43 + `Watch ${talk.title} from ATmosphereConf 2026`, 44 + }; 45 + } 46 + 47 + export default async function TalkPage({ 48 + params, 49 + }: { 50 + params: Promise<{ rkey: string }>; 51 + }) { 52 + const { rkey } = await params; 53 + const talks = loadTalks(); 54 + const talk = talks.find((t) => t.rkey === rkey); 55 + 56 + if (!talk) notFound(); 57 + 58 + // Load and split transcript 59 + let segments: ReturnType<typeof splitUtterances> = []; 60 + if (talk.transcriptFile) { 61 + const transcript = loadTranscript(talk.transcriptFile); 62 + segments = splitUtterances(transcript.transcription.utterances); 63 + } 64 + 65 + return ( 66 + <> 67 + <Nav /> 68 + <main className="mx-auto max-w-7xl px-6 pt-24 pb-16"> 69 + {/* Header */} 70 + <header className="mb-8"> 71 + <h1 className="text-headline-md text-on-surface mb-3"> 72 + {talk.title} 73 + </h1> 74 + 75 + {/* Speakers */} 76 + {talk.speakers.length > 0 && ( 77 + <p className="text-label-md text-on-surface-variant mb-3"> 78 + {talk.speakers 79 + .map((s) => `${s.name} @${s.id}`) 80 + .join(" · ")} 81 + </p> 82 + )} 83 + 84 + {/* Metadata chips */} 85 + <div className="flex flex-wrap gap-2 mb-4"> 86 + {talk.room && <Chip>{talk.room}</Chip>} 87 + {talk.talkType && <Chip>{talk.talkType}</Chip>} 88 + <Chip>{formatDuration(talk.durationMs)}</Chip> 89 + {talk.startsAt && <Chip>{formatDate(talk.startsAt)}</Chip>} 90 + </div> 91 + 92 + {/* Description */} 93 + {talk.description && ( 94 + <p className="text-body-md text-on-surface-variant max-w-3xl"> 95 + {talk.description} 96 + </p> 97 + )} 98 + </header> 99 + 100 + {/* Video + Transcript */} 101 + {segments.length > 0 ? ( 102 + <TalkPageClient 103 + hlsUrl={talk.hlsUrl} 104 + segments={segments} 105 + speakers={talk.speakers} 106 + /> 107 + ) : ( 108 + <div className="rounded-lg overflow-hidden bg-surface-container-lowest"> 109 + <video 110 + controls 111 + className="w-full aspect-video" 112 + src={talk.hlsUrl} 113 + playsInline 114 + > 115 + Your browser does not support video playback. 116 + </video> 117 + </div> 118 + )} 119 + </main> 120 + </> 121 + ); 122 + }
+2
src/lib/transcript.ts
··· 19 19 ): TranscriptSegment[] { 20 20 const segments: TranscriptSegment[] = []; 21 21 22 + if (!utterances || utterances.length === 0) return segments; 23 + 22 24 for (let uIdx = 0; uIdx < utterances.length; uIdx++) { 23 25 const utterance = utterances[uIdx]; 24 26 const sentences = utterance.text.split(/(?<=[.!?])\s+/).filter(Boolean);