Ionosphere.tv
3
fork

Configure Feed

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

feat: concept sidebar pills seek to first mention in transcript

Each concept pill shows a timestamp tooltip and seeks the video to
where the concept is first mentioned when clicked. Timestamps derived
from overlapping timestamp facets in the document.

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

+45 -16
+45 -16
apps/ionosphere/src/app/talks/[rkey]/TalkContent.tsx
··· 1 1 "use client"; 2 2 3 3 import { useMemo, useRef, useState, useEffect, useCallback } from "react"; 4 - import { TimestampProvider } from "@/app/components/TimestampProvider"; 4 + import { TimestampProvider, useTimestamp } from "@/app/components/TimestampProvider"; 5 5 import VideoPlayer from "@/app/components/VideoPlayer"; 6 6 import TranscriptView from "@/app/components/TranscriptView"; 7 7 import { fetchComments, type CommentData } from "@/lib/comments"; 8 8 import ReactionBar from "@/app/components/ReactionBar"; 9 + 10 + function ConceptSidebar({ concepts }: { concepts: Array<{ rkey: string; name: string; timeNs?: number }> }) { 11 + const { seekTo } = useTimestamp(); 12 + return ( 13 + <section> 14 + <h2 className="text-xs font-semibold text-neutral-500 uppercase tracking-wide mb-2">Concepts</h2> 15 + <div className="flex flex-wrap gap-1.5"> 16 + {concepts.map((c) => ( 17 + <button 18 + key={c.rkey} 19 + onClick={() => c.timeNs ? seekTo(c.timeNs) : undefined} 20 + className="text-xs px-2 py-0.5 rounded-full bg-amber-500/10 text-amber-300/80 hover:bg-amber-500/20 hover:text-amber-200 transition-colors cursor-pointer" 21 + title={c.timeNs ? `Jump to ${Math.floor(c.timeNs / 1e9 / 60)}:${String(Math.floor((c.timeNs / 1e9) % 60)).padStart(2, "0")}` : c.name} 22 + > 23 + {c.name} 24 + </button> 25 + ))} 26 + </div> 27 + </section> 28 + ); 29 + } 9 30 10 31 interface TalkContentProps { 11 32 talk: any; ··· 59 80 }, [talk.document]); 60 81 61 82 // Derive concepts from document facets (concept-ref entities) 83 + // For each concept, find the timestamp of its first mention 62 84 const docConcepts = useMemo(() => { 63 85 if (!document) return []; 64 - const seen = new Map<string, { name: string; uri: string; rkey: string }>(); 86 + 87 + // Build a byte→time lookup from timestamp facets 88 + const byteToTime: Array<{ byteStart: number; byteEnd: number; startTime: number }> = []; 89 + for (const f of document.facets) { 90 + for (const feat of f.features) { 91 + if (feat.$type === "tv.ionosphere.facet#timestamp" && feat.startTime != null) { 92 + byteToTime.push({ byteStart: f.index.byteStart, byteEnd: f.index.byteEnd, startTime: feat.startTime }); 93 + } 94 + } 95 + } 96 + 97 + const seen = new Map<string, { name: string; uri: string; rkey: string; timeNs: number }>(); 65 98 for (const f of document.facets) { 66 99 for (const feat of f.features) { 67 100 if (feat.$type === "tv.ionosphere.facet#concept-ref" && feat.conceptUri) { 68 101 if (!seen.has(feat.conceptUri)) { 69 102 const rkey = feat.conceptUri.split("/").pop() || ""; 103 + // Find the nearest timestamp facet overlapping this byte range 104 + let timeNs = 0; 105 + for (const ts of byteToTime) { 106 + if (ts.byteStart < f.index.byteEnd && ts.byteEnd > f.index.byteStart) { 107 + timeNs = ts.startTime; 108 + break; 109 + } 110 + } 70 111 seen.set(feat.conceptUri, { 71 112 name: feat.conceptName || feat.label || rkey, 72 113 uri: feat.conceptUri, 73 114 rkey, 115 + timeNs, 74 116 }); 75 117 } 76 118 } ··· 216 258 {/* Right sidebar — concepts + cross-refs (hidden on mobile, scrollable on desktop) */} 217 259 <aside className="hidden lg:flex lg:flex-col lg:w-56 xl:w-64 shrink-0 border-l border-neutral-800 overflow-y-auto p-4 gap-5"> 218 260 {(concepts.length > 0 || docConcepts.length > 0) && ( 219 - <section> 220 - <h2 className="text-xs font-semibold text-neutral-500 uppercase tracking-wide mb-2">Concepts</h2> 221 - <div className="flex flex-wrap gap-1.5"> 222 - {(concepts.length > 0 ? concepts : docConcepts).map((c: any) => ( 223 - <a 224 - key={c.rkey} 225 - href={`/concepts/${c.rkey}`} 226 - className="text-xs px-2 py-0.5 rounded-full bg-amber-500/10 text-amber-300/80 hover:bg-amber-500/20 hover:text-amber-200 transition-colors" 227 - > 228 - {c.name} 229 - </a> 230 - ))} 231 - </div> 232 - </section> 261 + <ConceptSidebar concepts={concepts.length > 0 ? concepts : docConcepts} /> 233 262 )} 234 263 235 264 {/* Mobile speakers (shown below transcript on small screens) */}