PageSourceSearch

https://15half.com/assets/quiz-media-bootstrap-20260807-v8.js?v=20…ia-r2-v1-20260823-quiz-t0-box-transition-v1

js 15half.com collected 2026-10-09 22:39:15 UTC 10,759 bytes, 316 lines download raw bytes

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.