Ionosphere.tv
3
fork

Configure Feed

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

feat: HLS-inspired chunked transcript loading on track pages

Instead of fetching the full 10MB transcript, the client now:
1. Fetches a manifest (~3KB) listing available 5-min chunks
2. Loads the chunk at the current playback position (~10KB gzip)
3. Pre-fetches adjacent chunks (previous + next)
4. Assembles loaded chunks into a stitched document
5. As playback progresses, loads the next chunk automatically

Falls back to full transcript fetch if chunked endpoint unavailable.

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

+90 -13
+90 -13
apps/ionosphere/src/app/tracks/[stream]/TrackViewContent.tsx
··· 126 126 function TrackViewInner({ track, stream }: { track: TrackData; stream: string }) { 127 127 const [activeTab, setActiveTab] = useState<"talks" | "transcript">("talks"); 128 128 const [transcript, setTranscript] = useState(track.transcript ?? null); 129 - const [transcriptFetched, setTranscriptFetched] = useState(!!track.transcript); 130 129 const [loadingTranscript, setLoadingTranscript] = useState(false); 131 130 132 - // Lazy-load transcript when tab is first activated 131 + // Chunked transcript state 132 + const manifestRef = useRef<any>(null); 133 + const loadedChunksRef = useRef<Map<number, any>>(new Map()); 134 + const lastChunkIndexRef = useRef(-1); 135 + 136 + const { currentTimeNs } = useTimestamp(); 137 + const currentTimeSec = currentTimeNs / 1e9; 138 + 139 + // HLS-inspired chunked transcript loading 133 140 useEffect(() => { 134 - if (activeTab !== "transcript" || transcriptFetched || loadingTranscript) return; 135 - setLoadingTranscript(true); 136 - fetch(`${API_BASE}/xrpc/tv.ionosphere.getTrack?stream=${encodeURIComponent(stream)}&include=transcript`) 137 - .then(res => res.json()) 138 - .then(data => { if (data.transcript) setTranscript(data.transcript); }) 139 - .catch(() => {}) 140 - .finally(() => { setLoadingTranscript(false); setTranscriptFetched(true); }); 141 - }, [activeTab, transcriptFetched, loadingTranscript, stream]); 141 + if (activeTab !== "transcript") return; 142 + if (transcript) return; // already have full transcript 143 + 144 + const CHUNK_DURATION_S = 300; // 5 minutes 145 + 146 + async function loadManifest() { 147 + if (manifestRef.current) return manifestRef.current; 148 + 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 + } 155 + // 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); 159 + setLoadingTranscript(false); 160 + return null; 161 + } 162 + 163 + 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); 169 + } 170 + } 171 + 172 + function assembleDocument() { 173 + const chunks = loadedChunksRef.current; 174 + if (chunks.size === 0) return; 175 + 176 + // Sort by index and stitch together 177 + const sorted = [...chunks.entries()].sort((a, b) => a[0] - b[0]); 178 + let text = ""; 179 + const facets: any[] = []; 180 + let byteOffset = 0; 181 + 182 + for (const [, chunk] of sorted) { 183 + const encoder = new TextEncoder(); 184 + const chunkBytes = encoder.encode(chunk.text); 185 + 186 + for (const facet of chunk.facets) { 187 + facets.push({ 188 + index: { 189 + byteStart: facet.index.byteStart + byteOffset, 190 + byteEnd: facet.index.byteEnd + byteOffset, 191 + }, 192 + features: facet.features, 193 + }); 194 + } 195 + 196 + text += chunk.text; 197 + byteOffset += chunkBytes.length; 198 + } 199 + 200 + setTranscript({ text, facets }); 201 + setLoadingTranscript(false); 202 + } 203 + 204 + async function tick() { 205 + const manifest = await loadManifest(); 206 + if (!manifest) return; 207 + 208 + const currentChunk = Math.floor(currentTimeSec / CHUNK_DURATION_S); 209 + if (currentChunk === lastChunkIndexRef.current && loadedChunksRef.current.size > 0) return; 210 + lastChunkIndexRef.current = currentChunk; 211 + 212 + // Load current chunk + neighbors 213 + const toLoad = [currentChunk - 1, currentChunk, currentChunk + 1] 214 + .filter(i => i >= 0 && i < manifest.chunkCount); 215 + 216 + await Promise.all(toLoad.map(loadChunk)); 217 + assembleDocument(); 218 + } 219 + 220 + tick(); 221 + }, [activeTab, currentTimeSec, stream, transcript]); 142 222 const [speakerPopover, setSpeakerPopover] = useState<{ speakerId: string; position: { x: number; y: number } } | null>(null); 143 223 144 224 // Zoom state 145 225 const [zoomLevel, setZoomLevel] = useState(1); 146 226 const [panCenter, setPanCenter] = useState<number | null>(null); 147 227 const [containerWidth, setContainerWidth] = useState(800); 148 - 149 - const { currentTimeNs } = useTimestamp(); 150 - const currentTimeSec = currentTimeNs / 1e9; 151 228 152 229 const timelineContainerRef = useRef<HTMLDivElement>(null); 153 230 const timelineBarRef = useRef<HTMLDivElement>(null);