1const DEMO_PACKAGE_URL = "/functions/v1/demo-package"; 2const PUBLIC_SNAPSHOT_ORIGIN = "https://snapshots.15half.com"; 3const STATIC_SHOW_MEDIA_ORIGIN = window.location.hostname === "15half.com" 4 ? window.location.origin 5 : PUBLIC_SNAPSHOT_ORIGIN; 6const staticShowMediaUrl = (pathname) => 7 new URL(pathname, STATIC_SHOW_MEDIA_ORIGIN).toString(); 8const HOST_VIDEO_URL = staticShowMediaUrl( 9 "/media/static/v1/quiz-host-v6.mp4", 10); 11const LIVE_STATE_EVENT = "channel155-live-show-state"; 12const AUDIO_PREFETCH_EVENT = "channel155-quiz-audio-prefetch"; 13 14const warmedUrls = new Set(); 15const warmingUrls = new Map(); 16 17const registry = { 18 ready: false, 19 essentialsReady: false, 20 warmedUrls, 21 downloadOrder: [], 22 warmOrder: [], 23 audioPreloadUrls: [], 24 // Playback deliberately uses the original URL. Native media loading is much 25 // more reliable than Blob URLs on iOS Safari, especially when one host video 26 // is represented in both the full-screen and circular layouts. 27 resolve(url) { 28 return url; 29 }, 30}; 31 32window.__channelQuizMedia = registry; 33 34function announceAudioPrefetch(urls) { 35 registry.audioPreloadUrls = [...new Set(urls.filter(Boolean))]; 36 window.dispatchEvent( 37 new CustomEvent(AUDIO_PREFETCH_EVENT, { 38 detail: { urls: registry.audioPreloadUrls }, 39 }), 40 ); 41} 42 43const wait = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms)); 44 45async function fetchWithRetry(url, options = {}, attempts = 3) { 46 let lastError; 47 for (let attempt = 0; attempt < attempts; attempt += 1) { 48 try { 49 const response = await fetch(url, options); 50 if (!response.ok) throw new Error(`HTTP ${response.status} for ${url}`); 51 return response; 52 } catch (error) { 53 lastError = error; 54 if (attempt < attempts - 1) await wait(450 * (attempt + 1)); 55 } 56 } 57 throw lastError; 58} 59 60async function warmAsset(url, priority = "low") { 61 if (!url || warmedUrls.has(url)) return; 62 if (warmingUrls.has(url)) return warmingUrls.get(url); 63 64 const promise = (async () => { 65 const response = await fetchWithRetry(url, { 66 cache: "force-cache", 67 priority, 68 }); 69 // Consume the response so the browser can commit it to its HTTP cache. We 70 // intentionally discard the bytes instead of creating a Blob URL. 71 await response.arrayBuffer(); 72 warmedUrls.add(url); 73 registry.warmOrder.push(url); 74 })().finally(() => warmingUrls.delete(url)); 75 76 warmingUrls.set(url, promise); 77 return promise; 78} 79 80async function readDemoPackage(url, options, attempts) { 81 const response = await fetchWithRetry(url, options, attempts); 82 const payload = await response.json(); 83 if (!payload?.demoPackage?.questions?.length) { 84 throw new Error("The quiz demo package is incomplete"); 85 } 86 return payload.demoPackage; 87} 88 89async function fetchDemoPackage() { 90 const edgeUrl = new URL(DEMO_PACKAGE_URL, PUBLIC_SNAPSHOT_ORIGIN); 91 try { 92 return await readDemoPackage( 93 edgeUrl, 94 { headers: { Accept: "application/json" } }, 95 1, 96 ); 97 } catch (edgeError) { 98 console.warn("Quiz package edge unavailable; using origin.", edgeError); 99 return await readDemoPackage( 100 DEMO_PACKAGE_URL, 101 { cache: "no-store", headers: { Accept: "application/json" } }, 102 4, 103 ); 104 } 105} 106 107function playbackOrder(voice) { 108 const urls = [voice.opening]; 109 const questionCount = Math.max( 110 voice.question?.length ?? 0, 111 voice.reveal?.length ?? 0, 112 ); 113 for (let index = 0; index < questionCount; index += 1) { 114 urls.push(voice.question?.[index], voice.reveal?.[index]); 115 if (index < questionCount - 1) urls.push(voice.transition?.[index]); 116 } 117 urls.push(voice.closing); 118 return [...new Set(urls.filter(Boolean))]; 119} 120 121function audioUrlForLiveState(demoPackage, show) { 122 const voice = demoPackage.voice ?? {}; 123 const currentUrl = String(show?.publicPayload?.voiceUrl ?? "").trim(); 124 if (currentUrl) return currentUrl; 125 126 const rawQuestionIndex = show?.questionIndex; 127 const questionIndex = 128 rawQuestionIndex === null || rawQuestionIndex === undefined 129 ? null 130 : Number(rawQuestionIndex); 131 if (!Number.isInteger(questionIndex) || questionIndex < 0) { 132 if (show?.state === "opening") return voice.opening; 133 if (show?.state === "closing") return voice.closing; 134 return null; 135 } 136 137 if (show.state === "question_intro") return voice.question?.[questionIndex]; 138 if (["question_open", "answer_locked", "answer_reveal"].includes(show.state)) { 139 return voice.reveal?.[questionIndex]; 140 } 141 if (["score_update", "transition"].includes(show.state)) { 142 return ( 143 voice.transition?.[questionIndex] ?? 144 voice.question?.[questionIndex + 1] ?? 145 voice.closing 146 ); 147 } 148 return voice.question?.[questionIndex] ?? null; 149} 150 151function isMidShowQuiz(show) { 152 return ( 153 show && 154 show.runId !== "off-air" && 155 show.state !== "off_air" && 156 show.state !== "ended" && 157 (Number.isInteger(show.questionIndex) || 158 ["opening", "closing"].includes(show.state)) 159 ); 160} 161 162function midShowPlaybackOrder(demoPackage, show) { 163 const fullOrder = playbackOrder(demoPackage.voice ?? {}); 164 const currentUrl = audioUrlForLiveState(demoPackage, show); 165 const currentIndex = fullOrder.indexOf(currentUrl); 166 if (currentIndex >= 0) return fullOrder.slice(currentIndex); 167 168 const rawQuestionIndex = show?.questionIndex; 169 const questionIndex = 170 rawQuestionIndex === null || rawQuestionIndex === undefined 171 ? null 172 : Number(rawQuestionIndex); 173 if (Number.isInteger(questionIndex) && questionIndex >= 0) { 174 const fallbackUrl = demoPackage.voice?.question?.[questionIndex]; 175 const fallbackIndex = fullOrder.indexOf(fallbackUrl); 176 if (fallbackIndex >= 0) return fullOrder.slice(fallbackIndex); 177 } 178 return fullOrder; 179} 180 181async function waitForLiveShowState() { 182 if (window.__channelLiveShowState) return window.__channelLiveShowState; 183 return await new Promise((resolve) => { 184 const onState = (event) => resolve(event.detail ?? null); 185 window.addEventListener(LIVE_STATE_EVENT, onState, { once: true }); 186 }); 187} 188 189function mediaPrefetchPolicy(connection = navigator.connection) { 190 const effectiveType = String(connection?.effectiveType || "").toLowerCase(); 191 if (connection?.saveData || effectiveType === "slow-2g" || effectiveType === "2g") { 192 return { concurrency: 1, futureAudioLimit: 2 }; 193 } 194 if (effectiveType === "3g") { 195 return { concurrency: 1, futureAudioLimit: 6 }; 196 } 197 return { concurrency: 2, futureAudioLimit: Number.POSITIVE_INFINITY }; 198} 199 200async function warmInBatches(urls, priority = "low", concurrency = 2) { 201 for (let offset = 0; offset < urls.length; offset += concurrency) { 202 const batch = urls.slice(offset, offset + concurrency); 203 await Promise.all( 204 batch.map((url) => warmAsset(url, priority).catch(() => null)), 205 ); 206 await wait(1_000); 207 } 208} 209 210// Only the tiny JSON package blocks the demo bundle so it cannot fall back to 211// an old embedded quiz. There is deliberately no loading UI here. 212export async function prepareQuizDemo() { 213 const demoPackage = await fetchDemoPackage(); 214 window.__channelQuizDemoPackage = demoPackage; 215 registry.ready = true; 216 return demoPackage; 217} 218 219// Live quiz playback uses the same immutable package. Fetching this tiny JSON 220// ahead of airtime lets the channel warm speech files without switching the 221// React app into demo mode. 222export async function fetchQuizPackageForPrefetch() { 223 return await fetchDemoPackage(); 224} 225
226export function startQuizBackgroundPrefetch( 227 demoPackage, 228 { live = false, upcoming = false } = {}, 229) { 230 const firstThreeSounds = [ 231 demoPackage.voice?.opening, 232 demoPackage.voice?.question?.[0], 233 demoPackage.voice?.reveal?.[0], 234 demoPackage.voice?.transition?.[0], 235 demoPackage.voice?.question?.[1], 236 ].filter(Boolean); 237 const firstStill = demoPackage.questions?.[0]?.imageUrl ?? null; 238 const essentials = new Set([...firstThreeSounds, firstStill].filter(Boolean)); 239 const remainingAudio = playbackOrder(demoPackage.voice ?? {}).filter( 240 (url) => !essentials.has(url), 241 ); 242 243 const run = async () => { 244 const policy = mediaPrefetchPolicy(); 245 const liveShow = live ? await waitForLiveShowState() : null; 246 if (isMidShowQuiz(liveShow)) { 247 const futureAudio = midShowPlaybackOrder(demoPackage, liveShow); 248 const currentAudio = futureAudio.slice(0, 1); 249 const nextAudio = futureAudio.slice(1, 2); 250 const laterAudio = futureAudio.slice(2); 251 const currentStill = demoPackage.questions?.[Number(liveShow?.questionIndex)]?.imageUrl ?? firstStill; 252 253 registry.prefetchMode = "mid-show"; 254 registry.downloadOrder = [ 255 ...currentAudio, 256 currentStill, 257 HOST_VIDEO_URL, 258 ...nextAudio, 259 ...laterAudio, 260 ]; 261 announceAudioPrefetch([...currentAudio, ...nextAudio]); 262 263 // A late viewer must be able to join the segment already on air. Warm the 264 // current speech and presenter concurrently so neither waits behind the 265 // other. The next speech remains the second audio priority, followed by 266 // the rest of the future-only queue; elapsed audio is never downloaded. 267 await Promise.all( 268 [ 269 ...currentAudio.map((url) => 270 warmAsset(url, "high").catch(() => null), 271 ), 272 currentStill ? warmAsset(currentStill, "high").catch(() => null) : null, 273 warmAsset(HOST_VIDEO_URL, "high").catch(() => null), 274 ].filter(Boolean), 275 ); 276 await Promise.all( 277 nextAudio.map((url) => warmAsset(url, "high").catch(() => null)), 278 ); 279 registry.essentialsReady = true; 280 await warmInBatches( 281 laterAudio.slice(0, policy.futureAudioLimit), 282 "low", 283 policy.concurrency, 284 ); 285 return; 286 } 287 288 registry.prefetchMode = "full-show"; 289 registry.downloadOrder = [ 290 HOST_VIDEO_URL, 291 ...firstThreeSounds, 292 ...demoPackage.questions.map((question) => question?.imageUrl).filter(Boolean), 293 ...remainingAudio, 294 ]; 295 announceAudioPrefetch(firstThreeSounds.slice(0, 3)); 296 // Warm the presenter and opening voice chain while the viewer keeps seeing 297 // the normal channel/countdown UI. The quiz begins directly at T-0. 298 const essentialPriority = upcoming ? "low" : "high"; 299 await warmInBatches( 300 [HOST_VIDEO_URL, ...firstThreeSounds, firstStill].filter(Boolean), 301 essentialPriority, 302 policy.concurrency, 303 ); 304 registry.essentialsReady = true; 305 306 // Continue quietly in connection-aware batches. Fast/unknown connections 307 // warm the full show; constrained connections protect playback and input. 308 await warmInBatches( 309 remainingAudio.slice(0, policy.futureAudioLimit), 310 "low", 311 policy.concurrency, 312 ); 313 }; 314 315 run().catch(() => null); 316}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.