// Injected into stream.jw.org. Discovers the ordered list of on-demand // sessions, drives session selection, and reports playback progress back to // the native app through window.webkit.messageHandlers.bridge. (function () { if (window.__bridge) return; const CARD_TITLE_SELECTOR = '[data-testid="vod-program-card-title"]'; const CARD_SELECTOR = '[data-testid="vod-program-card"]'; const COOKIE_ACCEPT_SELECTOR = '.lnc-acceptCookiesButton'; const PROGRESS_INTERVAL_MS = 4000; let video = null; let pendingSeek = null; let lastReportedTitle = null; let knownTitles = []; function post(payload) { try { window.webkit.messageHandlers.bridge.postMessage(JSON.stringify(payload)); } catch (e) { // messageHandlers unavailable (e.g. plain browser testing); ignore. } } function currentTitle() { return document.title.replace(/\s*::\s*JW Stream\s*$/, '').trim(); } function dismissCookieBanner() { const btn = document.querySelector(COOKIE_ACCEPT_SELECTOR); if (btn) btn.click(); } function scanPrograms() { const nodes = document.querySelectorAll(CARD_TITLE_SELECTOR); if (!nodes.length) return; const titles = Array.from(nodes).map((n) => n.textContent.trim()); const changed = titles.length !== knownTitles.length || titles.some((t, i) => t !== knownTitles[i]); if (changed) { knownTitles = titles; post({ type: 'sessions', titles }); } } function nudgeRepaint(v) { // Workaround for a WebKitGTK compositing glitch: switching the video // source can leave the picture black (audio keeps playing) until // something forces the compositor to repaint the video layer. Toggling // a transform forces exactly that, harmlessly. v.style.transform = 'translateZ(0)'; requestAnimationFrame(() => { requestAnimationFrame(() => { v.style.transform = ''; }); }); } function attachVideo(v) { if (video === v) return; video = v; video.addEventListener('loadedmetadata', () => { if (pendingSeek != null && isFinite(video.duration)) { video.currentTime = Math.min(pendingSeek, Math.max(video.duration - 1, 0)); pendingSeek = null; } post({ type: 'metadata', title: currentTitle(), duration: video.duration }); }); video.addEventListener('ended', () => { post({ type: 'ended', title: currentTitle() }); }); video.addEventListener('play', () => post({ type: 'playstate', paused: false })); video.addEventListener('pause', () => post({ type: 'playstate', paused: true })); video.addEventListener('playing', () => nudgeRepaint(v)); } function reportProgress() { if (!video) return; const title = currentTitle(); if (!title) return; post({ type: 'progress', title, currentTime: video.currentTime, duration: video.duration, paused: video.paused, }); lastReportedTitle = title; } function scanForVideo() { const v = document.querySelector('video.vjs-tech, [data-testid="main-player"] video'); if (v) attachVideo(v); } const observer = new MutationObserver(() => { dismissCookieBanner(); scanPrograms(); scanForVideo(); }); observer.observe(document.documentElement, { childList: true, subtree: true }); setInterval(() => { dismissCookieBanner(); scanPrograms(); scanForVideo(); reportProgress(); }, PROGRESS_INTERVAL_MS); window.__bridge = { getPrograms() { return JSON.stringify(knownTitles); }, openByTitle(title) { const nodes = document.querySelectorAll(CARD_TITLE_SELECTOR); for (const n of nodes) { if (n.textContent.trim() === title) { const btn = n.closest('button'); if (btn) { btn.click(); return true; } } } return false; }, seek(seconds) { if (video && isFinite(video.duration)) { video.currentTime = Math.min(seconds, Math.max(video.duration - 1, 0)); } else { pendingSeek = seconds; } }, play() { if (video) video.play().catch(() => {}); }, pause() { if (video) video.pause(); }, getState() { return JSON.stringify({ hasVideo: !!video, paused: video ? video.paused : null, currentTime: video ? video.currentTime : null, duration: video ? video.duration : null, title: currentTitle(), }); }, }; dismissCookieBanner(); scanPrograms(); scanForVideo(); })();