The AtmosphereConf talks your skyline missed
0
fork

Configure Feed

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

feat: add video player, transcript panel, and talk page client

- VideoPlayer: HLS.js with Safari fallback, time reporting, seek control
- TranscriptPanel: sentence segments, active highlight, click-to-seek,
auto-scroll with scroll lock, in-transcript search with highlighting
- TalkPageClient: coordinates video ↔ transcript state

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

+301
+54
src/components/talk-page-client.tsx
··· 1 + "use client"; 2 + 3 + import { useState, useCallback, useRef } from "react"; 4 + import { VideoPlayer } from "./video-player"; 5 + import { TranscriptPanel } from "./transcript-panel"; 6 + import type { TranscriptSegment, Speaker, SeekTarget } from "@/lib/types"; 7 + 8 + interface TalkPageClientProps { 9 + hlsUrl: string; 10 + segments: TranscriptSegment[]; 11 + speakers: Speaker[]; 12 + } 13 + 14 + export function TalkPageClient({ 15 + hlsUrl, 16 + segments, 17 + speakers, 18 + }: TalkPageClientProps) { 19 + const [currentTimeMs, setCurrentTimeMs] = useState(0); 20 + const [seekTo, setSeekTo] = useState<SeekTarget | null>(null); 21 + const seekCounter = useRef(0); 22 + 23 + const handleTimeUpdate = useCallback((timeMs: number) => { 24 + setCurrentTimeMs(timeMs); 25 + }, []); 26 + 27 + const handleSeek = useCallback((timeMs: number) => { 28 + seekCounter.current += 1; 29 + setSeekTo({ timeMs, id: seekCounter.current }); 30 + }, []); 31 + 32 + return ( 33 + <div className="flex flex-col lg:flex-row gap-6"> 34 + {/* Video — sticky on desktop */} 35 + <div className="w-full lg:w-[60%] lg:sticky lg:top-20 lg:self-start"> 36 + <VideoPlayer 37 + hlsUrl={hlsUrl} 38 + onTimeUpdate={handleTimeUpdate} 39 + seekTo={seekTo} 40 + /> 41 + </div> 42 + 43 + {/* Transcript */} 44 + <div className="w-full lg:w-[40%] relative"> 45 + <TranscriptPanel 46 + segments={segments} 47 + speakers={speakers} 48 + currentTimeMs={currentTimeMs} 49 + onSeek={handleSeek} 50 + /> 51 + </div> 52 + </div> 53 + ); 54 + }
+170
src/components/transcript-panel.tsx
··· 1 + "use client"; 2 + 3 + import { useState, useRef, useEffect, useCallback, useMemo } from "react"; 4 + import type { TranscriptSegment, Speaker } from "@/lib/types"; 5 + import { formatTimestamp, resolveSpeaker } from "@/lib/format"; 6 + 7 + interface TranscriptPanelProps { 8 + segments: TranscriptSegment[]; 9 + speakers: Speaker[]; 10 + currentTimeMs: number; 11 + onSeek: (timeMs: number) => void; 12 + } 13 + 14 + export function TranscriptPanel({ 15 + segments, 16 + speakers, 17 + currentTimeMs, 18 + onSeek, 19 + }: TranscriptPanelProps) { 20 + const [search, setSearch] = useState(""); 21 + const [userScrolled, setUserScrolled] = useState(false); 22 + const activeRef = useRef<HTMLButtonElement>(null); 23 + const programmaticScroll = useRef(false); 24 + 25 + // Find active segment 26 + const activeIndex = useMemo(() => { 27 + for (let i = segments.length - 1; i >= 0; i--) { 28 + if (currentTimeMs >= segments[i].startMs) return i; 29 + } 30 + return -1; 31 + }, [segments, currentTimeMs]); 32 + 33 + // Filter by search 34 + const searchLower = search.toLowerCase(); 35 + const filteredSegments = useMemo(() => { 36 + if (!search) return segments; 37 + return segments.filter((s) => s.text.toLowerCase().includes(searchLower)); 38 + }, [segments, search, searchLower]); 39 + 40 + const matchCount = search ? filteredSegments.length : 0; 41 + 42 + // Auto-scroll to active segment 43 + useEffect(() => { 44 + if (userScrolled || search || !activeRef.current) return; 45 + programmaticScroll.current = true; 46 + activeRef.current.scrollIntoView({ behavior: "smooth", block: "center" }); 47 + setTimeout(() => { 48 + programmaticScroll.current = false; 49 + }, 500); 50 + }, [activeIndex, userScrolled, search]); 51 + 52 + // Detect manual scroll 53 + const handleScroll = useCallback(() => { 54 + if (programmaticScroll.current) return; 55 + setUserScrolled(true); 56 + }, []); 57 + 58 + // Click a segment to seek 59 + const handleSegmentClick = (startMs: number) => { 60 + setUserScrolled(false); 61 + onSeek(startMs); 62 + }; 63 + 64 + // Highlight matching text 65 + function highlightText(text: string): React.ReactNode { 66 + if (!search) return text; 67 + const regex = new RegExp( 68 + `(${search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, 69 + "gi", 70 + ); 71 + const parts = text.split(regex); 72 + return parts.map((part, i) => 73 + regex.test(part) ? ( 74 + <mark 75 + key={i} 76 + className="bg-primary-fixed/20 text-on-surface rounded-sm px-0.5" 77 + > 78 + {part} 79 + </mark> 80 + ) : ( 81 + part 82 + ), 83 + ); 84 + } 85 + 86 + return ( 87 + <div className="flex flex-col bg-surface-container-low lg:h-[calc(100vh-5rem)] rounded-lg relative"> 88 + {/* Search */} 89 + <div className="sticky top-0 z-10 p-3 bg-surface-container-low rounded-t-lg"> 90 + <div className="relative"> 91 + <input 92 + type="text" 93 + value={search} 94 + onChange={(e) => setSearch(e.target.value)} 95 + placeholder="Search transcript..." 96 + aria-label="Search transcript" 97 + 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" 98 + /> 99 + {search && ( 100 + <span 101 + className="absolute right-3 top-1/2 -translate-y-1/2 text-label-sm text-on-surface-variant" 102 + aria-live="polite" 103 + > 104 + {matchCount} match{matchCount !== 1 ? "es" : ""} 105 + </span> 106 + )} 107 + </div> 108 + </div> 109 + 110 + {/* Segments */} 111 + <div 112 + className="flex-1 overflow-y-auto px-3 pb-3" 113 + onScroll={handleScroll} 114 + role="region" 115 + aria-label="Transcript" 116 + > 117 + {filteredSegments.length === 0 && search && ( 118 + <p className="text-body-md text-on-surface-variant py-8 text-center"> 119 + No matches 120 + </p> 121 + )} 122 + {filteredSegments.map((segment, idx) => { 123 + const isActive = segments[activeIndex]?.id === segment.id; 124 + const prevSegment = idx > 0 ? filteredSegments[idx - 1] : null; 125 + const showSpeaker = 126 + !prevSegment || prevSegment.speaker !== segment.speaker; 127 + 128 + return ( 129 + <button 130 + key={segment.id} 131 + ref={isActive ? activeRef : undefined} 132 + onClick={() => handleSegmentClick(segment.startMs)} 133 + aria-label={`${resolveSpeaker(segment.speaker, speakers)}, ${formatTimestamp(segment.startMs)}: ${segment.text.slice(0, 50)}`} 134 + className={[ 135 + "w-full text-left px-3 py-2 rounded-lg transition-all duration-150 cursor-pointer", 136 + "hover:bg-surface-container-high", 137 + isActive 138 + ? "border-l-2 border-primary-fixed bg-surface-container-high" 139 + : "border-l-2 border-transparent", 140 + ].join(" ")} 141 + > 142 + {showSpeaker && ( 143 + <span className="text-body-md font-bold text-primary-fixed-dim block mb-0.5"> 144 + {resolveSpeaker(segment.speaker, speakers)} 145 + </span> 146 + )} 147 + <span className="text-label-sm text-on-surface-variant mr-2"> 148 + {formatTimestamp(segment.startMs)} 149 + </span> 150 + <span className="text-body-lg text-on-surface"> 151 + {highlightText(segment.text)} 152 + </span> 153 + </button> 154 + ); 155 + })} 156 + </div> 157 + 158 + {/* Follow button */} 159 + {userScrolled && !search && ( 160 + <button 161 + onClick={() => setUserScrolled(false)} 162 + aria-label="Resume auto-scroll" 163 + 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" 164 + > 165 + ↓ Follow 166 + </button> 167 + )} 168 + </div> 169 + ); 170 + }
+77
src/components/video-player.tsx
··· 1 + "use client"; 2 + 3 + import { useRef, useEffect, useCallback } from "react"; 4 + import Hls from "hls.js"; 5 + import type { SeekTarget } from "@/lib/types"; 6 + 7 + interface VideoPlayerProps { 8 + hlsUrl: string; 9 + onTimeUpdate: (timeMs: number) => void; 10 + seekTo: SeekTarget | null; 11 + } 12 + 13 + export function VideoPlayer({ hlsUrl, onTimeUpdate, seekTo }: VideoPlayerProps) { 14 + const videoRef = useRef<HTMLVideoElement>(null); 15 + const hlsRef = useRef<Hls | null>(null); 16 + const lastSeekId = useRef<number>(-1); 17 + 18 + // Initialize HLS 19 + useEffect(() => { 20 + const video = videoRef.current; 21 + if (!video) return; 22 + 23 + if (Hls.isSupported()) { 24 + const hls = new Hls(); 25 + hls.loadSource(hlsUrl); 26 + hls.attachMedia(video); 27 + hlsRef.current = hls; 28 + 29 + hls.on(Hls.Events.ERROR, (_event, data) => { 30 + if (data.fatal) { 31 + console.error("HLS fatal error:", data.type, data.details); 32 + } 33 + }); 34 + 35 + return () => { 36 + hls.destroy(); 37 + hlsRef.current = null; 38 + }; 39 + } else if (video.canPlayType("application/vnd.apple.mpegurl")) { 40 + // Safari native HLS 41 + video.src = hlsUrl; 42 + } 43 + }, [hlsUrl]); 44 + 45 + // Time update handler 46 + const handleTimeUpdate = useCallback(() => { 47 + const video = videoRef.current; 48 + if (video) { 49 + onTimeUpdate(Math.round(video.currentTime * 1000)); 50 + } 51 + }, [onTimeUpdate]); 52 + 53 + // Seek when seekTo changes 54 + useEffect(() => { 55 + const video = videoRef.current; 56 + if (!video || !seekTo || seekTo.id === lastSeekId.current) return; 57 + lastSeekId.current = seekTo.id; 58 + video.currentTime = seekTo.timeMs / 1000; 59 + if (video.paused) { 60 + video.play().catch(() => {}); 61 + } 62 + }, [seekTo]); 63 + 64 + return ( 65 + <div className="rounded-lg overflow-hidden bg-surface-container-lowest"> 66 + <video 67 + ref={videoRef} 68 + controls 69 + className="w-full aspect-video" 70 + onTimeUpdate={handleTimeUpdate} 71 + playsInline 72 + > 73 + Your browser does not support video playback. 74 + </video> 75 + </div> 76 + ); 77 + }