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("<", "<") 195 .replaceAll(">", ">") 196 .replaceAll('"', """) 197 .replaceAll("'", "'"); 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.