Ionosphere.tv
3
fork

Configure Feed

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

fix: transcript follows video seek + chunked loading improvements

- WindowedTranscriptView: snap scroll position on large seeks (was
only updating velocity, not position, for jumps > 1s)
- Chunked transcript: aggressive prefetch (±3 behind, +5 ahead),
throttled to 1Hz, properly grows document as chunks arrive
- Fixed stream URI mismatch in chunked transcript endpoint (was using
stream_video_uri instead of stream record uri)

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

+68 -34
+7 -4
apps/ionosphere-appview/src/tracks.ts
··· 124 124 export function getTranscriptManifest(db: Database.Database, slug: string): TranscriptManifest | null { 125 125 const dbStream = getStreamFromDb(db, slug); 126 126 const hardcoded = STREAMS.find((s) => s.slug === slug); 127 - const streamUri = dbStream?.stream_video_uri ?? hardcoded?.uri; 128 - if (!streamUri) return null; 127 + // stream_transcripts reference the tv.ionosphere.stream URI, not the place.stream.video URI 128 + const streamUri = dbStream?.uri ?? null; 129 + const streamVideoUri = dbStream?.stream_video_uri ?? hardcoded?.uri; 130 + if (!streamUri && !streamVideoUri) return null; 129 131 130 132 const durationSeconds = dbStream?.duration_seconds ?? hardcoded?.durationSeconds ?? 0; 131 133 const totalDurationMs = durationSeconds * 1000; ··· 159 161 export function getTranscriptChunk(db: Database.Database, slug: string, chunkIndex: number): TranscriptChunk | null { 160 162 const dbStream = getStreamFromDb(db, slug); 161 163 const hardcoded = STREAMS.find((s) => s.slug === slug); 162 - const streamUri = dbStream?.stream_video_uri ?? hardcoded?.uri; 163 - if (!streamUri) return null; 164 + const streamUri = dbStream?.uri ?? null; 165 + const streamVideoUri = dbStream?.stream_video_uri ?? hardcoded?.uri; 166 + if (!streamUri && !streamVideoUri) return null; 164 167 165 168 const durationSeconds = dbStream?.duration_seconds ?? hardcoded?.durationSeconds ?? 0; 166 169 const totalDurationMs = durationSeconds * 1000;
+3 -1
apps/ionosphere/src/app/components/WindowedTranscriptView.tsx
··· 298 298 scrollVelocity.current = scrollVelocity.current * 0.7 + newVelocity * 0.3; 299 299 } 300 300 } else { 301 - // Seek or pause — stop velocity 301 + // Seek or large jump — snap to new position, stop velocity 302 + const viewportH = containerRef.current.clientHeight; 303 + scrollTargetRef.current = timeToScrollY(currentTimeNs) - viewportH * playheadFrac; 302 304 scrollVelocity.current = 0; 303 305 } 304 306
+58 -29
apps/ionosphere/src/app/tracks/[stream]/TrackViewContent.tsx
··· 131 131 // Chunked transcript state 132 132 const manifestRef = useRef<any>(null); 133 133 const loadedChunksRef = useRef<Map<number, any>>(new Map()); 134 - const lastChunkIndexRef = useRef(-1); 134 + const loadingChunksRef = useRef<Set<number>>(new Set()); 135 + const chunkCountRef = useRef(0); 135 136 136 137 const { currentTimeNs } = useTimestamp(); 137 138 const currentTimeSec = currentTimeNs / 1e9; 138 139 140 + // Throttle chunk loading to once per second (not 60fps) 141 + const [chunkTimeSec, setChunkTimeSec] = useState(0); 142 + useEffect(() => { 143 + const rounded = Math.floor(currentTimeSec); 144 + if (rounded !== chunkTimeSec) setChunkTimeSec(rounded); 145 + }, [currentTimeSec, chunkTimeSec]); 146 + 139 147 // HLS-inspired chunked transcript loading 140 148 useEffect(() => { 141 149 if (activeTab !== "transcript") return; 142 - if (transcript) return; // already have full transcript 143 150 144 151 const CHUNK_DURATION_S = 300; // 5 minutes 152 + let cancelled = false; 145 153 146 154 async function loadManifest() { 147 155 if (manifestRef.current) return manifestRef.current; 148 156 setLoadingTranscript(true); 149 - const res = await fetch(`${API_BASE}/xrpc/tv.ionosphere.getTrackTranscript?stream=${encodeURIComponent(stream)}`); 150 - const data = await res.json(); 151 - if (data.chunkCount) { 152 - manifestRef.current = data; 153 - return data; 154 - } 157 + try { 158 + const res = await fetch(`${API_BASE}/xrpc/tv.ionosphere.getTrackTranscript?stream=${encodeURIComponent(stream)}`); 159 + const data = await res.json(); 160 + if (data.chunkCount) { 161 + manifestRef.current = data; 162 + return data; 163 + } 164 + } catch {} 155 165 // Fallback: load full transcript the old way 156 - const fullRes = await fetch(`${API_BASE}/xrpc/tv.ionosphere.getTrack?stream=${encodeURIComponent(stream)}&include=transcript`); 157 - const fullData = await fullRes.json(); 158 - if (fullData.transcript) setTranscript(fullData.transcript); 166 + try { 167 + const fullRes = await fetch(`${API_BASE}/xrpc/tv.ionosphere.getTrack?stream=${encodeURIComponent(stream)}&include=transcript`); 168 + const fullData = await fullRes.json(); 169 + if (fullData.transcript) setTranscript(fullData.transcript); 170 + } catch {} 159 171 setLoadingTranscript(false); 160 172 return null; 161 173 } 162 174 163 175 async function loadChunk(index: number) { 164 - if (loadedChunksRef.current.has(index)) return; 165 - const res = await fetch(`${API_BASE}/xrpc/tv.ionosphere.getTrackTranscript?stream=${encodeURIComponent(stream)}&chunk=${index}`); 166 - const chunk = await res.json(); 167 - if (chunk.text !== undefined) { 168 - loadedChunksRef.current.set(index, chunk); 176 + if (loadedChunksRef.current.has(index) || loadingChunksRef.current.has(index)) return; 177 + loadingChunksRef.current.add(index); 178 + try { 179 + const res = await fetch(`${API_BASE}/xrpc/tv.ionosphere.getTrackTranscript?stream=${encodeURIComponent(stream)}&chunk=${index}`); 180 + const chunk = await res.json(); 181 + if (chunk.text !== undefined) { 182 + loadedChunksRef.current.set(index, chunk); 183 + } 184 + } finally { 185 + loadingChunksRef.current.delete(index); 169 186 } 170 187 } 171 188 172 189 function assembleDocument() { 173 190 const chunks = loadedChunksRef.current; 174 - if (chunks.size === 0) return; 191 + if (chunks.size === 0 || chunks.size === chunkCountRef.current) return; 192 + chunkCountRef.current = chunks.size; 175 193 176 - // Sort by index and stitch together 177 194 const sorted = [...chunks.entries()].sort((a, b) => a[0] - b[0]); 178 195 let text = ""; 179 196 const facets: any[] = []; ··· 203 220 204 221 async function tick() { 205 222 const manifest = await loadManifest(); 206 - if (!manifest) return; 223 + if (!manifest || cancelled) return; 207 224 208 - const currentChunk = Math.floor(currentTimeSec / CHUNK_DURATION_S); 209 - if (currentChunk === lastChunkIndexRef.current && loadedChunksRef.current.size > 0) return; 210 - lastChunkIndexRef.current = currentChunk; 225 + const currentChunk = Math.floor(chunkTimeSec / CHUNK_DURATION_S); 211 226 212 - // Load current chunk + neighbors 213 - const toLoad = [currentChunk - 1, currentChunk, currentChunk + 1] 214 - .filter(i => i >= 0 && i < manifest.chunkCount); 227 + // Immediate: current ± 1 (must have for smooth scrolling) 228 + const immediate = [currentChunk - 1, currentChunk, currentChunk + 1] 229 + .filter(i => i >= 0 && i < manifest.chunkCount && !loadedChunksRef.current.has(i)); 230 + 231 + if (immediate.length > 0) { 232 + await Promise.all(immediate.map(loadChunk)); 233 + if (!cancelled) assembleDocument(); 234 + } 235 + 236 + // Prefetch: ± 3 behind, + 5 ahead (non-blocking, background) 237 + const prefetch = [-2, -3, 2, 3, 4, 5] 238 + .map(d => currentChunk + d) 239 + .filter(i => i >= 0 && i < manifest.chunkCount && !loadedChunksRef.current.has(i)); 215 240 216 - await Promise.all(toLoad.map(loadChunk)); 217 - assembleDocument(); 241 + if (prefetch.length > 0 && !cancelled) { 242 + Promise.all(prefetch.map(loadChunk)).then(() => { 243 + if (!cancelled) assembleDocument(); 244 + }); 245 + } 218 246 } 219 247 220 248 tick(); 221 - }, [activeTab, currentTimeSec, stream, transcript]); 249 + return () => { cancelled = true; }; 250 + }, [activeTab, chunkTimeSec, stream]); 222 251 const [speakerPopover, setSpeakerPopover] = useState<{ speakerId: string; position: { x: number; y: number } } | null>(null); 223 252 224 253 // Zoom state ··· 467 496 </div> 468 497 )} 469 498 {activeTab === "transcript" && ( 470 - loadingTranscript || (!transcriptFetched && !transcript) 499 + loadingTranscript || (!transcript) 471 500 ? <div className="flex items-center justify-center h-32 text-neutral-500">Loading transcript...</div> 472 501 : transcript?.facets?.length 473 502 ? <WindowedTranscriptView document={transcript} />