PageSourceSearch

https://15half.com/assets/web-reruns.js

js 15half.com collected 2026-10-09 22:39:25 UTC 95,913 bytes, 2,323 lines download raw bytes

1import { createReplayClock, installReplaySpeed, REPLAY_RATE_EVENT } from "./replay-speed.js";
2
3const REPLAY_ENDPOINT = "/functions/v1/bootstrap-v2";
4const REPLAY_PUBLIC_ENDPOINT = typeof window !== "undefined" &&
5  window.location.hostname === "15half.com"
6  ? "https://snapshots.15half.com/functions/v1/bootstrap-v2"
7  : REPLAY_ENDPOINT;
8const GUEST_STATE_PREFIX = "channel155_web_replay:";
9const REPLAY_AVATAR_PLACEHOLDER =
10  "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%23898a96'/%3E%3Ccircle cx='32' cy='25' r='11' fill='%23f5f5f7'/%3E%3Cpath d='M13 56c2-11 9-17 19-17s17 6 19 17' fill='%23f5f5f7'/%3E%3C/svg%3E";
11const REPLAY_STARTUP_LEAD_MS = 120;
12const REPLAY_MEDIA_WARM_TIMEOUT_MS = 8_000;
13const SHARE_DISMISSED_EVENT = "channel155:show-share-dismissed";
14const REPLAY_RESTARTED_EVENT = "channel155:web-replay-restarted";
15const REPLAY_BOOKMARK_INTERVAL_MS = 1_000;
16const QUIZ_QUESTION_COUNTDOWN_AUDIO =
17  "/media/static/v1/quiz-answer-countdown-v2.mp3";
18
19function replayShowType(kind) {
20  return ({
21    "daily-news": "news",
22    "hamoach": "quiz",
23    "marketplace-price": "marketplace",
24    "music-battle": "music",
25    "songs-quiz": "songs",
26    "film-ghosts": "ghosts",
27  })[kind] || "unknown";
28}
29const isQuizReplayKind = (kind) => kind === "hamoach";
30
31function replayInteractionType(kind, actionID, payload = {}) {
32  if (kind === "marketplace-price" || Number.isFinite(Number(payload.guess))) {
33    return "price_guess";
34  }
35  if (kind === "music-battle" || String(actionID).startsWith("music:")) {
36    return "music_vote";
37  }
38  if (kind === "daily-news") return "news_reaction";
39  return "quiz_answer";
40}
41
42function trackReplayParticipation(archive, actionID, payload, isAuthenticated) {
43  window.channel155Analytics?.trackParticipation({
44    interactionType: replayInteractionType(archive.kind, actionID, payload),
45    showType: replayShowType(archive.kind),
46    experience: "replay",
47    contentId: actionID,
48    broadcastId: archive.episodeKey,
49    isAuthenticated,
50  });
51}
52const REPLAY_STATIC_MEDIA = Object.freeze({
53  "film-ghosts": { video: "/media/static/v1/movies-host-v3-original-v2.mp4?v=20260921-movies-host-v3-original-v2", image: "/assets/movies-game-background-v3.webp?v=20260921-movies-brand-v3" },
54  hamoach: {
55    video: "/media/static/v1/quiz-host-v6.mp4",
56    image: "/assets/tv-default-background-v2.webp?v=20260808-tv-bg-v2",
57  },
58  "songs-quiz": {
59    video: "/media/static/v1/songs-host-v1.mp4",
60    image: "/assets/songs-host-background-v1.webp?v=20260823-songs-host-background-v1",
61  },
62  "marketplace-price": {
63    video: "/media/static/v1/marketplace-host-v2.mp4",
64    image: "/assets/marketplace-stage-background-v2.webp",
65  },
66  "daily-news": {
67    video: "/media/static/v1/news-host-v3.mp4",
68    image: "/assets/news-studio-background-v3.webp?v=20260824-news-stage-framed-fades-v1",
69  },
70});
71
72export const WEB_REPLAY_SHOWS = [
73  {
74    programKey: "hamoach",
75    path: "/the-quiz",
76    title: "החידון",
77    subtitle: "שאלון טריוויה בו כולם מתחרים נגד כולם",
78    banner: "/assets/rerun-quiz-banner-v1.webp",
79    ogTitle: "החידון",
80    ogDescription: "שאלון טריוויה בו כולם מתחרים נגד כולם",
81    ogImage: "/assets/og-the-quiz-v1.jpg",
82  },
83  {
84    programKey: "songs-quiz",
85    path: "/songs-quiz",
86    title: "הצליל הגואל",
87    subtitle: "כמה שירים אתם יכולים לזהות?",
88    banner: "/assets/rerun-songs-banner-v1.webp",
89    ogTitle: "הצליל הגואל",
90    ogDescription: "כמה שירים אתם יכולים לזהות",
91    ogImage: "/assets/og-songs-quiz-v1.jpg",
92  },
93  { programKey: "film-ghosts", path: "/movie-quiz", title: "רוחות של סרטים", subtitle: "מזהים את הסרט גם כשהשחקנים נעלמו?", banner: "/assets/movies-rerun-banner-v3.webp?v=20260921-movies-brand-v3", ogTitle: "רוחות של סרטים", ogDescription: "מזהים את הסרט גם כשהשחקנים נעלמו", ogImage: "/assets/og-movie-quiz-v1.jpg" },
94  {
95    programKey: "marketplace-price",
96    path: "/price-quiz",
97    title: "כמה זה יעלה לי?",
98    subtitle: "נחשו כמה מבקשים על המוצר הבא",
99    banner: "/assets/rerun-price-banner-v1.webp",
100    ogTitle: "כמה זה יעלה לי",
101    ogDescription: "נחשו כמה מבקשים על המוצר הבא",
102    ogImage: "/assets/og-price-quiz-v1.jpg",
103  },
104  {
105    programKey: "daily-news",
106    path: "/the-news",
107    title: "מתי המלחמה הבאה?",
108    subtitle: "חדשות ושטויות מהארץ והעולם",
109    banner: "/assets/rerun-news-banner-v1.webp",
110    ogTitle: "מתי המלחמה הבאה",
111    ogDescription: "חדשות ושטויות מהארץ והעולם",
112    ogImage: "/assets/og-the-news-v1.jpg",
113  },
114  {
115    programKey: "music-battle",
116    path: "/music-battle",
117    title: "השיר הבא תודה רבה",
118    subtitle: "אתם בוחרים מה יהיה הקליפ הבא",
119    banner: "/assets/rerun-music-banner-v1.webp",
120    ogTitle: "השיר הבא תודה רבה",
121    ogDescription: "אתם בוחרים מה יהיה הקליפ הבא",
122    ogImage: "/assets/og-music-battle-v1.jpg",
123  },
124];
125
126export const WEB_REPLAY_DEMO_MODES = Object.freeze({
127  "film-ghosts": "ghosts",
128  hamoach: "quiz",
129  "songs-quiz": "songs",
130  "marketplace-price": "price",
131  "daily-news": "news",
132  "music-battle": "music",
133});
134
135const showByProgram = new Map(
136  WEB_REPLAY_SHOWS.map((show) => [show.programKey, show]),
137);
138
139const LEGACY_WEB_REPLAY_PATHS = new Map([
140  ["/רוחות-של-סרטים", "film-ghosts"],
141  ["/החידון", "hamoach"],
142  ["/הצליל-הגואל", "songs-quiz"],
143  ["/כמה-זה-יעלה-לי", "marketplace-price"],
144  ["/מתי-המלחמה-הבאה", "daily-news"],
145  ["/השיר-הבא-תודה-רבה", "music-battle"],
146]);
147
148export function webReplayShowURL(programKey, origin = "https://15half.com") {
149  const path = showByProgram.get(programKey)?.path ?? "/";
150  return new URL(path, origin).href;
151}
152
153function decodedPath(pathname = window.location.pathname) {
154  try {
155    return decodeURIComponent(pathname).replace(/\/+$/, "") || "/";
156  } catch {
157    return pathname.replace(/\/+$/, "") || "/";
158  }
159}
160
161export function webReplayProgramForPath(pathname = window.location.pathname) {
162  const path = decodedPath(pathname);
163  return WEB_REPLAY_SHOWS.find((show) => show.path === path)?.programKey ??
164    LEGACY_WEB_REPLAY_PATHS.get(path) ?? null;
165}
166
167export function canonicalWebReplayPathForPath(
168  pathname = window.location.pathname,
169) {
170  const programKey = webReplayProgramForPath(pathname);
171  return showByProgram.get(programKey)?.path ?? null;
172}
173
174function applyWebReplayMetadata(show) {
175  const pageURL = webReplayShowURL(show.programKey);
176  const imageURL = new URL(show.ogImage, pageURL).href;
177  document.title = show.ogTitle;
178  document.querySelector('link[rel="canonical"]')?.setAttribute("href", pageURL);
179  document.querySelector('meta[property="og:url"]')?.setAttribute("content", pageURL);
180  document.querySelector('meta[name="description"]')?.setAttribute("content", show.ogDescription);
181  document.querySelector('meta[property="og:title"]')?.setAttribute("content", show.ogTitle);
182  document.querySelector('meta[property="og:description"]')?.setAttribute("content", show.ogDescription);
183  document.querySelector('meta[property="og:image"]')?.setAttribute("content", imageURL);
184  document.querySelector('meta[property="og:image:alt"]')?.setAttribute("content", show.ogTitle);
185  document.querySelector('meta[name="twitter:title"]')?.setAttribute("content", show.ogTitle);
186  document.querySelector('meta[name="twitter:description"]')?.setAttribute("content", show.ogDescription);
187  document.querySelector('meta[name="twitter:image"]')?.setAttribute("content", imageURL);
188  document.querySelector('meta[name="twitter:image:alt"]')?.setAttribute("content", show.ogTitle);
189}
190
191function escapeHTML(value) {
192  return String(value ?? "")
193    .replaceAll("&", "&")
194    .replaceAll("<", "&lt;")
195    .replaceAll(">", "&gt;")
196    .replaceAll('"', "&quot;")
197    .replaceAll("'", "&#039;");
198}
199
200async function requestJSON(url, options = {}) {
201  const controller = new AbortController();
202  const timeout = window.setTimeout(() => controller.abort(), options.timeoutMs ?? 8_000);
203  try {
204    const response = await fetch(url, {
205      credentials: "same-origin",
206      cache: "no-store",
207      ...options,
208      headers: {
209        Accept: "application/json",
210        ...(options.body ? { "Content-Type": "application/json" } : {}),
211        ...(options.headers ?? {}),
212      },
213      signal: controller.signal,
214    });
215    const payload = await response.json().catch(() => ({}));
216    if (!response.ok) {
217      const error = new Error(payload?.error?.message || `HTTP ${response.status}`);
218      error.status = response.status;
219      error.code = payload?.error?.code ?? "request_failed";
220      error.payload = payload;
221      throw error;
222    }
223    return payload;
224  } finally {
225    window.clearTimeout(timeout);
226  }
227}
228
229async function requestPublicReplayJSON(url, options = {}) {
230  const target = new URL(url, window.location.origin);
231  try {
232    const payload = await requestJSON(target, {
233      ...options,
234      timeoutMs: Math.min(Number(options.timeoutMs) || 8_000, 2_500),
235    });
236    if (
237      target.searchParams.get("webReplay") !== "catalog" ||
238      (Array.isArray(payload?.episodes) && payload.episodes.length > 0) ||
239      target.origin === window.location.origin
240    ) return payload;
241  } catch (error) {
242    if (target.origin === window.location.origin) throw error;
243  }
244  const originURL = new URL(target.pathname + target.search, window.location.origin);
245  return requestJSON(originURL, { ...options, timeoutMs: 6_000 });
246}
247
248function replayAvatarIndex(message) {
249  if (Number.isInteger(message?.avatarIndex)) return message.avatarIndex;
250  const key = String(message?.avatarKey ?? "").toLowerCase();
251  if (!key || key === "placeholder") return -1;
252  const match = key.match(/(\d+)/);
253  return match ? Math.max(0, Number(match[1]) - 1) : 0;
254}
255
256function normalizeReplayChatMessage(message) {
257  return {
258    ...message,
259    id: String(message?.id ?? ""),
260    userId: String(message?.userId ?? message?.userID ?? ""),
261    avatarUrl: message?.avatarUrl ?? message?.avatarURL ?? null,
262    avatarIndex: replayAvatarIndex(message),
263    publishedAt: String(message?.publishedAt ?? ""),
264    offsetMs: Math.max(0, Number(message?.offsetMs) || 0),
265  };
266}
267
268function mergeReplayChatMessages(current, incoming) {
269  const messages = new Map();
270  for (const message of [...current, ...incoming]) {
271    const normalized = normalizeReplayChatMessage(message);
272    if (!normalized.id) continue;
273    messages.set(normalized.id, normalized);
274  }
275  return [...messages.values()].sort((left, right) =>
276    Date.parse(left.publishedAt) - Date.parse(right.publishedAt)
277  );
278}
279
280async function loadReplayChatInBackground(target, episodeKey) {
281  let before = "";
282  let previousCursor = "";
283  do {
284    const chatURL = new URL(REPLAY_PUBLIC_ENDPOINT, window.location.origin);
285    chatURL.searchParams.set("webReplay", "chat");
286    chatURL.searchParams.set("episodeKey", episodeKey);
287    chatURL.searchParams.set("limit", "300");
288    if (before) chatURL.searchParams.set("before", before);
289    const payload = await requestPublicReplayJSON(chatURL, { timeoutMs: 12_000 });
290    target.chatMessages = mergeReplayChatMessages(
291      target.chatMessages ?? [],
292      Array.isArray(payload.messages) ? payload.messages : [],
293    );
294    // Keep a durable, episode-scoped snapshot outside the show controller.
295    // Quiz reruns spend longer warming their package, so their first chat page
296    // usually arrives before React mounts. The other controllers can mount
297    // first; carrying the messages on the event makes that later hydration
298    // deterministic instead of relying on a shared-global timing race.
299    window.__channelReplayChatSnapshot = {
300      episodeKey,
301      messages: target.chatMessages,
302    };
303    document.documentElement.dataset.replayChatState = "ready";
304    document.documentElement.dataset.replayChatCount = String(
305      target.chatMessages.length,
306    );
307    window.dispatchEvent(
308      new CustomEvent("channel155:replay-chat-updated", {
309        detail: {
310          episodeKey,
311          count: target.chatMessages.length,
312          messages: target.chatMessages,
313        },
314      }),
315    );
316    previousCursor = before;
317    before = String(payload.chatHistoryCursor ?? "");
318    if (
319      !payload.hasMoreChatHistory ||
320      !before ||
321      before === previousCursor
322    ) break;
323    await new Promise((resolve) => window.setTimeout(resolve, 120));
324  } while (true);
325}
326
327function replayPhaseMedia(archive, plan, positionMs) {
328  const manifest = archive?.timelineManifest ?? {};
329  const phase = segmentAt(plan, positionMs);
330  const media = [];
331  if (isQuizReplayKind(archive?.kind)) {
332    const question = manifest.questions?.[phase?.questionIndex];
333    const asset = phase?.state === "opening"
334      ? manifest.openingVoice
335      : ["closing", "post_show"].includes(phase?.state)
336      ? manifest.closingVoice
337      : phase?.state === "smalltalk"
338      ? question?.smalltalkVoice
339      : ["question_intro", "question_open"].includes(phase?.state)
340      ? question?.questionVoice
341      : ["answer_reveal", "score_update"].includes(phase?.state)
342      ? question?.revealVoice
343      : question?.transitionVoice;
344    if (asset?.url) media.push({ kind: "audio", url: asset.url });
345  } else if (["songs-quiz", "film-ghosts"].includes(archive?.kind)) {
346    const asset = phase?.state === "closing"
347      ? manifest.closingVoice
348      : manifest.openingVoice;
349    if (["opening", "closing"].includes(phase?.state) && asset?.url) {
350      media.push({ kind: "audio", url: asset.url });
351    }
352    const round = manifest.songRounds?.[phase?.questionIndex];
353    if (round?.thumbnailURL) media.push({ kind: "image", url: round.thumbnailURL });
354  } else if (archive?.kind === "marketplace-price") {
355    const item = manifest.priceItems?.[phase?.questionIndex];
356    const asset = phase?.state === "opening"
357      ? manifest.openingVoice
358      : phase?.state === "closing"
359      ? manifest.closingVoice
360      : phase?.state === "reveal"
361      ? { url: item?.revealAudioURL }
362      : { url: item?.titleAudioURL };
363    if (asset?.url) media.push({ kind: "audio", url: asset.url });
364    if (item?.imageURL) media.push({ kind: "image", url: item.imageURL });
365  } else if (archive?.kind === "daily-news") {
366    const item = currentNewsItem(archive, positionMs);
367    const asset = item?.voiceURL
368      ? { url: item.voiceURL }
369      : positionMs < Number(manifest.newsItems?.[0]?.offsetMs ?? 0)
370      ? manifest.openingVoice
371      : manifest.closingVoice;
372    if (asset?.url) media.push({ kind: "audio", url: asset.url });
373    const imageURL = item?.imageURL || item?.imageUrl;
374    if (imageURL) media.push({ kind: "image", url: imageURL });
375  } else if (archive?.kind === "music-battle") {
376    const round = currentMusicRound(archive, positionMs);
377    if (round?.played?.thumbnailURL) {
378      media.push({ kind: "image", url: round.played.thumbnailURL });
379    }
380  }
381  const staticMedia = REPLAY_STATIC_MEDIA[archive?.kind];
382  if (staticMedia?.video) media.push({ kind: "video", url: staticMedia.video });
383  if (staticMedia?.image) media.push({ kind: "image", url: staticMedia.image });
384  return media.filter((item, index, all) =>
385    item.url && all.findIndex((candidate) => candidate.url === item.url) === index
386  );
387}
388
389function warmReplayImage(url) {
390  return new Promise((resolve) => {
391    const image = new Image();
392    let settled = false;
393    const finish = () => {
394      if (settled) return;
395      settled = true;
396      window.clearTimeout(timeout);
397      resolve();
398    };
399    const timeout = window.setTimeout(finish, REPLAY_MEDIA_WARM_TIMEOUT_MS);
400    image.addEventListener("load", finish, { once: true });
401    image.addEventListener("error", finish, { once: true });
402    image.decoding = "async";
403    image.src = new URL(url, window.location.origin).toString();
404  });
405}
406
407function warmReplayMedia(url, kind) {
408  return new Promise((resolve) => {
409    const media = document.createElement(kind === "video" ? "video" : "audio");
410    let settled = false;
411    const finish = () => {
412      if (settled) return;
413      settled = true;
414      window.clearTimeout(timeout);
415      media.removeEventListener("canplay", finish);
416      media.removeEventListener("loadeddata", finish);
417      media.removeEventListener("error", finish);
418      resolve();
419    };
420    const timeout = window.setTimeout(finish, REPLAY_MEDIA_WARM_TIMEOUT_MS);
421    media.preload = "auto";
422    media.muted = true;
423    media.playsInline = true;
424    media.addEventListener("canplay", finish, { once: true });
425    media.addEventListener("loadeddata", finish, { once: true });
426    media.addEventListener("error", finish, { once: true });
427    media.src = new URL(url, window.location.origin).toString();
428    media.load();
429  });
430}
431
432async function warmReplayCriticalMedia(archive, plan, positionMs) {
433  const tasks = replayPhaseMedia(archive, plan, positionMs).map((item) =>
434    item.kind === "image"
435      ? warmReplayImage(item.url)
436      : warmReplayMedia(item.url, item.kind)
437  );
438  if (["songs-quiz", "film-ghosts"].includes(archive?.kind) || archive?.kind === "music-battle") {
439    for (const origin of ["https://www.youtube.com", "https://i.ytimg.com"]) {
440      if (document.head.querySelector(`link[rel="preconnect"][href="${origin}"]`)) continue;
441      const link = document.createElement("link");
442      link.rel = "preconnect";
443      link.href = origin;
444      link.crossOrigin = "anonymous";
445      document.head.appendChild(link);
446    }
447  }
448  await Promise.allSettled(tasks);
449}
450
451function replayRequestID() {
452  return globalThis.crypto?.randomUUID?.() ??
453    `web-${Date.now()}-${Math.random().toString(36).slice(2)}`;
454}
455
456function formatReplayDate(value) {
457  const date = new Date(value);
458  if (!Number.isFinite(date.getTime())) return "";
459  const weekday = new Intl.DateTimeFormat("he-IL", {
460    timeZone: "Asia/Jerusalem",
461    weekday: "long",
462  }).format(date);
463  const dateParts = new Intl.DateTimeFormat("en-GB", {
464    timeZone: "Asia/Jerusalem",
465    day: "numeric",
466    month: "numeric",
467  }).formatToParts(date);
468  const values = Object.fromEntries(
469    dateParts.map((part) => [part.type, part.value]),
470  );
471  return `${weekday}, ${values.day}/${values.month}`;
472}
473
474function formatClock(value) {
475  return new Intl.DateTimeFormat("he-IL", {
476    timeZone: "Asia/Jerusalem",
477    hour: "2-digit",
478    minute: "2-digit",
479    hourCycle: "h23",
480  }).format(new Date(value));
481}
482
483function countdownParts(milliseconds) {
484  const total = Math.max(0, Math.ceil(milliseconds / 1_000));
485  return [
486    Math.floor(total / 3600),
487    Math.floor((total % 3600) / 60),
488    total % 60,
489  ];
490}
491
492function countdownMarkup(milliseconds) {
493  const [hours, minutes, seconds] = countdownParts(milliseconds);
494  return [
495    [hours, "שעות"],
496    [minutes, "דקות"],
497    [seconds, "שניות"],
498  ].map(([value, label]) => `
499    <span class="web-rerun-countdown__unit">
500      <strong>${String(value).padStart(2, "0")}</strong>
501      <small>${label}</small>
502    </span>
503  `).join('<span class="web-rerun-countdown__separator">:</span>');
504}
505
506function nextScheduleStart(bootstrap) {
507  const now = Date.now();
508  return (Array.isArray(bootstrap?.schedule) ? bootstrap.schedule : [])
509    .map((slot) => Date.parse(slot?.startsAt ?? ""))
510    .filter((startsAt) => Number.isFinite(startsAt) && startsAt > now)
511    .sort((left, right) => left - right)[0] ?? Number.NaN;
512}
513
514function showStateOwnsChannel(show) {
515  const state = String(show?.state ?? "").trim().toLowerCase();
516  return Boolean(
517    show?.runId && show.runId !== "off-air" &&
518      state && !["off_air", "ended"].includes(state),
519  );
520}
521
522function scheduleLineupState(schedule, nowMs = Date.now()) {
523  const slots = (Array.isArray(schedule) ? schedule : [])
524    .filter((slot) => showByProgram.has(String(slot?.programKey ?? "")))
525    .map((slot) => ({
526      startsAtMs: Date.parse(slot?.startsAt ?? ""),
527      endsAtMs: Date.parse(slot?.endsAt ?? ""),
528    }))
529    .filter((slot) =>
530      Number.isFinite(slot.startsAtMs) && Number.isFinite(slot.endsAtMs) &&
531      slot.endsAtMs > slot.startsAtMs
532    )
533    .sort((left, right) => left.startsAtMs - right.startsAtMs);
534  for (let index = 0; index < slots.length; index += 1) {
535    const slot = slots[index];
536    if (slot.startsAtMs <= nowMs && nowMs < slot.endsAtMs) return "live";
537    const next = slots[index + 1];
538    if (
539      next && slot.endsAtMs <= nowMs && nowMs < next.startsAtMs &&
540      next.startsAtMs - slot.endsAtMs <= 2 * 60_000
541    ) return "intermission";
542  }
543  return "off";
544}
545
546function scheduleOwnsChannel(schedule, nowMs = Date.now()) {
547  return scheduleLineupState(schedule, nowMs) === "live";
548}
549
550function scheduleHasInterShowCountdown(schedule, nowMs = Date.now()) {
551  return scheduleLineupState(schedule, nowMs) === "intermission";
552}
553
554function recordHomeStartupMilestone(name, datasetKey) {
555  if (document.documentElement.dataset[datasetKey]) return;
556  const atMs = Math.round(globalThis.performance?.now?.() ?? 0);
557  document.documentElement.dataset[datasetKey] = String(atMs);
558  window.__channelStartupMetrics = {
559    ...(window.__channelStartupMetrics ?? {}),
560    [name]: atMs,
561  };
562  globalThis.performance?.mark?.(`channel-${name}`);
563}
564
565function createRerunHome(episodes, startsAtMs) {
566  const episodeByProgram = new Map(
567    (Array.isArray(episodes) ? episodes : []).map((episode) => [
568      episode.programKey,
569      episode,
570    ]),
571  );
572  const home = document.createElement("section");
573  home.className = "web-rerun-home";
574  home.setAttribute("aria-label", "שידורים חוזרים");
575  const startLabel = Number.isFinite(startsAtMs) ? formatClock(startsAtMs) : "19:00";
576  home.innerHTML = `
577    <header class="web-rerun-home__waiting">
578      <h1>
579        <bdi class="web-rerun-home__start-time">${escapeHTML(startLabel)}</bdi>
580        <span class="web-rerun-home__title-divider" aria-hidden="true">|</span>
581        <span class="web-rerun-home__start-label">השידורים מתחילים</span>
582      </h1>
583      <span class="web-rerun-home__title-separator" aria-hidden="true"></span>
584      <time class="web-rerun-countdown" data-web-rerun-countdown></time>
585    </header>
586    <div class="web-rerun-home__heading"><span></span><h2>שידור חוזר</h2><span></span></div>
587    <div class="web-rerun-grid"></div>
588  `;
589  const grid = home.querySelector(".web-rerun-grid");
590  for (const show of WEB_REPLAY_SHOWS) {
591    const episode = episodeByProgram.get(show.programKey);
592    if (!episode?.isReady) continue;
593    const card = document.createElement("a");
594    card.className = "web-rerun-card";
595    card.href = show.path;
596    card.target = "_blank";
597    card.rel = "noopener";
598    card.setAttribute("aria-label", `צפייה בשידור החוזר של ${show.title}`);
599    card.innerHTML = `
600      <span class="web-rerun-card__banner">
601        <img src="${show.banner}" alt="" loading="lazy" decoding="async">
602        <strong>${escapeHTML(formatReplayDate(episode.broadcastDate))}</strong>
603      </span>
604      <span class="web-rerun-card__title">${escapeHTML(show.title)}</span>
605      <span class="web-rerun-card__subtitle">${escapeHTML(show.subtitle)}</span>
606    `;
607    grid.appendChild(card);
608  }
609  if (!grid.childElementCount) home.classList.add("has-no-ready-reruns");
610  return home;
611}
612
613export async function installWebRerunHome(bootstrapPromise) {
614  document.documentElement.classList.add("has-web-rerun-home-loading");
615  const catalogURL = new URL(REPLAY_PUBLIC_ENDPOINT, window.location.origin);
616  catalogURL.searchParams.set("webReplay", "catalog");
617  const catalogPromise = requestPublicReplayJSON(catalogURL, {
618    timeoutMs: 6_000,
619  }).catch(() => ({ episodes: [] }));
620  const bootstrap = await Promise.resolve(bootstrapPromise).catch(() => null);
621  let catalog = { episodes: [] };
622  let startsAtMs = nextScheduleStart(bootstrap);
623  let mountedBroadcast = null;
624  let home = null;
625  let countdownTimer = 0;
626  let syncFrame = 0;
627  let publicSchedule = Array.isArray(bootstrap?.schedule)
628    ? bootstrap.schedule
629    : [];
630  let liveStateActive = showStateOwnsChannel(bootstrap?.show);
631  let manualIntermissionUntilMs = 0;
632
633  const removeDocumentClass = (className) => {
634    const root = document.documentElement;
635    if (root.classList.contains(className)) root.classList.remove(className);
636  };
637
638  const addDocumentClass = (className) => {
639    const root = document.documentElement;
640    if (!root.classList.contains(className)) root.classList.add(className);
641  };
642
643  const removeHome = () => {
644    home?.remove();
645    home = null;
646    mountedBroadcast = null;
647  };
648
649  const updateCountdown = () => {
650    if (!home?.isConnected) return;
651    if (!Number.isFinite(startsAtMs) || startsAtMs <= Date.now()) {
652      startsAtMs = nextScheduleStart({ schedule: publicSchedule });
653    }
654    const startTime = home.querySelector(".web-rerun-home__start-time");
655    const startLabel = Number.isFinite(startsAtMs) ? formatClock(startsAtMs) : "19:00";
656    if (startTime && startTime.textContent !== startLabel) {
657      startTime.textContent = startLabel;
658    }
659    const countdown = home.querySelector("[data-web-rerun-countdown]");
660    if (countdown) {
661      countdown.dateTime = Number.isFinite(startsAtMs)
662        ? new Date(startsAtMs).toISOString()
663        : "";
664      countdown.innerHTML = countdownMarkup(
665        Number.isFinite(startsAtMs) ? startsAtMs - Date.now() : 0,
666      );
667    }
668  };
669
670  const sync = () => {
671    const manualIntermissionActive = manualIntermissionUntilMs > Date.now();
672    const scheduledLineupActive = scheduleOwnsChannel(publicSchedule);
673    const scheduledInterShowCountdown = scheduleHasInterShowCountdown(
674      publicSchedule,
675    );
676    const broadcast = document.querySelector(".broadcast");
677    const programSurfaceLive =
678      broadcast?.classList.contains("is-program-live") ||
679      document.documentElement.classList.contains("is-program-live");
680    const isProgramLive = programSurfaceLive ||
681      (!manualIntermissionActive && (liveStateActive || scheduledLineupActive));
682    if (isProgramLive) addDocumentClass("has-active-channel-lineup");
683    else removeDocumentClass("has-active-channel-lineup");
684    if (
685      (manualIntermissionActive || scheduledInterShowCountdown) &&
686      !isProgramLive
687    ) {
688      addDocumentClass("has-inter-show-countdown");
689    } else removeDocumentClass("has-inter-show-countdown");
690    if (
691      isProgramLive ||
692      ((manualIntermissionActive || scheduledInterShowCountdown) &&
693        !isProgramLive)
694    ) {
695      removeHome();
696      removeDocumentClass("has-web-rerun-home-loading");
697      removeDocumentClass("has-web-rerun-home");
698      return;
699    }
700    if (!broadcast) return;
701    const isOffAir = broadcast.classList.contains("is-off-air-screen") &&
702      !isProgramLive;
703    if (!isOffAir) {
704      removeHome();
705      // This observer watches class changes. Mutating an already-absent class
706      // here can enqueue the observer again in some browsers and starve the
707      // main thread exactly when an offline page crosses into a live show.
708      // Keep the transition idempotent so Safari and Chrome cannot enter that
709      // self-triggering microtask loop.
710      removeDocumentClass("has-web-rerun-home-loading");
711      removeDocumentClass("has-web-rerun-home");
712      return;
713    }
714    if (broadcast === mountedBroadcast && home?.isConnected) return;
715    if (!home?.isConnected) {
716      home = createRerunHome(catalog.episodes, startsAtMs);
717    }
718    broadcast.appendChild(home);
719    mountedBroadcast = broadcast;
720    removeDocumentClass("has-web-rerun-home-loading");
721    addDocumentClass("has-web-rerun-home");
722    updateCountdown();
723    recordHomeStartupMilestone("full-home-ready", "channelFullHomeReadyMs");
724  };
725
726  const queueSync = () => {
727    if (syncFrame) return;
728    syncFrame = window.requestAnimationFrame(() => {
729      syncFrame = 0;
730      sync();
731    });
732  };
733  const observer = new MutationObserver(queueSync);
734  observer.observe(document.documentElement, {
735    subtree: true,
736    childList: true,
737    attributes: true,
738    attributeFilter: ["class"],
739  });
740  const onLiveState = (event) => {
741    liveStateActive = showStateOwnsChannel(event.detail);
742    queueSync();
743  };
744  const onScheduleState = (event) => {
745    if (!Array.isArray(event.detail?.schedule)) return;
746    publicSchedule = event.detail.schedule;
747    startsAtMs = nextScheduleStart({ schedule: publicSchedule });
748    queueSync();
749  };
750  const onShareDismissed = (event) => {
751    const nextStartsAtMs = Date.parse(event.detail?.nextStartsAt ?? "");
752    if (!Number.isFinite(nextStartsAtMs) || nextStartsAtMs <= Date.now()) {
753      return;
754    }
755    manualIntermissionUntilMs = nextStartsAtMs;
756    startsAtMs = nextStartsAtMs;
757    queueSync();
758  };
759  window.addEventListener("channel155-live-show-state", onLiveState);
760  window.addEventListener("channel155-schedule-state", onScheduleState);
761  window.addEventListener(SHARE_DISMISSED_EVENT, onShareDismissed);
762
763  sync();
764  catalog = await catalogPromise;
765  if (home?.isConnected) {
766    const upgradedHome = createRerunHome(catalog.episodes, startsAtMs);
767    home.replaceWith(upgradedHome);
768    home = upgradedHome;
769    updateCountdown();
770  }
771  queueSync();
772  countdownTimer = window.setInterval(() => {
773    updateCountdown();
774    queueSync();
775  }, 1_000);
776  window.addEventListener("pagehide", () => {
777    observer.disconnect();
778    window.cancelAnimationFrame(syncFrame);
779    window.clearInterval(countdownTimer);
780    window.removeEventListener("channel155-live-show-state", onLiveState);
781    window.removeEventListener("channel155-schedule-state", onScheduleState);
782    window.removeEventListener(SHARE_DISMISSED_EVENT, onShareDismissed);
783  }, { once: true });
784}
785
786function guestState(episodeKey) {
787  try {
788    return JSON.parse(window.localStorage.getItem(`${GUEST_STATE_PREFIX}
788${episodeKey}`)) ?? {};
789  } catch {
790    return {};
791  }
792}
793
794function saveGuestState(episodeKey, state) {
795  try {
796    window.localStorage.setItem(
797      `${GUEST_STATE_PREFIX}${episodeKey}`,
798      JSON.stringify(state),
799    );
800  } catch {}
801}
802
803function segmentAt(segments, positionMs) {
804  let startsAtMs = 0;
805  for (let index = 0; index < segments.length; index += 1) {
806    const segment = segments[index];
807    const durationMs = Math.max(1, Number(segment.durationMs) || 1);
808    if (positionMs < startsAtMs + durationMs || index === segments.length - 1) {
809      return {
810        ...segment,
811        index,
812        startsAtMs,
813        durationMs,
814        elapsedMs: Math.max(0, Math.min(durationMs, positionMs - startsAtMs)),
815      };
816    }
817    startsAtMs += durationMs;
818  }
819  return null;
820}
821
822function voiceDuration(asset, rate, fallback, leadIn = 0, allowance = 0) {
823  if (!asset) return fallback;
824  const playable = Math.max(
825    0,
826    Number(asset.playbackDurationMs) ||
827      Number(asset.durationMs) - Math.max(0, Number(asset.voiceStartOffsetMs) || 0),
828  );
829  return Math.max(
830    1,
831    Number(asset.phaseDurationMs) || 0,
832    leadIn + Math.ceil(playable / Math.max(.5, rate)) + allowance,
833  );
834}
835
836function quizVoiceAsset(manifest, segment) {
837  if (segment.state === "opening") return manifest.openingVoice;
838  if (["closing", "post_show"].includes(segment.state)) return manifest.closingVoice;
839  const question = manifest.questions?.[segment.questionIndex];
840  if (!question) return null;
841  if (segment.state === "smalltalk") return question.smalltalkVoice;
842  if (["question_intro", "question_open"].includes(segment.state)) return question.questionVoice;
843  if (["answer_reveal", "score_update"].includes(segment.state)) return question.revealVoice;
844  if (["transition", "bumper"].includes(segment.state)) return question.transitionVoice;
845  return null;
846}
847
848export function playbackPlan(archive) {
849  const manifest = archive.timelineManifest ?? {};
850  if (isQuizReplayKind(archive.kind)) {
851    const rate = Math.max(.5, Number(manifest.hostPlaybackRate) || 1.1);
852    const segments = Array.isArray(manifest.quizSegments)
853      ? manifest.quizSegments.map((segment) => {
854        const asset = quizVoiceAsset(manifest, segment);
855        return {
856          ...segment,
857          durationMs: Math.max(
858            1,
859            Number(segment.durationMs) || 1,
860            voiceDuration(
861              asset,
862              rate,
863              1,
864              Number(asset?.voiceLeadInMs) || 0,
865              900,
866            ),
867          ),
868        };
869      })
870      : [];
871    return segments.length ? segments : (manifest.questions ?? []).flatMap((_, index) => [
872      { state: "question_intro", questionIndex: index, durationMs: 8_000 },
873      { state: "answer_reveal", questionIndex: index, durationMs: 5_000 },
874      { state: "transition", questionIndex: index, durationMs: 2_000 },
875    ]);
876  }
877  if (["songs-quiz", "film-ghosts"].includes(archive.kind)) {
878    const rate = Math.max(.5, Number(manifest.hostPlaybackRate) || 1.3);
879    return [
880      {
881        state: "opening",
882        durationMs: voiceDuration(manifest.openingVoice, rate, 16_000, 500, 1_800),
883      },
884      { state: "game_countdown", durationMs: Number(manifest.gameCountdownDurationMs) || 3_000 },
885      ...(manifest.songRounds ?? []).flatMap((_, index) => [
886        { state: "guess", questionIndex: index, durationMs: Number(manifest.guessDurationMs) || 8_000 },
887        { state: "reveal", questionIndex: index, durationMs: Number(manifest.revealDurationMs) || 8_000 },
888        { state: "gap", questionIndex: index, durationMs: Number(manifest.interRoundGapMs) || 50 },
889      ]),
890      {
891        state: "closing",
892        durationMs: voiceDuration(manifest.closingVoice, rate, 11_000, 500, 1_800),
893      },
894    ];
895  }
896  if (archive.kind === "marketplace-price") {
897    const rate = Math.max(.5, Number(manifest.hostPlaybackRate) || 1.1);
898    return [
899      { state: "opening", durationMs: voiceDuration(manifest.openingVoice, rate, 8_000) },
900      ...(manifest.priceItems ?? []).flatMap((_, index) => [
901        { state: "guess", questionIndex: index, durationMs: 10_000 },
902        { state: "reveal", questionIndex: index, durationMs: 4_000 },
903      ]),
904      { state: "closing", durationMs: voiceDuration(manifest.closingVoice, rate, 7_000) },
905    ];
906  }
907  return [];
908}
909
910export function replayRevealPosition(archive, plan, actionID) {
911  const manifest = archive?.timelineManifest ?? {};
912  const items = archive?.kind === "hamoach"
913    ? manifest.questions ?? []
914    : ["songs-quiz", "film-ghosts"].includes(archive?.kind)
915    ? manifest.songRounds ?? []
916    : [];
917  const prefix = archive?.kind === "hamoach" ? "quiz:" : archive?.kind === "film-ghosts" ? "ghosts:" : "songs:";
918  const questionIndex = items.findIndex((item) =>
919    `${prefix}${item?.id}` === actionID
920  );
921  if (questionIndex < 0) return null;
922  const revealState = archive.kind === "hamoach" ? "answer_reveal" : "reveal";
923  let positionMs = 0;
924  for (const segment of plan) {
925    if (
926      segment.state === revealState &&
927      Number(segment.questionIndex) === questionIndex
928    ) return positionMs;
929    positionMs += Math.max(1, Number(segment.durationMs) || 1);
930  }
931  return null;
932}
933
934export function jumpReplaySessionToReveal(
935  session,
936  actionID,
937  nowMs = globalThis.performance?.now?.() ?? 0,
938) {
939  const targetMs = replayRevealPosition(
940    session?.archive,
941    session?.plan ?? [],
942    actionID,
943  );
944  if (
945    !Number.isFinite(targetMs) ||
946    targetMs <= Math.max(0, Number(session?.positionMs) || 0)
947  ) return false;
948  session.positionMs = targetMs;
949  session.anchorPositionMs = targetMs;
950  session.startedAt = nowMs;
951  session.phaseIdentity = "";
952  return true;
953}
954
955function planDuration(plan, fallback) {
956  const value = plan.reduce((total, segment) => total + Math.max(1, Number(segment.durationMs) || 1), 0);
957  return value > 0 ? Math.min(Math.max(1, fallback), value) : Math.max(1, fallback);
958}
959
960function resumePositionForOnce(archive, plan, submittedIDs) {
961  const manifest = archive.timelineManifest ?? {};
962  const ordered = isQuizReplayKind(archive.kind)
963    ? (manifest.questions ?? []).map((item) => `quiz:${item.id}`)
964    : ["songs-quiz", "film-ghosts"].includes(archive.kind)
965    ? (manifest.songRounds ?? []).map((item) => `${archive.kind === "film-ghosts" ? "ghosts" : "songs"}:${item.id}`)
966    : archive.kind === "marketplace-price"
967    ? (manifest.priceItems ?? []).map((item) => `price:${item.id}`)
968    : [];
969  const answeredPrefix = ordered.findIndex((id) => !submittedIDs.has(id));
970  const answeredCount = answeredPrefix === -1 ? ordered.length : answeredPrefix;
971  if (!ordered.length || answeredCount === 0) return 0;
972  if (answeredCount === ordered.length) {
973    return planDuration(plan, archive.durationMs);
974  }
975  const revealIndex = answeredCount - 1;
976  const revealState = isQuizReplayKind(archive.kind) ? "answer_reveal" : "reveal";
977  let position = 0;
978  for (const segment of plan) {
979    if (
980      segment.state === revealState &&
981      Number(segment.questionIndex) === revealIndex
982    ) return position;
983    position += Math.max(1, Number(segment.durationMs) || 1);
984  }
985  return 0;
986}
987
988function newsReplayEndMs(archive, durationMs) {
989  const manifest = archive?.timelineManifest ?? {};
990  const lastItem = Array.isArray(manifest.newsItems)
991    ? manifest.newsItems.at(-1)
992    : null;
993  if (!lastItem) return Math.max(1, durationMs);
994  const closingStartMs = Math.max(
995    0,
996    Number(lastItem.offsetMs) + Number(lastItem.durationMs),
997  );
998  const closing = manifest.closingVoice;
999  const rate = Math.max(.5, Number(manifest.hostPlaybackRate) || 1.25);
1000  const closingDurationMs = Math.max(
1001    1,
1002    Number(closing?.phaseDurationMs) ||
1003      Number(closing?.playbackDurationMs) + 500 ||
1004      Math.ceil((Number(closing?.durationMs) || 4_000) / rate) + 500,
1005  );
1006  return Math.min(Math.max(1, durationMs), closingStartMs + closingDurationMs);
1007}
1008
1009function newsReplayIsCompleted(archive, attempt, local, durationMs) {
1010  if (local.newsState === "completed") return true;
1011  if (local.newsState === "replaying") return false;
1012  const savedPositionMs = Math.max(0, Number(attempt?.positionMs) || 0);
1013  const playbackEndMs = newsReplayEndMs(archive, durationMs);
1014  if (savedPositionMs >= Math.max(1, playbackEndMs - 750)) return true;
1015  return Number(attempt?.completedCycles) > 0 && savedPositionMs === 0;
1016}
1017
1018function newsResumePositionMs(archive, savedPositionMs) {
1019  const offsets = (archive?.timelineManifest?.newsItems ?? [])
1020    .map((item) => Math.max(0, Number(item?.offsetMs) || 0));
1021  const saved = Math.max(0, Number(savedPositionMs) || 0);
1022  let reachedIndex = -1;
1023  for (let index = 0; index < offsets.length; index += 1) {
1024    if (offsets[index] <= saved) reachedIndex = index;
1025    else break;
1026  }
1027  return reachedIndex > 0 ? offsets[reachedIndex - 1] : 0;
1028}
1029
1030function musicResumePositionMs(serverPositionMs, localPositionMs, durationMs) {
1031  const duration = Math.max(1, durationMs);
1032  const restartBoundary = Math.max(0, duration - 1_000);
1033  const server = Math.min(duration, Math.max(0, Number(serverPositionMs) || 0));
1034  const local = Number.isFinite(Number(localPositionMs))
1035    ? Math.min(duration, Math.max(0, Number(localPositionMs)))
1036    : null;
1037  const resumableLocal = local > 0 && local < restartBoundary ? local : null;
1038  if (server >= restartBoundary) return resumableLocal ?? 0;
1039  const resolved = Math.max(server, resumableLocal ?? 0);
1040  return resolved >= restartBoundary ? 0 : resolved;
1041}
1042
1043export const WEB_REPLAY_RESUME_RULES = Object.freeze({
1044  once: resumePositionForOnce,
1045  newsCompleted: newsReplayIsCompleted,
1046  newsPosition: newsResumePositionMs,
1047  musicPosition: musicResumePositionMs,
1048});
1049
1050export function quizReplayTimeline(segments, startAtMs) {
1051  let cursor = startAtMs;
1052  return segments.map((segment, index) => {
1053    const startsAtMs = cursor;
1054    cursor += Math.max(1, Number(segment.durationMs) || 1);
1055    return { ...segment, sequence: index + 1, startsAtMs, endsAtMs: cursor };
1056  });
1057}
1058
1059// A per-viewer projection only. Always start from the frozen broadcast totals:
1060// rerenders, retries and "play again" must never accumulate extra votes.
1061export function quizReplayAnswerCounts(originalCounts, choiceCount, selectedChoice) {
1062  if (!Array.isArray(originalCounts) || originalCounts.length !== choiceCount) return null;
1063  const counts = originalCounts.map(value => Math.max(0, Number(value) || 0));
1064  if (Number.isInteger(selectedChoice) && selectedChoice >= 0 && selectedChoice < choiceCount) {
1065    counts[selectedChoice] += 1;
1066  }
1067  return counts;
1068}
1069
1070export function quizDemoPackageFromArchive(archive, plan = playbackPlan(archive)) {
1071  const manifest = archive?.timelineManifest ?? {};
1072  const questions = Array.isArray(manifest.questions)
1073    ? manifest.questions.map((question, index) => ({
1074      id: question.id,
1075      category: question.category || "החידון",
1076      text: question.text || "",
1077      choices: Array.isArray(question.choices) ? question.choices : [],
1078      correct: Number(question.correctIndex) || 0,
1079      smalltalk: question.smalltalk || "עוד רגע עולה השאלה הבאה.",
1080      transition: question.transition || `שאלה ${index + 2}`,
1081      timerLine: question.timerLine || "חמש עשרה שניות. השעון לא מחכה.",
1082      reveal: question.reveal || "התשובה נחשפה.",
1083      ordinal: Number(question.ordinal) || index + 1,
1084      answerCounts: Array.isArray(question.answerCounts) &&
1085          question.answerCounts.length === (question.choices?.length ?? 0)
1086        ? question.answerCounts
1087        : [],
1088      answerCountsReady: Array.isArray(question.answerCounts) &&
1089        question.answerCounts.length === (question.choices?.length ?? 0),
1090    }))
1091    : [];
1092  const questionAssets = (manifest.questions ?? []).map((item) =>
1093    item.questionVoice ?? null
1094  );
1095  const revealAssets = (manifest.questions ?? []).map((item) =>
1096    item.revealVoice ?? null
1097  );
1098  const transitionAssets = (manifest.questions ?? []).map((item) =>
1099    item.transitionVoice ?? null
1100  );
1101  const duration = (asset) => Math.max(1, Number(asset?.durationMs) || 1);
1102  const cues = (asset) => Array.isArray(asset?.captionCues)
1103    ? asset.captionCues
1104    : [];
1105  return {
1106    packageId: archive.episodeKey,
1107    // The seek clock and rendered quiz must use the same audio-adjusted plan.
1108    // Reconstructing only selected phases from the raw archive accumulates drift.
1109    timelineSegments: plan,
1110    openingScriptHe: manifest.openingText || "",
1111    closingScriptHe: manifest.closingText || "",
1112    questions,
1113    voice: {
1114      opening: manifest.openingVoice?.url ?? null,
1115      question: questionAssets.map((asset) => asset?.url ?? null),
1116      reveal: revealAssets.map((asset) => asset?.url ?? null),
1117      transition: transitionAssets.map((asset) => asset?.url ?? null),
1118      closing: manifest.closingVoice?.url ?? null,
1119      durations: {
1120        opening: duration(manifest.openingVoice),
1121        question: questionAssets.map(duration),
1122        reveal: revealAssets.map(duration),
1123        transition: transitionAssets.map(duration),
1124        closing: duration(manifest.closingVoice),
1125      },
1126      captionCues: {
1127        opening: cues(manifest.openingVoice),
1128        question: questionAssets.map(cues),
1129        reveal: revealAssets.map(cues),
1130        transition: transitionAssets.map(cues),
1131        closing: cues(manifest.closingVoice),
1132      },
1133    },
1134  };
1135}
1136
1137function submittedReplayState(attempt, local) {
1138  const selections = { ...(local.selections ?? {}) };
1139  const guesses = { ...(local.guesses ?? {}) };
1140  for (const action of attempt?.submittedActions ?? []) {
1141    if (Number.isInteger(action?.payload?.choiceIndex)) {
1142      selections[action.actionID] = Number(action.payload.choiceIndex);
1143    }
1144    if (Number.isFinite(Number(action?.payload?.guess))) {
1145      guesses[action.actionID] = Number(action.payload.guess);
1146    }
1147  }
1148  return { selections, guesses };
1149}
1150
1151export async function prepareLiveWebReplay(programKey) {
1152  const show = showByProgram.get(programKey);
1153  const demoMode = WEB_REPLAY_DEMO_MODES[programKey];
1154  if (!show || !demoMode) throw new Error("Unknown replay program");
1155
1156  document.documentElement.classList.add("is-web-replay", "is-live-ui-replay");
1157  document.title = `${show.title} — שידור חוזר | ערוץ 15½`;
1158  applyWebReplayMetadata(show);
1159  window.__channelSuppressFirstVisit = true;
1160  window.__channelQuizProgramKey = "hamoach";
1161  document.documentElement.dataset.channelProgramKey = window.__channelQuizProgramKey;
1162
1163  const archiveURL = new URL(REPLAY_PUBLIC_ENDPOINT, window.location.origin);
1164  archiveURL.searchParams.set("webReplay", "archive");
1165  archiveURL.searchParams.set("programKey", programKey);
1166  const { archive } = await requestPublicReplayJSON(archiveURL, { timeoutMs: 10_000 });
1167  const local = guestState(archive.episodeKey);
1168  let signedIn = true;
1169  let attempt = null;
1170  try {
1171    const response = await requestJSON(REPLAY_ENDPOINT, {
1172      method: "POST",
1173      body: JSON.stringify({
1174        action: "start",
1175        episodeKey: archive.episodeKey,
1176        deferChat: true,
1177        requestID: replayRequestID(),
1178      }),
1179    });
1180    attempt = response.attempt;
1181  } catch (error) {
1182    if (error.status === 401) signedIn = false;
1183    else if (error.code === "replay_already_completed") {
1184      attempt = error.payload?.attempt;
1185    } else {
1186      // A replay is public media. A transient profile/session write failure
1187      // must not turn a ready archive into a loading error; retain progress
1188      // locally and let the next entry reconcile with the server.
1189      signedIn = false;
1190      console.warn("Replay progress unavailable; continuing locally", error);
1191    }
1192  }
1193
1194  const submittedIDs = new Set(
1195    signedIn
1196      ? attempt?.submittedActionIDs ?? []
1197      : Object.keys(local.selections ?? {}).concat(Object.keys(local.guesses ?? {})),
1198  );
1199  const { selections, guesses } = submittedReplayState(
1200    attempt,
1201    signedIn ? {} : local,
1202  );
1203  const plan = playbackPlan(archive);
1204  const durationMs = planDuration(
1205    plan,
1206    Math.max(1, Number(archive.durationMs) || 1),
1207  );
1208  const completed = archive.replayPolicy === "once"
1209    ? (signedIn ? attempt?.status === "completed" : local.completed === true)
1210    : archive.kind === "daily-news"
1211    ? newsReplayIsCompleted(archive, attempt, local, durationMs)
1212    : false;
1213  const serverPositionMs = signedIn
1214    ? Math.max(0, Number(attempt?.positionMs) || 0)
1215    : Math.max(0, Number(local.positionMs) || 0);
1216  const initialPositionMs = completed
1217    ? durationMs
1218    : archive.replayPolicy === "once"
1219    ? resumePositionForOnce(archive, plan, submittedIDs)
1220    : archive.kind === "daily-news"
1221    ? newsResumePositionMs(archive, serverPositionMs)
1222    : archive.kind === "music-battle"
1223    ? musicResumePositionMs(serverPositionMs, local.positionMs, durationMs)
1224    : serverPositionMs;
1225  const boundedPositionMs = Math.min(durationMs, initialPositionMs);
1226  void warmReplayCriticalMedia(archive, plan, boundedPositionMs).catch(() => null);
1227  const startupNotBeforeMs = Date.now() +
1228    (completed ? 0 : REPLAY_STARTUP_LEAD_MS);
1229  const showStartsAtMs = startupNotBeforeMs - boundedPositionMs;
1230  const playbackClock = createReplayClock(startupNotBeforeMs);
1231  playbackClock.reset(boundedPositionMs, startupNotBeforeMs);
1232  let playbackStarted = completed ||
1233    (!isQuizReplayKind(programKey) && archive.kind !== "daily-news");
1234  const serverCompletionRemainingMs = Math.max(
1235    0,
1236    Number(archive.durationMs) - (Number(attempt?.positionMs) || 0),
1237  );
1238  let completionNotBeforeMs = Date.now() + serverCompletionRemainingMs;
1239
1240  const replay = {
1241    archive,
1242    demoMode,
1243    show,
1244    durationMs,
1245    showStartsAtMs,
1246    startupNotBeforeMs,
1247    selections,
1248    guesses,
1249    submittedIDs,
1250    signedIn,
1251    completed,
1252    restartInFlight: false,
1253    completionPersisted: completed,
1254    musicLoopSyncInFlight: false,
1255    chatMessages: [],
1256    mediaRates: new Map(),
1257    playbackRate: 1,
1258    get waitingForStart() { return !playbackStarted; },
1259    nowMs() {
1260      // A frozen position at zero still selects the opening phase and starts
1261      // narration. Stay strictly before the show until its visible surface is ready.
1262      if (!playbackStarted || Date.now() < this.startupNotBeforeMs) {
1263        return this.showStartsAtMs - 1;
1264      }
1265      const position = this.positionMs();
1266      return this.showStartsAtMs + position;
1267    },
1268    startPlayback() {
1269      if (playbackStarted) return false;
1270      playbackStarted = true;
1271      this.startupNotBeforeMs = Date.now() + REPLAY_STARTUP_LEAD_MS;
1272      this.showStartsAtMs = this.startupNotBeforeMs - boundedPositionMs;
1273      playbackClock.reset(boundedPositionMs, this.startupNotBeforeMs);
1274      completionNotBeforeMs =
1275        this.startupNotBeforeMs + serverCompletionRemainingMs;
1276      if (window.__channelDemo) {
1277        window.__channelDemo.showStartsAt =
1278          new Date(this.showStartsAtMs).toISOString();
1279      }
1280      window.dispatchEvent(new CustomEvent(REPLAY_RATE_EVENT, {
1281        detail: { rate: this.playbackRate, reason: "startup-ready" },
1282      }));
1283      this.armCompletion?.();
1284      return true;
1285    },
1286    setPlaybackRate(rate) {
1287      const next = rate === 2 && !this.completed ? 2 : 1;
1288      if (next === this.playbackRate) return;
1289      playbackClock.setRate(next);
1290      this.playbackRate = next;
1291      for (const [media, base] of this.mediaRates) media.playbackRate = base * next;
1292      document.documentElement.dataset.replayPlaybackRate = String(next);
1293      window.dispatchEvent(new CustomEvent(REPLAY_RATE_EVENT, { detail: { rate: next } }));
1294      this.armCompletion?.();
1295    },
1296    quizDemoPackage: isQuizReplayKind(programKey)
1297      ? quizDemoPackageFromArchive(archive, plan)
1298      : null,
1299    positionMs() {
1300      if (!playbackStarted) return boundedPositionMs;
1301      const elapsedMs = playbackClock.positionMs();
1302      if (archive.kind === "music-battle" && elapsedMs >= this.durationMs) {
1303        const positionMs = elapsedMs % this.durationMs;
1304        playbackClock.reset(positionMs);
1305        this.cancelSpeedHold?.();
1306        this.showStartsAtMs = Date.now() - positionMs;
1307        this.startupNotBeforeMs = this.showStartsAtMs;
1308        window.__channelDemo && (window.__channelDemo.showStartsAt =
1309          new Date(this.showStartsAtMs).toISOString());
1310        saveGuestState(archive.episodeKey, {
1311          selections: this.selections,
1312          guesses: this.guesses,
1313          positionMs: 0,
1314          completed: false,
1315        });
1316        window.dispatchEvent(new CustomEvent(REPLAY_RESTARTED_EVENT, {
1317          detail: {
1318            kind: archive.kind,
1319            showStartsAtMs: this.showStartsAtMs,
1320            automatic: true,
1321          },
1322        }));
1323        if (this.signedIn && !this.musicLoopSyncInFlight) {
1324          this.musicLoopSyncInFlight = true;
1325          void requestJSON(REPLAY_ENDPOINT, {
1326            method: "POST",
1327            body: JSON.stringify({
1328              action: "exit",
1329              episodeKey: archive.episodeKey,
1330              positionMs: 0,
1331              requestID: replayRequestID(),
1332            }),
1333          }).catch(() => null).finally(() => {
1334            this.musicLoopSyncInFlight = false;
1335          });
1336        }
1337        return positionMs;
1338      }
1339      return Math.max(0, Math.min(this.durationMs, elapsedMs));
1340    },
1341    jumpToReveal(actionID) {
1342      const targetMs = replayRevealPosition(archive, plan, actionID);
1343      if (!Number.isFinite(targetMs) || targetMs <= this.positionMs()) return false;
1344      this.cancelSpeedHold?.();
1345      playbackClock.reset(targetMs);
1346      this.showStartsAtMs = Date.now() - targetMs;
1347      this.startupNotBeforeMs = this.showStartsAtMs;
1348      if (window.__channelDemo) {
1349        window.__channelDemo.showStartsAt =
1350          new Date(this.showStartsAtMs).toISOString();
1351      }
1352      window.dispatchEvent(new CustomEvent(REPLAY_RATE_EVENT, {
1353        detail: { rate: this.playbackRate, reason: "answer-reveal" },
1354      }));
1355      return true;
1356    },
1357    async submit(actionID, payload) {
1358      if (!actionID || this.submittedIDs.has(actionID)) return;
1359      this.submittedIDs.add(actionID);
1360      if (Number.isFinite(Number(payload?.guess))) {
1361        this.guesses[actionID] = Number(payload.guess);
1362      } else if (Number.isInteger(payload?.choiceIndex)) {
1363        this.selections[actionID] = Number(payload.choiceIndex);
1364      }
1365      this.jumpToReveal(actionID);
1366      if (!this.signedIn) {
1367        saveGuestState(archive.episodeKey, {
1368          selections: this.selections,
1369          guesses: this.guesses,
1370          positionMs: archive.replayPolicy === "unlimited"
1371            ? this.positionMs()
1372            : 0,
1373          completed: false,
1374        });
1375        trackReplayParticipation(archive, actionID, payload, false);
1376        return;
1377      }
1378      try {
1379        await requestJSON(REPLAY_ENDPOINT, {
1380          method: "POST",
1381          body: JSON.stringify({
1382            action: "submit",
1383            episodeKey: archive.episodeKey,
1384            actionID,
1385            payload,
1386            requestID: replayRequestID(),
1387          }),
1388        });
1389        trackReplayParticipation(archive, actionID, payload, true);
1390      } catch (error) {
1391        this.submittedIDs.delete(actionID);
1392        delete this.selections[actionID];
1393        delete this.guesses[actionID];
1394        throw error;
1395      }
1396    },
1397    async restart() {
1398      if (this.restartInFlight) return false;
1399      if (!["hamoach", "songs-quiz", "film-ghosts", "marketplace-price", "daily-news"]
1400        .includes(archive.kind)) return false;
1401      this.restartInFlight = true;
1402      const restartLocally = () => {
1403        window.clearTimeout(serverCompletionTimer);
1404        this.cancelSpeedHold?.();
1405        Object.keys(this.selections).forEach((key) => delete this.selections[key]);
1406        Object.keys(this.guesses).forEach((key) => delete this.guesses[key]);
1407        this.submittedIDs.clear();
1408        this.completed = false;
1409        this.completionPersisted = false;
1410        this.showStartsAtMs = Date.now() + 120;
1411        this.startupNotBeforeMs = this.showStartsAtMs;
1412        playbackClock.reset(0, this.startupNotBeforeMs);
1413        completionNotBeforeMs = Date.now() + Number(archive.durationMs);
1414        if (window.__channelDemo) {
1415          window.__channelDemo.showStartsAt =
1416            new Date(this.showStartsAtMs).toISOString();
1417        }
1418        saveGuestState(archive.episodeKey, {
1419          selections: this.selections,
1420          guesses: this.guesses,
1421          positionMs: 0,
1422          completed: false,
1423          ...(archive.kind === "daily-news"
1424            ? { newsState: "replaying" }
1425            : {}),
1426        });
1427        window.dispatchEvent(new CustomEvent(REPLAY_RESTARTED_EVENT, {
1428          detail: { kind: archive.kind, showStartsAtMs: this.showStartsAtMs },
1429        }));
1430      };
1431      try {
1432        if (archive.kind === "daily-news") {
1433          restartLocally();
1434          if (this.signedIn) {
1435            void requestJSON(REPLAY_ENDPOINT, {
1436              method: "POST",
1437              body: JSON.stringify({
1438                action: "start",
1439                episodeKey: archive.episodeKey,
1440                deferChat: true,
1441                requestID: replayRequestID(),
1442              }),
1443            }).then((response) => {
1444              attempt = response.attempt ?? attempt;
1445            }).catch(() => null);
1446          }
1447          this.armCompletion?.();
1448          return true;
1449        }
1450        if (this.signedIn) {
1451          const response = await requestJSON(REPLAY_ENDPOINT, {
1452            method: "POST",
1453            body: JSON.stringify({
1454              action: "reset",
1455              episodeKey: archive.episodeKey,
1456              requestID: replayRequestID(),
1457            }),
1458          });
1459          attempt = response.attempt ?? attempt;
1460        }
1461        restartLocally();
1462        this.armCompletion?.();
1463        return true;
1464      } catch {
1465        return false;
1466      } finally {
1467        this.restartInFlight = false;
1468      }
1469    },
1470  };
1471  window.__channelReplay = replay;
1472  installReplaySpeed(replay);
1473  void loadReplayChatInBackground(replay, archive.episodeKey)
1474    .catch((error) => {
1475      document.documentElement.dataset.replayChatState = "error";
1476      console.warn("Replay chat unavailable", error);
1477    });
1478
1479  let completionTimer = 0;
1480  let serverCompletionTimer = 0;
1481  const persist = (reason = "exit") => {
1482    const positionMs = replay.positionMs();
1483    const isComplete = reason === "complete" || positionMs >= replay.durationMs;
1484    if (isComplete && replay.completionPersisted) return;
1485    saveGuestState(archive.episodeKey, {
1486      selections: replay.selections,
1487      guesses: replay.guesses,
1488      positionMs: archive.replayPolicy === "unlimited" && !isComplete
1489        ? positionMs
1490        : 0,
1491      completed: archive.replayPolicy === "once" && isComplete,
1492      ...(archive.kind === "daily-news"
1493        ? { newsState: isComplete ? "completed" : "replaying" }
1494        : {}),
1495    });
1496    if (isComplete) {
1497      replay.completed = true;
1498      replay.completionPersisted = true;
1499      replay.cancelSpeedHold?.();
1500    }
1501    if (!replay.signedIn) {
1502      return;
1503    }
1504    const body = JSON.stringify({
1505      action: isComplete ? "complete" : "exit",
1506      episodeKey: archive.episodeKey,
1507      positionMs: isComplete ? replay.durationMs : positionMs,
1508      requestID: replayRequestID(),
1509    });
1510    const send = () => navigator.sendBeacon?.(
1511      REPLAY_ENDPOINT, new Blob([body], { type: "application/json" }),
1512    );
1513    // The server owns completion eligibility; accelerated client time must not
1514    // weaken that contract or emit a guaranteed-to-fail early completion.
1515    if (isComplete && Date.now() < completionNotBeforeMs) {
1516      window.clearTimeout(serverCompletionTimer);
1517      serverCompletionTimer = window.setTimeout(send, completionNotBeforeMs - Date.now() + 250);
1518    } else send();
1519  };
1520  replay.markCompleted = () => persist("complete");
1521  replay.armCompletion = () => {
1522    window.clearTimeout(completionTimer);
1523    if (archive.kind === "music-battle" || replay.completed) return;
1524    completionTimer = window.setTimeout(
1525      () => persist("complete"),
1526      Math.max(0, replay.startupNotBeforeMs - Date.now()) +
1527        Math.max(0, replay.durationMs - replay.positionMs()) / replay.playbackRate + 250,
1528    );
1529  };
1530  replay.armCompletion();
1531  const bookmarkTimer = archive.replayPolicy === "unlimited"
1532    ? window.setInterval(() => {
1533      const positionMs = replay.positionMs();
1534      if (replay.completed || positionMs >= replay.durationMs) return;
1535      saveGuestState(archive.episodeKey, {
1536        selections: replay.selections,
1537        guesses: replay.guesses,
1538        positionMs,
1539        completed: false,
1540        ...(archive.kind === "daily-news"
1541          ? { newsState: "replaying" }
1542          : {}),
1543      });
1544    }, REPLAY_BOOKMARK_INTERVAL_MS)
1545    : 0;
1546  const persistOnExit = () => persist("exit");
1547  window.addEventListener("pagehide", persistOnExit, { once: true });
1548  window.addEventListener("beforeunload", persistOnExit, { once: true });
1549  window.addEventListener("pagehide", () => {
1550    window.clearTimeout(completionTimer);
1551    window.clearInterval(bookmarkTimer);
1552  }, { once: true });
1553  return replay;
1554}
1555
1556function avatarURL(message) {
1557  if (message.avatarUrl) return message.avatarUrl;
1558  return REPLAY_AVATAR_PLACEHOLDER;
1559}
1560
1561function renderChat(panel, messages, positionMs) {
1562  const visible = messages.filter((message) => Number(message.offsetMs) <= positionMs);
1563  const list = panel.querySelector("[data-replay-chat-list]");
1564  const previousLast = list.dataset.lastMessageId;
1565  const nextLast = visible.at(-1)?.id ?? "";
1566  if (previousLast === nextLast) return;
1567  list.innerHTML = visible.map((message) => `
1568    <article class="web-replay-chat__message">
1569      <img src="${escapeHTML(avatarURL(message))}" alt="" loading="lazy">
1570      <p><strong>${escapeHTML(message.name)}</strong> ${escapeHTML(message.text)}</p>
1571    </article>
1572  `).join("");
1573  list.dataset.lastMessageId = nextLast;
1574  list.scrollTop = list.scrollHeight;
1575}
1576
1577function createYouTubeTransport(container) {
1578  let identity = "";
1579  let frame = null;
1580  let stopTimer = 0;
1581  const command = (func, args = []) => frame?.contentWindow?.postMessage(
1582    JSON.stringify({ event: "command", func, args }),
1583    "https://www.youtube.com",
1584  );
1585  return {
1586    play({ id, videoId, startSeconds = 0, muted = true, stopAfterMs = 0 }) {
1587      if (!videoId) return this.stop();
1588      if (identity !== id) {
1589        identity = id;
1590        window.clearTimeout(stopTimer);
1591        container.innerHTML = "";
1592        frame = document.createElement("iframe");
1593        frame.allow = "autoplay; encrypted-media; picture-in-picture";
1594        frame.referrerPolicy = "strict-origin-when-cross-origin";
1595        frame.title = "נגן וידאו";
1596        frame.src = `https://www.youtube.com/embed/${encodeURIComponent(videoId)}?enablejsapi=1&autoplay=1&controls=0&playsinline=1&rel=0&modestbranding=1&start=${Math.max(0, Math.floor(startSeconds))}&origin=${encodeURIComponent(window.location.origin)}${muted ? "&mute=1" : ""}`;
1597        frame.addEventListener("load", () => {
1598          command("seekTo", [Math.max(0, startSeconds), true]);
1599          command(muted ? "mute" : "unMute");
1600          command("playVideo");
1601        });
1602        container.appendChild(frame);
1603        if (stopAfterMs > 0) stopTimer = window.setTimeout(() => command("pauseVideo"), stopAfterMs);
1604      } else {
1605        command(muted ? "mute" : "unMute");
1606      }
1607    },
1608    stop() {
1609      window.clearTimeout(stopTimer);
1610      identity = "";
1611      frame = null;
1612      container.innerHTML = "";
1613    },
1614    setMuted(muted) {
1615      command(muted ? "mute" : "unMute");
1616    },
1617  };
1618}
1619
1620function captionFor(asset, elapsedMs, fallback = "") {
1621  const cues = Array.isArray(asset?.captionCues) ? asset.captionCues : [];
1622  const offset = Math.max(0, Number(asset?.voiceStartOffsetMs) || 0);
1623  const mediaMs = elapsedMs + offset;
1624  return cues.find((cue) => mediaMs >= Number(cue.startMs) && mediaMs <= Number(cue.endMs))?.text || fallback;
1625}
1626
1627function currentNewsItem(archive, positionMs) {
1628  const items = archive.timelineManifest?.newsItems ?? [];
1629  return items.find((item) => positionMs >= Number(item.offsetMs) && positionMs < Number(item.offsetMs) + Number(item.durationMs)) ?? null;
1630}
1631
1632function currentMusicRound(archive, positionMs) {
1633  const rounds = archive.timelineManifest?.musicRounds ?? [];
1634  let selected = rounds[0] ?? null;
1635  for (const round of rounds) {
1636    if (positionMs >= Number(round.startedOffsetMs ?? 0)) selected = round;
1637  }
1638  return selected;
1639}
1640
1641function musicPercentages(round, positionMs) {
1642  const candidates = Array.isArray(round?.candidates)
1643    ? round.candidates.slice(0, 2)
1644    : [];
1645  if (candidates.length !== 2) return candidates.map(() => 0);
1646  const elapsedMs = Math.max(
1647    0,
1648    positionMs - Number(round.startedOffsetMs ?? 0),
1649  );
1650  const visibleVotes = (round.liveVoteTimeline ?? []).filter((vote) =>
1651    Number(vote.offsetMs) <= elapsedMs
1652  );
1653  const counts = candidates.map((candidate) => visibleVotes.filter((vote) =>
1654    vote.candidateVideoID === candidate.id ||
1655    vote.candidateVideoID === candidate.youtubeVideoId
1656  ).length);
1657  const total = counts.reduce((sum, value) => sum + value, 0);
1658  return total > 0
1659    ? counts.map((value) => Math.round(value / total * 100))
1660    : [50, 50];
1661}
1662
1663function newsReactionAt(archive, positionMs) {
1664  let selected = null;
1665  for (const event of archive.reactionTimeline ?? []) {
1666    if (Number(event.offsetMs) <= positionMs) selected = event;
1667  }
1668  return selected;
1669}
1670
1671function stageMarkup(session, phase) {
1672  const { archive, selections, guesses, positionMs } = session;
1673  const manifest = archive.timelineManifest ?? {};
1674  if (isQuizReplayKind(archive.kind)) {
1675    if (["opening", "smalltalk", "closing", "post_show"].includes(phase?.state)) {
1676      const question = manifest.questions?.[phase?.questionIndex] ?? null;
1677      const text = phase?.state === "opening" ? manifest.openingText
1678        : ["closing", "post_show"].includes(phase?.state) ? manifest.closingText
1679        : phase?.state === "smalltalk" ? question?.category
1680        : question?.reveal;
1681      return `<div class="web-replay-host web-replay-host--quiz">
1682        <video src="/assets/host-presenter-v6.mp4" autoplay muted loop playsinline></video>
1683        ${text ? `<p>${escapeHTML(text)}</p>` : ""}
1684      </div>`;
1685    }
1686    const question = manifest.questions?.[phase?.questionIndex];
1687    if (!question) return '<p class="web-replay-empty">התוכן אינו זמין</p>';
1688    const actionID = `quiz:${question.id}`;
1689    const selected = selections[actionID];
1690    const reveal = ["answer_reveal", "score_update", "transition", "bumper"].includes(phase.state);
1691    const answering = ["question_intro", "question_open"].includes(phase.state);
1692    const correctCount = (manifest.questions ?? [])
1693      .slice(0, phase.questionIndex + (reveal ? 1 : 0))
1694      .filter((item) => selections[`quiz:${item.id}`] === item.correctIndex)
1695      .length;
1696    return `<section class="web-replay-question">
1697      <p class="web-replay-question__meta">${correctCount} נכונות · שאלה ${question.ordinal}/${manifest.questions.length}</p>
1698      <h2>${escapeHTML(question.text)}</h2>
1699      <div class="web-replay-choices">${question.choices.map((choice, index) => `
1700        <button data-replay-choice="${index}" data-action-id="${escapeHTML(actionID)}"
1701          ${!answering || selected !== undefined ? "disabled" : ""}
1702          class="${selected === index ? "is-selected" : ""} ${reveal && index === question.correctIndex ? "is-correct" : ""} ${reveal && selected === index && index !== question.correctIndex ? "is-wrong" : ""}">
1703          ${escapeHTML(choice)}
1704        </button>`).join("")}</div>
1705      ${reveal ? `<p class="web-replay-reveal">${escapeHTML(question.reveal)}</p>` : ""}
1706    </section>`;
1707  }
1708  if (["songs-quiz", "film-ghosts"].includes(archive.kind)) {
1709    const isFilmGhosts = archive.kind === "film-ghosts";
1710    if (["opening", "closing"].includes(phase?.state)) {
1711      const text = phase.state === "opening" ? manifest.openingText : manifest.closingText;
1712      const hostVideo = REPLAY_STATIC_MEDIA[archive.kind]?.video ||
1713        "/media/static/v1/songs-host-v1.mp4";
1714      return `<div class="web-replay-host web-replay-host--songs ${isFilmGhosts ? "web-replay-host--film-ghosts" : ""}">
1715        <video src="${escapeHTML(hostVideo)}" autoplay muted loop playsinline></video>
1716        ${text ? `<p>${escapeHTML(text)}</p>` : ""}
1717      </div>`;
1718    }
1719    if (phase?.state === "game_countdown") {
1720      return `<div class="web-replay-game-countdown"><strong>${Math.max(1, 3 - Math.floor(phase.elapsedMs / 1_000))}</strong><span>מתחילים!</span></div>`;
1721    }
1722    const round = manifest.songRounds?.[phase?.questionIndex];
1723    if (!round) return "";
1724    const actionID = `${archive.kind === "film-ghosts" ? "ghosts" : "songs"}:${round.id}`;
1725    const selected = selections[actionID];
1726    const reveal = ["reveal", "gap"].includes(phase.state);
1727    const correctCount = isFilmGhosts
1728      ? (manifest.songRounds ?? [])
1729        .slice(0, phase.questionIndex + (reveal ? 1 : 0))
1730        .filter((item) => selections[`ghosts:${item.id}`] === item.correctIndex)
1731        .length
1732      : 0;
1733    const filmRoundProgress = isFilmGhosts
1734      ? Math.max(0, Math.min(1, 1 - phase.elapsedMs / Math.max(1, phase.durationMs)))
1735      : 0;
1736    const imageURL = isFilmGhosts
1737      ? (reveal ? round.originalImageUrl : round.editedImageUrl || round.thumbnailURL)
1738      : round.thumbnailURL;
1739    const choices = round.choices.map((choice, index) => `
1740        <button data-replay-choice="${index}" data-action-id="${escapeHTML(actionID)}"
1741          ${reveal || selected !== undefined ? "disabled" : ""}
1742          class="${selected === index ? "is-selected" : ""} ${reveal && index === round.correctIndex ? "is-correct" : ""} ${reveal && selected === index && index !== round.correctIndex ? "is-wrong" : ""}">
1743          ${escapeHTML(choice)}
1744        </button>`).join("");
1745    const answerArea = isFilmGhosts
1746      ? `<div class="web-replay-film-answer-slot ${reveal ? "is-reveal" : ""}">
1747          <div class="web-replay-choices">${choices}</div>
1748          ${reveal ? `<p class="web-replay-film-answer">${escapeHTML(round.titleHe || round.song)}</p>` : ""}
1749        </div>`
1750      : `<div class="web-replay-choices">${choices}</div>`;
1751    return `<section class="web-replay-question web-replay-question--songs ${isFilmGhosts ? "web-replay-question--film-ghosts" : ""}">
1752      <p class="web-replay-question__meta">
1752${isFilmGhosts ? `<span>${correctCount} ניחושים</span><span>שאלה ${round.ordinal} מ-${manifest.songRounds.length}</span>` : `שיר ${round.ordinal}/${manifest.songRounds.length}`}</p>
1753      ${isFilmGhosts ? `<div class="web-replay-question__film-progress" aria-hidden="true"><i style="transform:scaleX(${filmRoundProgress})"></i></div>` : ""}
1754      ${isFilmGhosts ? `<h2>${reveal ? (selected === round.correctIndex ? "בול! תשובה נכונה" : "כמעט. הנה התשובה:") : "מה הסרט או הסדרה?"}</h2>` : ""}
1755      ${imageURL ? `<img class="${isFilmGhosts ? "web-replay-film-thumb" : "web-replay-song-thumb"}" src="${escapeHTML(imageURL)}" alt="">` : ""}
1756      ${isFilmGhosts ? "" : `<h2>${reveal ? `${escapeHTML(round.song)} — ${escapeHTML(round.artist)}` : "איזה שיר זה?"}</h2>`}
1757      ${answerArea}
1758    </section>`;
1759  }
1760  if (archive.kind === "marketplace-price") {
1761    if (["opening", "closing"].includes(phase?.state)) {
1762      const text = phase.state === "opening" ? manifest.openingText : manifest.closingText;
1763      return `<div class="web-replay-host web-replay-host--price">
1764        <video src="/assets/marketplace-stage-host-v2.mp4" autoplay muted loop playsinline></video>
1765        ${text ? `<p>${escapeHTML(text)}</p>` : ""}
1766      </div>`;
1767    }
1768    const item = manifest.priceItems?.[phase?.questionIndex];
1769    if (!item) return "";
1770    const actionID = `price:${item.id}`;
1771    const guess = guesses[actionID];
1772    const reveal = phase.state === "reveal";
1773    return `<section class="web-replay-price">
1774      ${item.imageURL ? `<img src="${escapeHTML(item.imageURL)}" alt="${escapeHTML(item.imageAlt)}">` : ""}
1775      <div><p class="web-replay-question__meta">מוצר ${item.ordinal}/${manifest.priceItems.length}</p>
1776      <h2>${escapeHTML(item.title)}</h2><p>${escapeHTML(item.description)}</p>
1777      ${reveal ? `<strong class="web-replay-price__answer">₪${Number(item.price).toLocaleString("he-IL")}</strong>` : `
1778        <form data-replay-price-form data-action-id="${escapeHTML(actionID)}">
1779          <label>הניחוש שלכם <input name="guess" inputmode="numeric" type="number" min="0" required value="${escapeHTML(guess ?? "")}"></label>
1780          <button type="submit" ${guess !== undefined ? "disabled" : ""}>שליחת ניחוש</button>
1781        </form>`}
1782      </div>
1783    </section>`;
1784  }
1785  if (archive.kind === "daily-news") {
1786    const item = currentNewsItem(archive, positionMs);
1787    const reaction = newsReactionAt(archive, positionMs);
1788    if (!item) {
1789      const firstOffset = Number(manifest.newsItems?.[0]?.offsetMs ?? 0);
1790      const opening = positionMs < firstOffset;
1791      return `<div class="web-replay-host web-replay-host--news">
1792        <video src="/assets/news-host-presenter-v3.mp4" autoplay muted loop playsinline></video>
1793        <p>${escapeHTML(opening ? "ברוכים הבאים למתי המלחמה הבאה?" : "זה הכל להפעם")}</p>
1794      </div>`;
1795    }
1796    return `<article class="web-replay-news">
1797      ${item.imageURL ? `<img src="${escapeHTML(item.imageURL)}" alt="">` : ""}
1798      <div><p class="web-replay-question__meta">${escapeHTML(item.category || item.sourceName)}</p>
1799      <h2>${escapeHTML(item.title)}</h2><p>${escapeHTML(item.description)}</p>
1800      ${reaction ? `<p class="web-replay-news__reactions">💩 ${Number(reaction.poopCount) || 0} · 🚀 ${Number(reaction.rocketCount) || 0}</p>` : ""}
1801      ${item.articleURL ? `<a href="${escapeHTML(item.articleURL)}" target="_blank" rel="noopener">לכתבה המלאה</a>` : ""}</div>
1802    </article>`;
1803  }
1804  if (archive.kind === "music-battle") {
1805    const round = currentMusicRound(archive, positionMs);
1806    if (!round) return '<p class="web-replay-empty">התוכן אינו זמין</p>';
1807    const localVote = selections[`music:${round.id}`];
1808    const percentages = musicPercentages(round, positionMs);
1809    return `<section class="web-replay-music">
1810      <p class="web-replay-question__meta">${escapeHTML(round.theme)}</p>
1811      <h2>${escapeHTML(round.played.song)} — ${escapeHTML(round.played.artist)}</h2>
1812      <div class="web-replay-music__choices">${(round.candidates ?? []).map((candidate, index) => `
1813        <button data-replay-music-choice="${index}" data-action-id="music:${escapeHTML(round.id)}" ${localVote !== undefined ? "disabled" : ""} class="${localVote === index ? "is-selected" : ""}">
1814          ${candidate.thumbnailURL ? `<img src="${escapeHTML(candidate.thumbnailURL)}" alt="">` : ""}
1815          <span>${escapeHTML(candidate.song)}<small>${escapeHTML(candidate.artist)}</small></span><em>${percentages[index] ?? 0}%</em>
1816        </button>`).join("")}</div>
1817    </section>`;
1818  }
1819  return "";
1820}
1821
1822function shareText(archive, selections = {}) {
1823  if (archive.kind === "film-ghosts") {
1824    const rounds = archive.timelineManifest?.songRounds ?? [];
1825    const correct = rounds.filter(
1826      (round) => selections[`ghosts:${round.id}`] === round.correctIndex,
1827    ).length;
1828    return `זיהיתי ${correct} סרטים וסדרות נכונים מתוך ${rounds.length} בתוכנית רוחות של סרטים 🍿 בואו לבחון את עצמכם ולראות אם אתם מנצחים אותי: https://15half.com/movie-quiz`;
1829  }
1830  return `צפיתי בשידור החוזר של ${archive.title || showByProgram.get(archive.kind)?.title || "ערוץ 15½"}: ${webReplayShowURL(archive.kind)}`;
1831}
1832
1833export async function installWebReplay(programKey) {
1834  const show = showByProgram.get(programKey);
1835  if (!show) return;
1836  document.documentElement.classList.add("is-web-replay");
1837  document.title = `${show.title} — שידור חוזר | ערוץ 15½`;
1838  applyWebReplayMetadata(show);
1839  const root = document.getElementById("root");
1840  root.innerHTML = `
1841    <main class="web-replay-shell web-replay-shell--${escapeHTML(programKey)}" dir="rtl">
1842      <section class="web-replay-broadcast" data-web-replay-broadcast>
1843        <header class="web-replay-topline">
1844          <a href="/" class="web-replay-logo" aria-label="חזרה לערוץ">15½</a>
1845          <div class="web-replay-topline__actions">
1846            <button type="button" data-web-replay-mute aria-label="הפעלת קול">🔇</button>
1847            <span>שידור חוזר</span>
1848            <a href="/" aria-label="סגירה">×</a>
1849          </div>
1850        </header>
1851        <div class="web-replay-loading" data-web-replay-loading>טוענים את השידור החוזר…</div>
1852        <div class="web-replay-youtube" data-web-replay-youtube></div>
1853        <div class="web-replay-stage" data-web-replay-stage hidden></div>
1854        <footer class="web-replay-progress" hidden data-web-replay-progress>
1855          <span></span><time>00:00</time>
1856        </footer>
1857      </section>
1858      <aside class="web-replay-chat" aria-label="צ׳אט מהשידור">
1859        <header><strong>הצ׳אט מהשידור</strong><small>${escapeHTML(show.title)}</small></header>
1860        <div class="web-replay-chat__list" data-replay-chat-list></div>
1861        <p class="web-replay-chat__readonly">שליחת הודעות אפשרית רק בלייב</p>
1862      </aside>
1863    </main>
1864  `;
1865  const loading = root.querySelector("[data-web-replay-loading]");
1866  const stage = root.querySelector("[data-web-replay-stage]");
1867  const progress = root.querySelector("[data-web-replay-progress]");
1868  const chatPanel = root.querySelector(".web-replay-chat");
1869  const youtube = createYouTubeTransport(root.querySelector("[data-web-replay-youtube]"));
1870  const muteButton = root.querySelector("[data-web-replay-mute]");
1871  let muted = window.localStorage.getItem("channel155_muted") !== "false" ||
1872    (!window.__channelMobilePlaybackRecovery?.enabled && window.__channelAudioPlaybackBlocked === true);
1873  muteButton.textContent = muted ? "🔇" : "🔊";
1874
1875  try {
1876    const replayURL = new URL(REPLAY_PUBLIC_ENDPOINT, window.location.origin);
1877    replayURL.searchParams.set("webReplay", "archive");
1878    replayURL.searchParams.set("programKey", programKey);
1879    const { archive } = await requestPublicReplayJSON(replayURL, { timeoutMs: 10_000 });
1880    const plan = playbackPlan(archive);
1881    const durationMs = planDuration(plan, Math.max(1, Number(archive.durationMs) || 1));
1882    const local = guestState(archive.episodeKey);
1883    let signedIn = true;
1884    let attempt = null;
1885    try {
1886      const response = await requestJSON(REPLAY_ENDPOINT, {
1887        method: "POST",
1888        body: JSON.stringify({
1889          action: "start",
1890          episodeKey: archive.episodeKey,
1891          deferChat: true,
1892          requestID: replayRequestID(),
1893        }),
1894      });
1895      attempt = response.attempt;
1896    } catch (error) {
1897      if (error.status === 401) signedIn = false;
1898      else if (error.code === "replay_already_completed") attempt = error.payload?.attempt;
1899      else throw error;
1900    }
1901    const submittedIDs = new Set(
1902      signedIn ? attempt?.submittedActionIDs ?? [] : Object.keys(local.selections ?? {}),
1903    );
1904    const selections = { ...(local.selections ?? {}) };
1905    const guesses = { ...(local.guesses ?? {}) };
1906    if (signedIn && Array.isArray(attempt?.submittedActions)) {
1907      for (const action of attempt.submittedActions) {
1908        if (Number.isInteger(action?.payload?.choiceIndex)) {
1909          selections[action.actionID] = Number(action.payload.choiceIndex);
1910        }
1911        if (Number.isFinite(Number(action?.payload?.guess))) {
1912          guesses[action.actionID] = Number(action.payload.guess);
1913        }
1914      }
1915    }
1916    const completed = archive.replayPolicy === "once" &&
1917      (signedIn ? attempt?.status === "completed" : local.completed === true);
1918    let initialPositionMs = 0;
1919    if (archive.replayPolicy === "once") {
1920      initialPositionMs = resumePositionForOnce(archive, plan, submittedIDs);
1921    } else {
1922      initialPositionMs = signedIn
1923        ? Math.max(0, Number(attempt?.positionMs) || 0)
1924        : Math.max(0, Number(local.positionMs) || 0);
1925    }
1926    const session = {
1927      archive,
1928      plan,
1929      durationMs,
1930      selections,
1931      guesses,
1932      submittedIDs,
1933      positionMs: Math.min(durationMs, initialPositionMs),
1934      startedAt: performance.now(),
1935      anchorPositionMs: Math.min(durationMs, initialPositionMs),
1936      signedIn,
1937      completed,
1938      phaseIdentity: "",
1939      audioIdentity: "",
1940      questionCountdownIdentity: "",
1941      chatMessages: [],
1942      finished: false,
1943    };
1944
1945    void loadReplayChatInBackground(session, archive.episodeKey)
1946      .then(() => renderChat(chatPanel, session.chatMessages, session.positionMs))
1947      .catch(() => null);
1948
1949    loading.remove();
1950    stage.hidden = false;
1951    progress.hidden = false;
1952    const audio = document.createElement("audio");
1953    audio.preload = "auto";
1954    audio.playsInline = true;
1955    audio.muted = muted;
1956    root.appendChild(audio);
1957    const questionCountdownAudio = document.createElement("audio");
1958    questionCountdownAudio.src = QUIZ_QUESTION_COUNTDOWN_AUDIO;
1959    questionCountdownAudio.preload = "auto";
1960    questionCountdownAudio.playsInline = true;
1961    questionCountdownAudio.muted = muted;
1962    questionCountdownAudio.volume = 0.5;
1963    root.appendChild(questionCountdownAudio);
1964
1965    const persist = () => {
1966      if (!session.signedIn) {
1967        saveGuestState(archive.episodeKey, {
1968          selections: session.selections,
1969          guesses: session.guesses,
1970          positionMs: archive.replayPolicy === "unlimited" ? session.positionMs : 0,
1971          completed: archive.replayPolicy === "once" && (session.finished || session.completed),
1972        });
1973        return;
1974      }
1975      if (session.finished) return;
1976      const body = JSON.stringify({
1977        action: "exit",
1978        episodeKey: archive.episodeKey,
1979        positionMs: session.positionMs,
1980        requestID: replayRequestID(),
1981      });
1982      navigator.sendBeacon?.(REPLAY_ENDPOINT, new Blob([body], { type: "application/json" }));
1983    };
1984
1985    const submit = async (actionID, payload, type) => {
1986      if (session.submittedIDs.has(actionID)) return;
1987      session.submittedIDs.add(actionID);
1988      if (type === "guess") session.guesses[actionID] = payload.guess;
1989      else session.selections[actionID] = payload.choiceIndex;
1990      jumpReplaySessionToReveal(session, actionID);
1991      if (!session.signedIn) {
1992        saveGuestState(archive.episodeKey, {
1993          selections: session.selections,
1994          guesses: session.guesses,
1995          positionMs: 0,
1996          completed: false,
1997        });
1998        trackReplayParticipation(archive, actionID, payload, false);
1999        return;
2000      }
2001      try {
2002        await requestJSON(REPLAY_ENDPOINT, {
2003          method: "POST",
2004          body: JSON.stringify({
2005            action: "submit",
2006            episodeKey: archive.episodeKey,
2007            actionID,
2008            payload,
2009            requestID: replayRequestID(),
2010          }),
2011        });
2012        trackReplayParticipation(archive, actionID, payload, true);
2013      } catch {
2014        session.submittedIDs.delete(actionID);
2015        delete session.selections[actionID];
2016        delete session.guesses[actionID];
2017      }
2018    };
2019
2020    function syncAudio(asset, identity, elapsedMs, rate = 1, leadInMs = 0) {
2021      if (!asset?.url || elapsedMs < leadInMs) {
2022        if (session.audioIdentity !== identity) audio.pause();
2023        return;
2024      }
2025      const target = Math.max(0, Number(asset.voiceStartOffsetMs) || 0) +
2026        Math.max(0, elapsedMs - leadInMs) / 1_000 * rate;
2027      if (session.audioIdentity !== identity) {
2028        session.audioIdentity = identity;
2029        audio.src = asset.url;
2030        audio.playbackRate = rate;
2031        audio.muted = muted;
2032        audio.addEventListener("loadedmetadata", () => {
2033          if (Number.isFinite(audio.duration)) audio.currentTime = Math.min(target, Math.max(0, audio.duration - .05));
2034          audio.play().catch(() => null);
2035        }, { once: true });
2036        audio.load();
2037      } else if (audio.paused && target >= 0) {
2038        audio.play().catch(() => null);
2039      }
2040    }
2041
2042    function syncQuestionCountdown(phase) {
2043      const active = archive.kind === "film-ghosts" && phase?.state === "guess";
2044      if (!active) {
2045        session.questionCountdownIdentity = "";
2046        questionCountdownAudio.pause();
2047        try { questionCountdownAudio.currentTime = 0; } catch {}
2048        return;
2049      }
2050
2051      const identity = `film-ghosts:${phase.questionIndex ?? -1}:${phase.startsAtMs}`;
2052      const target = Math.max(0, Number(phase.elapsedMs) || 0) / 1_000;
2053      const align = () => {
2054        if (session.questionCountdownIdentity !== identity) return;
2055        try {
2056          questionCountdownAudio.currentTime = Number.isFinite(questionCountdownAudio.duration)
2057            ? Math.min(target, Math.max(0, questionCountdownAudio.duration - 0.03))
2058            : target;
2059        } catch {}
2060      };
2061      if (session.questionCountdownIdentity !== identity) {
2062        session.questionCountdownIdentity = identity;
2063        if (questionCountdownAudio.readyState >= 1) align();
2064        else questionCountdownAudio.addEventListener("loadedmetadata", align, { once: true });
2065      } else if (
2066        questionCountdownAudio.readyState >= 1 &&
2067        Math.abs(questionCountdownAudio.currentTime - target) > 0.45
2068      ) {
2069        align();
2070      }
2071      questionCountdownAudio.muted = muted;
2072      if (!muted && questionCountdownAudio.paused && !questionCountdownAudio.ended) {
2073        questionCountdownAudio.play().catch(() => null);
2074      }
2075    }
2076
2077    function synchronizeMedia(phase) {
2078      const manifest = archive.timelineManifest ?? {};
2079      syncQuestionCountdown(phase);
2080      const identity = phase
2081        ? `${phase.state}:${phase.questionIndex ?? -1}:${phase.startsAtMs}`
2082        : `${archive.kind}:${currentNewsItem(archive, session.positionMs)?.id ?? currentMusicRound(archive, session.positionMs)?.id ?? "bookend"}`;
2083      if (isQuizReplayKind(archive.kind) && phase) {
2084        const question = manifest.questions?.[phase.questionIndex];
2085        const asset = phase.state === "opening" ? manifest.openingVoice
2086          : ["closing", "post_show"].includes(phase.state) ? manifest.closingVoice
2087          : phase.state === "smalltalk" ? question?.smalltalkVoice
2088          : ["question_intro", "question_open"].includes(phase.state) ? question?.questionVoice
2089          : ["answer_reveal", "score_update"].includes(phase.state) ? question?.revealVoice
2090          : question?.transitionVoice;
2091        syncAudio(asset, identity, phase.elapsedMs, Number(manifest.hostPlaybackRate) || 1.1, Number(asset?.voiceLeadInMs) || 0);
2092        youtube.stop();
2093      } else if (["songs-quiz", "film-ghosts"].includes(archive.kind) && phase) {
2094        if (["opening", "closing"].includes(phase.state)) {
2095          syncAudio(
2096            phase.state === "opening" ? manifest.openingVoice : manifest.closingVoice,
2097            identity,
2098            phase.elapsedMs,
2099            Number(manifest.hostPlaybackRate) || 1.3,
2100            500,
2101          );
2102          youtube.stop();
2103        } else {
2104          audio.pause();
2105          const round = manifest.songRounds?.[phase.questionIndex];
2106          if (round && ["guess", "reveal"].includes(phase.state)) {
2107            youtube.play({
2108              id: identity,
2109              videoId: round.youtubeVideoId,
2110              startSeconds: phase.state === "guess"
2111                ? round.clipStartSeconds
2112                : round.revealStartSeconds ?? round.clipStartSeconds,
2113              muted,
2114              stopAfterMs: phase.state === "guess" ? Math.max(250, Number(round.clipDurationSeconds) * 1_000) : 0,
2115            });
2116          } else youtube.stop();
2117        }
2118      }
2118 else if (archive.kind === "marketplace-price" && phase) {
2119        const item = manifest.priceItems?.[phase.questionIndex];
2120        const asset = phase.state === "opening" ? manifest.openingVoice
2121          : phase.state === "closing" ? manifest.closingVoice
2122          : phase.state === "guess" && item?.titleAudioURL ? { url: item.titleAudioURL, durationMs: item.titleAudioDurationMs } 
2123          : phase.state === "reveal" && item?.revealAudioURL ? { url: item.revealAudioURL, durationMs: item.revealAudioDurationMs }
2124          : null;
2125        syncAudio(asset, identity, phase.elapsedMs, Number(manifest.hostPlaybackRate) || 1.1);
2126        youtube.stop();
2127      } else if (archive.kind === "daily-news") {
2128        const item = currentNewsItem(archive, session.positionMs);
2129        if (item) {
2130          syncAudio(
2131            item.voiceURL ? { url: item.voiceURL, durationMs: item.voiceDurationMs } : null,
2132            identity,
2133            Math.max(0, session.positionMs - Number(item.offsetMs)),
2134            1,
2135            Number(item.voiceLeadInMs) || 0,
2136          );
2137        } else {
2138          const firstOffset = Number(manifest.newsItems?.[0]?.offsetMs ?? 0);
2139          const asset = session.positionMs < firstOffset ? manifest.openingVoice : manifest.closingVoice;
2140          syncAudio(asset, identity, session.positionMs < firstOffset ? session.positionMs : Math.max(0, session.positionMs - Number(manifest.newsItems?.at(-1)?.offsetMs ?? 0) - Number(manifest.newsItems?.at(-1)?.durationMs ?? 0)), Number(manifest.hostPlaybackRate) || 1.25);
2141        }
2142        youtube.stop();
2143      } else if (archive.kind === "music-battle") {
2144        audio.pause();
2145        const round = currentMusicRound(archive, session.positionMs);
2146        if (round) {
2147          const start = Number(round.startedOffsetMs ?? 0);
2148          youtube.play({
2149            id: identity,
2150            videoId: round.played.youtubeVideoId,
2151            startSeconds: Math.max(0, (session.positionMs - start) / 1_000),
2152            muted,
2153          });
2154        }
2155      }
2156    }
2157
2158    function renderShare() {
2159      session.finished = true;
2160      audio.pause();
2161      questionCountdownAudio.pause();
2162      youtube.stop();
2163      const filmGhostsRounds = archive.kind === "film-ghosts"
2164        ? (archive.timelineManifest?.songRounds ?? [])
2165        : [];
2166      const filmGhostsScore = filmGhostsRounds.filter(
2167        (round) => session.selections[`ghosts:${round.id}`] === round.correctIndex,
2168      ).length;
2169      stage.innerHTML = `<section class="web-replay-share ${archive.kind === "film-ghosts" ? "web-replay-share--film-ghosts" : ""}">
2170        <button type="button" class="web-replay-share__close" data-web-replay-close aria-label="סגירת הסיכום וחזרה לערוץ">×</button>
2171        <img src="${show.banner}" alt="">
2172        <h1>${archive.kind === "film-ghosts" ? `<span>זיהיתם <strong>${filmGhostsScore}</strong> סרטים</span><span>וסדרות מתוך <strong>${filmGhostsRounds.length}</strong></span>` : "השידור החוזר הסתיים"}</h1>
2173        <button type="button" data-web-replay-share>${archive.kind === "film-ghosts" ? "אתגרו חברים" : "שתפו חברים"}</button>
2174        ${archive.replayPolicy === "unlimited" ? '<button type="button" class="is-secondary" data-web-replay-restart>צפייה נוספת</button>' : ""}
2175        <a href="/">חזרה לערוץ</a>
2176      </section>`;
2177      stage.querySelector("[data-web-replay-share]")?.addEventListener("click", async () => {
2178        const text = shareText(archive, session.selections);
2179        if (navigator.share) await navigator.share({ text }).catch(() => null);
2180        else await navigator.clipboard?.writeText(text).catch(() => null);
2181      });
2182      stage.querySelector("[data-web-replay-close]")?.addEventListener("click", () => {
2183        window.location.assign("/");
2184      });
2185      stage.querySelector("[data-web-replay-restart]")?.addEventListener("click", () => {
2186        session.finished = false;
2187        session.completed = false;
2188        session.phaseIdentity = "";
2189        session.audioIdentity = "";
2190        session.questionCountdownIdentity = "";
2191        session.anchorPositionMs = 0;
2192        session.startedAt = performance.now();
2193        session.positionMs = 0;
2194        if (session.signedIn) {
2195          requestJSON(REPLAY_ENDPOINT, {
2196            method: "POST",
2197            body: JSON.stringify({
2198              action: "start",
2199              episodeKey: archive.episodeKey,
2200              deferChat: true,
2201              requestID: replayRequestID(),
2202            }),
2203          }).catch(() => null);
2204        } else {
2205          saveGuestState(archive.episodeKey, {
2206            selections: session.selections,
2207            guesses: session.guesses,
2208            positionMs: 0,
2209            completed: false,
2210          });
2211        }
2212        render();
2213      });
2214      saveGuestState(archive.episodeKey, {
2215        selections: session.selections,
2216        guesses: session.guesses,
2217        positionMs: 0,
2218        completed: archive.replayPolicy === "once",
2219      });
2220      if (session.signedIn) {
2221        requestJSON(REPLAY_ENDPOINT, {
2222          method: "POST",
2223          body: JSON.stringify({
2224            action: "complete",
2225            episodeKey: archive.episodeKey,
2226            requestID: replayRequestID(),
2227          }),
2228        }).catch(() => null);
2229      }
2230    }
2231
2232    function render() {
2233      if (session.completed || session.positionMs >= session.durationMs) {
2234        if (!session.finished) renderShare();
2235        return;
2236      }
2237      session.positionMs = Math.min(
2238        session.durationMs,
2239        session.anchorPositionMs + performance.now() - session.startedAt,
2240      );
2241      const phase = session.plan.length ? segmentAt(session.plan, session.positionMs) : null;
2242      const identity = phase
2243        ? `${phase.state}:${phase.questionIndex ?? -1}:${phase.startsAtMs}`
2244        : `${archive.kind}:${currentNewsItem(archive, session.positionMs)?.id ?? currentMusicRound(archive, session.positionMs)?.id ?? "bookend"}`;
2245      if (session.phaseIdentity !== identity) {
2246        session.phaseIdentity = identity;
2247        stage.innerHTML = stageMarkup(session, phase);
2248        stage.querySelectorAll("[data-replay-choice]").forEach((button) => {
2249          button.addEventListener("click", async () => {
2250            const actionID = button.dataset.actionId;
2251            const submission = submit(
2252              actionID,
2253              { choiceIndex: Number(button.dataset.replayChoice) },
2254              "choice",
2255            );
2256            const revealPhase = session.plan.length
2257              ? segmentAt(session.plan, session.positionMs)
2258              : phase;
2259            stage.innerHTML = stageMarkup(session, revealPhase);
2260            synchronizeMedia(revealPhase);
2261            await submission;
2262          });
2263        });
2264        stage.querySelector("[data-replay-price-form]")?.addEventListener("submit", async (event) => {
2265          event.preventDefault();
2266          const form = event.currentTarget;
2267          const value = Math.max(0, Math.round(Number(new FormData(form).get("guess"))));
2268          await submit(form.dataset.actionId, { guess: value }, "guess");
2269          stage.innerHTML = stageMarkup(session, phase);
2270        });
2271        stage.querySelectorAll("[data-replay-music-choice]").forEach((button) => {
2272          button.addEventListener("click", () => {
2273            session.selections[button.dataset.actionId] = Number(button.dataset.replayMusicChoice);
2274            saveGuestState(archive.episodeKey, {
2275              selections: session.selections,
2276              guesses: session.guesses,
2277              positionMs: session.positionMs,
2278            });
2279            trackReplayParticipation(
2280              archive,
2281              button.dataset.actionId,
2282              { choiceIndex: Number(button.dataset.replayMusicChoice) },
2283              session.signedIn,
2284            );
2285            stage.innerHTML = stageMarkup(session, phase);
2286          });
2287        });
2288      }
2289      synchronizeMedia(phase);
2290      const ratio = Math.max(0, Math.min(1, session.positionMs / session.durationMs));
2291      progress.querySelector("span").style.width = `${ratio * 100}%`;
2292      const totalSeconds = Math.floor(session.positionMs / 1_000);
2293      progress.querySelector("time").textContent = `${String(Math.floor(totalSeconds / 60)).padStart(2, "0")}:${String(totalSeconds % 60).padStart(2, "0")}`;
2294      renderChat(chatPanel, session.chatMessages, session.positionMs);
2295      window.requestAnimationFrame(render);
2296    }
2297
2298    muteButton.addEventListener("click", () => {
2299      muted = !muted;
2300      window.localStorage.setItem("channel155_muted", String(muted));
2301      muteButton.textContent = muted ? "🔇" : "🔊";
2302      muteButton.setAttribute("aria-label", muted ? "הפעלת קול" : "השתקת קול");
2303      audio.muted = muted;
2304      questionCountdownAudio.muted = muted;
2305      youtube.setMuted(muted);
2306      window.dispatchEvent(new CustomEvent("channel:mute-changed", {
2307        detail: { muted },
2308      }));
2309      if (!muted) {
2310        audio.play().catch(() => null);
2311        if (session.questionCountdownIdentity) {
2312          questionCountdownAudio.play().catch(() => null);
2313        }
2314      }
2315    });
2316    window.addEventListener("pagehide", persist, { once: true });
2317    window.addEventListener("beforeunload", persist, { once: true });
2318    render();
2319  } catch (error) {
2320    loading.innerHTML = `<strong>השידור החוזר עדיין לא מוכן</strong><a href="/">חזרה לערוץ</a>`;
2321    console.error("web_replay_failed", error);
2322  }
2323}

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.