PageSourceSearch

https://peoplescdc.org/wp-content/plugins/embedpress/assets/js/initplyr.js?ver=1789522814

js peoplescdc.org collected 2026-09-24 20:55:14 UTC 63,187 bytes, 1,711 lines download raw bytes

1/**
2 * Note: This is complex initialization, but it is necessary for Gutenberg and Elementor compatibility. There are some known issues in Gutenberg that require this complex setup.
3 */
4var playerInit = [];
5
6
7// Event listener for when the DOM content is loaded
8document.addEventListener('DOMContentLoaded', function () {
9
10
11  const overlayMask = document.createElement('div');
12  overlayMask.className = 'overlay-mask';
13
14
15  // Select all embed wrappers with the class 'ep-embed-content-wraper'
16  let embedWrappers = document.querySelectorAll('.ep-embed-content-wraper');
17
18  // Initialize the player for each embed wrapper
19  embedWrappers.forEach(wrapper => {
20    initPlayer(wrapper);
21  });
22
23  // Mutation observer to detect any changes in the DOM
24  const observer = new MutationObserver(mutations => {
25    mutations.forEach(mutation => {
26      const addedNodes = Array.from(mutation.addedNodes);
27      addedNodes.forEach(node => {
28        traverseAndInitPlayer(node);
29      });
30    });
31  });
32
33  // Start observing changes in the entire document body and its subtree
34  observer.observe(document.body, { childList: true, subtree: true });
35
36  // Recursive function to traverse the DOM and initialize the player for each embed wrapper
37  function traverseAndInitPlayer(node) {
38    if (node.nodeType === Node.ELEMENT_NODE && node.classList.contains('ep-embed-content-wraper')) {
39      initPlayer(node);
40    }
41
42    if (node.hasChildNodes()) {
43      node.childNodes.forEach(childNode => {
44        traverseAndInitPlayer(childNode);
45      });
46    }
47  }
48
49
50});
51
52
53// Function to initialize the player for a given wrapper
54function initPlayer(wrapper) {
55  // One-shot guard: the MutationObserver below watches the entire body
56  // subtree, so any DOM injection inside the wrapper (Plyr's own controls,
57  // chapter label, end-screen overlay, etc.) re-fires the callback. Without
58  // this guard initPlayer ran twice on the same wrapper and every Pro
59  // feature dispatched twice — visible as duplicate chapter labels, two
60  // email-capture forms stacked, etc.
61  if (wrapper.classList.contains('plyr-initialized') || wrapper.dataset.epInitialized === '1') {
62    return;
63  }
64  wrapper.dataset.epInitialized = '1';
65
66  const playerId = wrapper.getAttribute('data-playerid');
67
68  // Get the options for the player from the wrapper's data attribute
69  let options = document.querySelector(`[data-playerid='${playerId}']`)?.getAttribute('data-options');
70
71
72
73  if (!options) {
74    return false;
75  }
76
77  // Parse the options string into a JSON object
78  if (typeof options === 'string') {
79    try {
80      options = JSON.parse(options);
81    } catch (e) {
82      return;
83    }
84  } else {
85    return;
86  }
87
88  // Advanced Privacy Mode: defer Plyr init + show click-to-load overlay.
89  // Iframes have already had `src` neutralized server-side; we restore on click.
90  if (options.privacy_mode && wrapper.classList.contains('ep-privacy-pending')) {
91    epShowPrivacyOverlay(wrapper, options, function () {
92      wrapper.classList.remove('ep-privacy-pending');
93      epRestorePrivacyIframes(wrapper);
94      // Mark wrapper so the recursive init knows to auto-play once Plyr
95      // is ready. The click on the overlay is a user gesture, so the
96      // play() promise is allowed here.
97      wrapper.setAttribute('data-ep-autoplay-after-init', '1');
98      wrapper.classList.remove('plyr-initialized');
99      // Reset the one-shot guard set above; otherwise the recursive
100      // initPlayer call bails immediately and the raw YouTube iframe is
101      // left exposed (no Plyr controls). Bug seen in Elementor preview.
102      delete wrapper.dataset.epInitialized;
103      initPlayer(wrapper);
104    });
105    return;
106  }
107
108
109  if (!options.poster_thumbnail) {
110    wrapper.style.opacity = "1";
111  }
112
113  // Create DOM elements from the icon strings
114  const pipPlayIconElement = document.createElement('div');
115  pipPlayIconElement.className = 'pip-play';
116  pipPlayIconElement.innerHTML = '<svg width="20" height="20" viewBox="-0.15 -0.112 0.9 0.9" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMinYMin" class="jam jam-play"><path fill="#fff" d="M.518.357A.037.037 0 0 0 .506.306L.134.08a.039.039 0 0 0-.02-.006.038.038 0 0 0-.038.037v.453c0 .007.002.014.006.02a.039.039 0 0 0 .052.012L.506.37A.034.034 0 0 0 .518.358zm.028.075L.174.658A.115.115 0 0 1 .017.622.109.109 0 0 1 0 .564V.111C0 .05.051 0 .114 0c.021 0 .042.006.06.017l.372.226a.11.11 0 0 1 0 .189z"/></svg>';
117  pipPlayIconElement.style.display = 'none';
118
119
120  const pipPauseIconElement = document.createElement('div');
121  pipPauseIconElement.className = 'pip-pause';
122  pipPauseIconElement.innerHTML = '<svg fill="#fff" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 2.5 2.5" xml:space="preserve"><path d="M1.013.499 1.006.5V.499H.748a.054.054 0 0 0-.054.054v1.394c0 .03.024.054.054.054h.266a.054.054 0 0 0 .054-.054V.553a.054.054 0 0 0-.054-.054zm.793 1.448V.553a.054.054 0 0 0-.054-.054L1.745.5V.499h-.258a.054.054 0 0 0-.054.054v1.394c0 .03.024.054.054.054h.265a.054.054 0 0 0 .054-.054z"/></svg>';
123
124  const pipCloseElement = document.createElement('div');
125  pipCloseElement.className = 'pip-close';
126  pipCloseElement.innerHTML = '<svg width="20" height="20" viewBox="0 0 0.9 0.9" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M.198.198a.037.037 0 0 1 .053 0L.45.397.648.199a.037.037 0 1 1 .053.053L.503.45l.198.198a.037.037 0 0 1-.053.053L.45.503.252.701A.037.037 0 0 1 .199.648L.397.45.198.252a.037.037 0 0 1 0-.053z" fill="#fff"/></svg>';
127
128  // Check if the player has not been initialized for this wrapper
129  if (playerId && !wrapper.classList.contains('plyr-initialized')) {
130
131
132    let selector = `[data-playerid='${playerId}'] .ose-embedpress-responsive`;
133
134
135    if (options.self_hosted && options.hosted_format === 'video') {
136      selector = `[data-playerid='${playerId}'] .ose-embedpress-responsive video`;
137    }
138    else if (options.self_hosted && options.hosted_format === 'audio') {
139      selector = `[data-playerid='${playerId}'] .ose-embedpress-responsive audio`;
140      wrapper.style.opacity = "1";
141    }
142
143    // Adaptive Streaming (Pro): attach hls.js / dash.js when source is .m3u8 / .mpd.
144    if (options.adaptive_streaming && options.self_hosted && options.hosted_format === 'video') {
145      var videoEl = wrapper.querySelector('.ose-embedpress-responsive video');
146      epAttachAdaptiveStreaming(videoEl);
147    }
148
149
150    // Set the main color of the player
151    document.querySelector(`[data-playerid='${playerId}']`).style.setProperty('--plyr-color-main', options.player_color);
152    document.querySelector(`[data-playerid='${playerId}'].custom-player-preset-1, [data-playerid='${playerId}'].custom-player-preset-3, [data-playerid='${playerId}'].custom-player-preset-4`)?.style.setProperty('--plyr-range-fill-background', '#ffffff');
153
154    // Set the poster thumbnail for the player
155    if (document.querySelector(`[data-playerid='${playerId}'] iframe`)) {
156      document.querySelector(`[data-playerid='${playerId}'] iframe`).setAttribute('data-poster', options.poster_thumbnail);
157    }
158    if (document.querySelector(`[data-playerid='${playerId}'] video`)) {
159      document.querySelector(`[data-playerid='${playerId}'] video`).setAttribute('data-poster', options.poster_thumbnail);
160    }
161
162    // Define the controls to be displayed
163    const controls = [
164      'play-large',
165      options.restart ? 'restart' : '',
166      options.rewind ? 'rewind' : '',
167      'play',
168      options.fast_forward ? 'fast-forward' : '',
169      'progress',
170      'current-time',
171      'duration',
172      'mute',
173      'volume',
174      'captions',
175      'settings',
176      options.pip ? 'pip' : '',
177      'airplay',
178      options.download ? 'download' : '',
179      options.fullscreen ? 'fullscreen' : '',
180
181    ].filter(control => control !== '');
182
183    // Detect if we're on iOS
184    const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
185
186    // Detect if this is a YouTube video
187    const isYouTube = document.querySelector(`[data-playerid='${playerId}'] iframe[src*="youtube"]`) !== null;
188
189    // For iOS YouTube videos, we need to use fallback fullscreen instead of native
190    // because webkitEnterFullscreen() doesn't work on iframes
191    const shouldUseFallbackFullscreen = isIOS && isYouTube;
192
193
194
195    // Create a new Plyr player instance with the specified options and controls
196    const player = new Plyr(selector, {
197      controls: controls,
198      seekTime: 10,
199      poster: options.poster_thumbnail,
200      storage: {
201        enabled: true,
202        key: 'plyr_volume'
203      },
204      displayDuration: true,
205      tooltips: { controls: options.player_tooltip, seek: options.player_tooltip },
206      hideControls: options.hide_controls,
207      // iOS fullscreen configuration - use fallback for YouTube on iOS
208      fullscreen: {
209        enabled: options.fullscreen !== false,
210        fallback: true,
211        iosNative: !shouldUseFallbackFullscreen // Disable iosNative for YouTube on iOS
212      },
213      // Enable playsinline for iOS devices to allow custom controls
214      playsinline: true,
215      youtube: {
216        ...(options.autoplay && { autoplay: options.autoplay }),
217        ...(options.start && { start: options.start }),
218        ...(options.end && { end: options.end }),
219        ...(options.rel && { rel: options.rel }),
220        ...(options.fullscreen && { fs: options.fullscreen })
221      },
222      vimeo: {
223        byline: false,
224        portrait: false,
225        title: false,
226        speed: true,
227        transparent: false,
228        controls: false,
229        ...(options.t && { t: options.t }),
230        ...(options.vautoplay && { autoplay: options.vautoplay }),
231        ...(options.autopause && { autopause: options.autopause }),
232        ...(options.dnt && { dnt: options.dnt }),
233      }
234    });
235
236    playerInit[playerId] = player;
237
238    // Privacy Mode: if the click-to-load overlay just consented, kick off
239    // playback automatically once the player is ready. Without this the
240    // viewer has to click the main play button — a double-click experience.
241    if (wrapper.getAttribute('data-ep-autoplay-after-init') === '1') {
242      wrapper.removeAttribute('data-ep-autoplay-after-init');
243      var autoPlay = function () { try { player.play(); } catch (e) {} };
244      if (typeof player.once === 'function') {
245        player.once('ready', autoPlay);
246        player.once('canplay', autoPlay);
247      } else {
248        setTimeout(autoPlay, 100);
249      }
250    }
251
252    // Each Pro feature initializes independently — a failure in one must
253    // never prevent the next one from running. (Card 81243 acceptance:
254    // "every feature should work independently".)
255    function epSafeInit(name, fn) {
256      try { fn(); }
257      catch (err) {
258        if (window.console && window.console.error) {
259          window.console.error('[EmbedPress] ' + name + ' init failed:', err);
260        }
261      }
262    }
263
264    if (options.auto_resume) {
265      epSafeInit('auto_resume', function () { epInitAutoResume(player, wrapper, options); });
266    }
267    if (options.end_screen) {
268      epSafeInit('end_screen', function () { epInitEndScreen(player, wrapper, options.end_screen); });
269    }
270    if (options.timed_cta && options.timed_cta.length) {
271      epSafeInit('timed_cta', function () { epInitTimedCTA(player, wrapper, options.timed_cta); });
272    }
273    if (options.chapters && options.chapters.items && options.chapters.items.length) {
274      epSafeInit('chapters', function () { epInitChapters(player, wrapper, options.chapters); });
275    }
276    if (options.email_capture) {
277      epSafeInit('email_capture', function () { epInitEmailCapture(player, wrapper, options.email_capture); });
278    }
279    if (options.action_lock) {
280      epSafeInit('action_lock', function () { epInitActionLock(player, wrapper, options.action_lock); });
281    }
282    if (options.lms_tracking) {
283      epSafeInit('lms_tracking', function () { epInitLmsTracking(player, wrapper, options.lms_tracking); });
284    }
285    if (options.heatmap) {
286      epSafeInit('heatmap', function () { epInitHeatmap(player, wrapper, options.heatmap); });
287    }
288
289    // Resolve a real video title (YouTube/Vimeo iframe API) once the
290    // player is ready, then cache on the wrapper. Heatmap + completion
291    // beacons read this so the analytics dashboards stop displaying
292    // YouTube's generic "YouTube video player" iframe placeholder.
293    //
294    // Skip in the editor (Gutenberg iframed canvas / Elementor preview):
295    // the analytics beacons never fire there, and probing the YouTube
296    // IFRAME API across a same-origin-but-iframed canvas surfaces
297    // cross-origin SecurityErrors + occasional "Error 153 player
298    // configuration" overlays. Only the live frontend needs this.
299    if (!epIsInEditor()) {
300      epSafeInit('video_title', function () { epBootstrapVideoTitle(player, wrapper); });
301    }
302
303
304    // iOS YouTube fullscreen fix: Ensure iframe has proper attributes
305    if (shouldUseFallbackFullscreen) {
306      const iframe = document.querySelector(`[data-playerid='${playerId}'] iframe[src*="youtube"]`);
307      if (iframe) {
308        // Ensure the iframe allows fullscreen
309        iframe.setAttribute('allowfullscreen', '');
310        iframe.setAttribute('webkitallowfullscreen', '');
311        iframe.setAttribute('mozallowfullscreen', '');
312
313        // Add iOS-specific class for styling
314        iframe.classList.add('ios-youtube-iframe');
315
316        // Listen for fullscreen events to handle iOS-specific behavior
317        player.on('enterfullscreen', () => {
318          // Force viewport meta tag update for better fullscreen experience
319          const viewport = document.querySelector('meta[name="viewport"]');
320          if (viewport) {
321            const originalContent = viewport.getAttribute('content');
322            viewport.setAttribute('data-original-content', originalContent);
323            viewport.setAttribute('content', 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover');
324          }
325        });
326
327        player.on('exitfullscreen', () => {
328          // Restore original viewport meta tag
329          const viewport = document.querySelector('meta[name="viewport"]');
330          if (viewport && viewport.hasAttribute('data-original-content')) {
331            viewport.setAttribute('content', viewport.getAttribute('data-original-content'));
332            viewport.removeAttribute('data-original-content');
333          }
334        });
335      }
336    }
337
338
339
340    // Mark the wrapper as initialized
341    wrapper.classList.add('plyr-initialized');
342
343    const posterElement = wrapper.querySelector('.plyr__poster');
344
345    if (posterElement) {
346      const interval = setInterval(() => {
347        const computedBg = window.getComputedStyle(posterElement).getPropertyValue('background-image');
348        if (posterElement.style.backgroundImage || (computedBg && computedBg !== 'none')) {
349          wrapper.style.opacity = '1';
350          clearInterval(interval);
351        }
352      }, 200);
353
354      // Fallback: ensure the player becomes visible even if poster never loads
355      setTimeout(() => {
356        clearInterval(interval);
357        wrapper.style.opacity = '1';
358      }, 5000);
359    } else {
360      // No poster element found — show the player immediately
361      wrapper.style.opacity = '1';
362    }
363
364  }
365
366  // Check for the existence of the player's pip button at regular intervals
367  const pipInterval = setInterval(() => {
368
369    let playerPip = document.querySelector(`[data-playerid="${playerId}"] [data-plyr="pip"]`);
370    if (playerPip) {
371      clearInterval(pipInterval);
372
373      let options = document.querySelector(`[data-playerid="${playerId}"]`).getAttribute('data-options');
374
375      options = JSON.parse(options);
376      if (!options.self_hosted) {
377
378        const iframeSelector = document.querySelector(`[data-playerid="${playerId}"] .plyr__video-wrapper`);
379
380        // Add click event listener to toggle the pip mode
381        playerPip.addEventListener('click', () => {
382          iframeSelector.classList.toggle('pip-mode');
383
384          let parentElement = iframeSelector.parentElement;
385          while (parentElement) {
386            parentElement.style.zIndex = '9999';
387            parentElement = parentElement.parentElement;
388          }
389
390        });
391
392
393
394        if (options.pip) {
395          iframeSelector.appendChild(pipPlayIconElement);
396          iframeSelector.appendChild(pipPauseIconElement);
397          iframeSelector.appendChild(pipCloseElement);
398          const pipPlay = document.querySelector(`[data-playerid="${playerId}"] .plyr__video-wrapper .pip-play`);
399          const pipPause = document.querySelector(`[data-playerid="${playerId}"] .plyr__video-wrapper .pip-pause`);
400          const pipClose = document.querySelector(`[data-playerid="${playerId}"] .plyr__video-wrapper .pip-close`);
401
402          pipClose.addEventListener('click', () => {
403            iframeSelector.classList.remove('pip-mode');
404          });
405
406
407          iframeSelector.addEventListener('click', () => {
408            const ariaPressedValue = document.querySelector(`[data-playerid="${playerId}"] .plyr__controls [data-plyr="play"]`).getAttribute('aria-pressed');
409
410            if (ariaPressedValue === 'true') {
411              pipPause.style.display = 'none';
412              pipPlay.style.display = 'flex';
413            } else {
414              pipPlay.style.display = 'none';
415              pipPause.style.display = 'flex';
416            }
417          });
418
419        }
420
421
422      }
423    }
424
425  }, 200);
426
427}
428
429/**
430 * Auto Resume Playback
431 *
432 * Persists the current playhead in localStorage and prompts the viewer
433 * to resume on revisit. Only active for sources where Plyr exposes
434 * `currentTime` and `duration` (self-hosted video/audio, Vimeo, YouTube).
435 *
436 * Storage key includes the source URL so each video has its own slot.
437 * Entries older than the TTL or beyond 95% completion are discarded.
438 */
439function epInitAutoResume(player, wrapper, options) {
440  var TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
441  var COMPLETE_PCT = 0.95;
442  var threshold = Math.max(5, parseInt(options.auto_resume_threshold, 10) || 30);
443
444  var sourceKey = epResumeSourceKey(wrapper);
445  if (!sourceKey) return;
446
447  var storageKey = 'embedpress_resume::' + sourceKey;
448
449  function readEntry() {
450    try {
451      var raw = window.localStorage.getItem(storageKey);
452      if (!raw) return null;
453      var entry = JSON.parse(raw);
454      if (!entry || typeof entry.t !== 'number') return null;
455      if (Date.now() - entry.savedAt > TTL_MS) {
456        window.localStorage.removeItem(storageKey);
457        return null;
458      }
459      return entry;
460    } catch (e) {
461      return null;
462    }
463  }
464
465  function writeEntry(t, duration) {
466    try {
467      window.localStorage.setItem(storageKey, JSON.stringify({
468        t: t,
469        d: duration || 0,
470        savedAt: Date.now()
471      }));
472    } catch (e) { /* quota — ignore */ }
473  }
474
475  function clearEntry() {
476    try { window.localStorage.removeItem(storageKey); } catch (e) {}
477  }
478
479  // Save position periodically while playing.
480  var lastSaved = 0;
481  player.on('timeupdate', function () {
482    var now = player.currentTime || 0;
483    var dur = player.duration || 0;
484    if (now < threshold) return;
485    if (dur && now / dur >= COMPLETE_PCT) return;
486    if (Math.abs(now - lastSaved) < 5) return; // throttle to ~5s
487    lastSaved = now;
488    writeEntry(now, dur);
489  });
490
491  player.on('ended', clearEntry);
492
493  // Show resume prompt once metadata is ready.
494  function maybePrompt() {
495    var entry = readEntry();
496    if (!entry || entry.t < threshold) return;
497    var dur = player.duration || entry.d || 0;
498    if (dur && entry.t / dur >= COMPLETE_PCT) {
499      clearEntry();
500      return;
501    }
502    epShowResumePrompt(wrapper, entry.t, function (resume) {
503      if (resume) {
504        try { player.currentTime = entry.t; } catch (e) {}
505      } else {
506        clearEntry();
507        try { player.currentTime = 0; } catch (e) {}
508      }
509      // Auto-play after either choice — the click was a user gesture, so
510      // the play promise is allowed here. Without this the viewer has to
511      // click the main play button afterwards (double-click).
512      try { player.play(); } catch (e) {}
513    });
514  }
515
516  if (player.duration > 0) {
517    maybePrompt();
518  } else {
519    player.once('loadedmetadata', maybePrompt);
520    // YouTube fires 'ready' before duration on some browsers.
521    player.once('ready', function () {
522      if (player.duration > 0) maybePrompt();
523    });
524  }
525}
526
527function epResumeSourceKey(wrapper) {
528  var media = wrapper.querySelector('video, audio, iframe');
529  if (!media) return '';
530  var src = media.getAttribute('src') || media.currentSrc || '';
531  if (!src && media.querySelector) {
532    var srcEl = media.querySelector('source');
533    if (srcEl) src = srcEl.getAttribute('src') || '';
534  }
535  return src.replace(/[?#].*$/, '');
536}
537
538function epFormatTime(seconds) {
539  seconds = Math.max(0, Math.floor(seconds));
540  var h = Math.floor(seconds / 3600);
541  var m = Math.floor((seconds % 3600) / 60);
542  var s = seconds % 60;
543  var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
544  return h > 0 ? h + ':' + pad(m) + ':' + pad(s) : m + ':' + pad(s);
545}
546
547/**
548 * Resolve a real, human-readable video title for analytics beacons.
549 *
550 * The dumb sources (iframe `title`, video `title`/`aria-label`, Plyr's
551 * own `player.title`) are unreliable for embedded providers — YouTube
552 * always rewrites its own iframe title to "YouTube video player",
553 * Vimeo to "vimeo-player", Wistia to "Wistia video player". So the
554 * heatmap + completion dashboards used to show every row as
555 * "YouTube video player". We filter those generic placeholders out
556 * and prefer the iframe-API-resolved title cached on the wrapper.
557 */
558var EP_GENERIC_TITLES = /^(youtube\s*video\s*player|vimeo[-\s]*player|wistia\s*video\s*player|video\s*player|youtube|vimeo|wistia)$/i;
559
560function epIsGenericTitle(t) {
561  if (!t) return true;
562  return EP_GENERIC_TITLES.test(String(t).trim());
563}
564
565function epResolveVideoTitle(player, wrapper) {
566  // 1. Cached title set by epBootstrapVideoTitle (YouTube/Vimeo iframe API).
567  if (wrapper && wrapper.__epVideoTitle && !epIsGenericTitle(wrapper.__epVideoTitle)) {
568    return wrapper.__epVideoTitle;
569  }
570  // 2. Plyr's resolved title (rare for embedded providers).
571  if (player && player.title && !epIsGenericTitle(player.title)) {
572    return player.title;
573  }
574  // 3. Iframe / video element attributes — only if non-generic.
575  try {
576    var iframe = wrapper.querySelector('iframe');
577    var videoEl = wrapper.querySelector('video');
578    var t = (iframe && iframe.getAttribute('title'))
579        || (videoEl && (videoEl.getAttribute('title') || videoEl.getAttribute('aria-label')))
580        || '';
581    if (!epIsGenericTitle(t)) return t;
582  } catch (e) {}
583  return '';
584}
585
586/**
587 * After the player is ready, ask YouTube/Vimeo for the actual title
588 * and cache it on the wrapper. The heatmap beacon fires every 30s and
589 * the completion beacon at end / on unload — by the time either runs
590 * the iframe API has had plenty of time to settle.
591 */
592/**
593 * Detect whether we're running inside the WordPress block editor's
594 * iframed canvas or Elementor's preview frame. Used to short-circuit
595 * cross-origin probes (YouTube IFRAME API, etc.) that throw or
596 * surface the "Error 153 Video player configuration error" overlay
597 * when called from a nested cross-origin context.
598 */
599function epIsInEditor() {
600  try {
601    if (window.parent !== window) {
602      // Block editor iframed canvas — body has class "block-editor-iframe__body"
603      // (WP 6.x) or there's a parent with `wp-admin` flag.
604      if (document.body && /\bblock-editor(-iframe)?__body\b/.test(document.body.className || '')) return true;
605      // Elementor preview iframe.
606      if (document.body && /\belementor-editor-active\b/.test(document.body.className || '')) return true;
607      // Gutenberg classic editor iframe (rare, but harmless).
608      try {
609        if (window.parent.wp && window.parent.wp.data && typeof window.parent.wp.data.select === 'function') return true;
610      } catch (e) {} // cross-origin parent — definitely live frontend
611    }
612  } catch (e) {}
613  return false;
614}
615
616function epBootstrapVideoTitle(player, wrapper) {
617  if (!player || !wrapper || wrapper.__epVideoTitle) return;
618  var run = function () {
619    try {
620      // YouTube — Plyr exposes the underlying YouTube player via
621      // `player.embed`. getVideoData() returns { title, video_id, ... }.
622      if (player.embed && typeof player.embed.getVideoData === 'function') {
623        var data = player.embed.getVideoData();
624        if (data && data.title && !epIsGenericTitle(data.title)) {
625          wrapper.__epVideoTitle = data.title;
626          return;
627        }
628      }
629      // Vimeo — `player.embed` is a Vimeo Player SDK instance with
630      // a Promise-returning getVideoTitle().
631      if (player.embed && typeof player.embed.getVideoTitle === 'function') {
632        var p = player.embed.getVideoTitle();
633        if (p && typeof p.then === 'function') {
634          p.then(function (title) {
635            if (title && !epIsGenericTitle(title)) wrapper.__epVideoTitle = title;
636          }).catch(function () {});
637          return;
638        }
639      }
640      // Self-hosted / fallback: trust Plyr's title.
641      if (player.title && !epIsGenericTitle(player.title)) {
642        wrapper.__epVideoTitle = player.title;
643      }
644    } catch (e) {}
645  };
646  if (typeof player.once === 'function') {
647    player.once('ready', function () { setTimeout(run, 250); });
648    player.once('canplay', function () { setTimeout(run, 250); });
649  } else {
650    setTimeout(run, 1000);
651  }
652}
653
654/**
655 * Drop-off Heatmap
656 *
657 * Posts the viewer's current 1-percent bucket to /heatmap/sample at most
658 * once per `interval` seconds while the video is playing. No personal
659 * data is sent — only the URL of the video and the bucket index.
660 */
661function epInitHeatmap(player, wrapper, settings) {
662  var videoUrl = epResumeSourceKey(wrapper) || '';
663  if (!videoUrl) return;
664  var lastSent = 0;
665  var lastBucket = -1;
666  var interval = (settings.interval || 30) * 1000;
667
668  player.on('timeupdate', function () {
669    var dur = player.duration || 0;
670    if (!dur) return;
671    var now = Date.now();
672    if (now - lastSent < interval) return;
673
674    var pct = Math.min(99, Math.max(0, Math.floor((player.currentTime / dur) * 100)));
675    if (pct === lastBucket) return;
675 // skip if viewer hasn't moved out of bucket
676    lastBucket = pct;
677    lastSent = now;
678
679    var body = new FormData();
680    body.append('video_url', videoUrl);
681    var videoTitle = epResolveVideoTitle(player, wrapper);
682    if (videoTitle) body.append('video_title', videoTitle);
683    body.append('bucket', pct);
684    fetch(settings.rest_url, {
685      method: 'POST',
686      headers: { 'X-WP-Nonce': settings.nonce },
687      body: body,
688      credentials: 'same-origin',
689      keepalive: true
690    }).catch(function () {});
691  });
692}
693
694/**
695 * Course Completion Tracking
696 *
697 * Two signals flow to /completion:
698 *
699 *  1. Threshold cross — watched/total exceeds the configured threshold
700 *     AND anti-skip passes. Fires `embedpress:video-completed`, hits the
701 *     server which calls the LMS adapter once per (user, video).
702 *  2. Progress beacons — sent on pause, ended, and beforeunload via
703 *     navigator.sendBeacon with progress_only=1. Bumps the per-user
704 *     max_watched_seconds map so the dashboard sees real watch depth
705 *     even for non-completers, and reflects "watched 100%" for users
706 *     who keep going past a 50% threshold.
707 *
708 * Idempotency lives on the server (per-user user-meta) — no
709 * sessionStorage gate here, because a failed first attempt (network
710 * blip, anti-skip rejection) used to lock the user out for the rest
711 * of the session. `fired` is page-load scoped only — it stops the
712 * threshold-cross from re-firing on every timeupdate after it crosses.
713 */
714function epInitLmsTracking(player, wrapper, settings) {
715  // Position threshold: how far through the video the viewer must be
716  // for the completion event to fire. Configurable (default 90%).
717  var positionThreshold = (settings.threshold || 90) / 100;
718  // Anti-skip floor: cumulative watched time must be at least this
719  // fraction of duration. Cap at 0.85 and otherwise scale to ~90% of
720  // the position threshold — leaves 10% slop for the unavoidable
721  // first-tick miss + dropped YouTube timeupdates (delta>=5 are
722  // excluded), so a natural watch-through to 50% still satisfies a 50%
723  // target. Server uses the same formula.
724  var antiSkipFloor = Math.min(0.85, positionThreshold * 0.9);
725  var watched = 0;
726  var lastT = null;
727  var fired = false;
728  var lastBeaconWatched = 0;
729
730  function tick() {
731    var t = player.currentTime || 0;
732    if (lastT !== null) {
733      var delta = t - lastT;
734      // Count only forward, small steps as watched (skip cuts forward).
735      // <5 because Plyr's YouTube timeupdate fires at a coarser rate
736      // than HTML5 video — bursts of 2–4s deltas are routine.
737      if (delta > 0 && delta < 5) watched += delta;
738    }
739    lastT = t;
740
741    if (fired) return;
742    var dur = player.duration || 0;
743    if (!dur) return;
744    var ratio = watched / dur;
745    if (t / dur >= positionThreshold && ratio >= antiSkipFloor) {
746      fired = true;
747      epReportCompletion(wrapper, settings, watched, dur, false);
748      lastBeaconWatched = watched;
749    }
750  }
751
752  function sendProgressBeacon() {
753    var dur = player.duration || 0;
754    if (!dur || watched <= 0) return;
755    // Throttle: skip if watched hasn't grown by at least 1s since the
756    // last beacon. Avoids back-to-back pause+beforeunload duplicates.
757    if (watched - lastBeaconWatched < 1) return;
758    lastBeaconWatched = watched;
759    epReportCompletion(wrapper, settings, watched, dur, true);
760  }
761
762  player.on('timeupdate', tick);
763  player.on('seeking', function () { lastT = null; }); // pause counting across seeks
764  player.on('pause', sendProgressBeacon);
765  player.on('ended', function () {
766    var dur = player.duration || 0;
767    // Natural `ended` is the strongest signal of a watch-through. Anti-
768    // skip floor still applies (so seek-to-last-second + ended doesn't
769    // credit completion), but positionThreshold is trivially met at end.
770    if (!fired && dur && watched / dur >= antiSkipFloor) {
771      fired = true;
772      epReportCompletion(wrapper, settings, watched, dur, false);
773      lastBeaconWatched = watched;
774    } else {
775      sendProgressBeacon();
776    }
777  });
778  // Capture progress for users who close the tab mid-watch. sendBeacon
779  // inside epReportCompletion survives the unload.
780  window.addEventListener('beforeunload', sendProgressBeacon);
781  // Safari iOS doesn't fire beforeunload reliably — pagehide is the
782  // documented replacement.
783  window.addEventListener('pagehide', sendProgressBeacon);
784}
785
786function epReportCompletion(wrapper, settings, watched, total, progressOnly) {
787  // At fire time the iframe is fully loaded, so the resume key is reliable.
788  // Fall back through every plausible identifier so the LMS adapter always
789  // has *something* to match the lesson on.
790  var iframe = wrapper.querySelector('iframe');
791  var videoEl = wrapper.querySelector('video');
792  var videoUrl = epResumeSourceKey(wrapper)
793    || (iframe && iframe.getAttribute('data-ep-privacy-src'))
794    || (iframe && iframe.src && iframe.src.replace(/[?#].*$/, ''))
795    || '';
796  // `player` isn't passed in here — the resolver tolerates a null player
797  // and falls back to the wrapper-cached title set on `ready`.
798  var videoTitle = epResolveVideoTitle(null, wrapper);
799  var pageId = document.body && document.body.className
800    ? (document.body.className.match(/postid-(\d+)/) || [])[1] || ''
801    : '';
802
803  var detail = {
804    video_url:       videoUrl,
805    video_title:     videoTitle,
806    page_id:         pageId,
807    watched_seconds: Math.round(watched),
808    total_seconds:   Math.round(total),
809  };
810
811  // Public JS event — only on actual completion, not on every progress
812  // beacon. LMS plugins listening client-side expect a single fire.
813  if (!progressOnly) {
814    document.dispatchEvent(new CustomEvent('embedpress:video-completed', { detail: detail }));
815  }
816
817  var body = new FormData();
818  body.append('video_url', detail.video_url);
819  if (videoTitle) body.append('video_title', videoTitle);
820  if (pageId)     body.append('page_id', pageId);
821  body.append('watched_seconds', detail.watched_seconds);
822  body.append('total_seconds', detail.total_seconds);
823  if (settings && settings.threshold) {
824    body.append('threshold', (settings.threshold / 100).toFixed(2));
825  }
826  if (progressOnly) body.append('progress_only', '1');
827
828  // Progress beacons must survive page unload — sendBeacon is the only
829  // transport that does. It can't set X-WP-Nonce, so we put _wpnonce
830  // in the form body (WP REST accepts both transports for cookie auth).
831  if (progressOnly && navigator && typeof navigator.sendBeacon === 'function') {
832    if (settings && settings.nonce) body.append('_wpnonce', settings.nonce);
833    try { navigator.sendBeacon(settings.rest_url, body); } catch (e) {}
834    return;
835  }
836
837  fetch(settings.rest_url, {
838    method: 'POST',
839    headers: { 'X-WP-Nonce': settings.nonce },
840    body: body,
841    credentials: 'same-origin',
842    keepalive: true
843  }).catch(function () {});
844}
845
846/**
847 * Adaptive Streaming
848 *
849 * Wires hls.js / dash.js into the <video> element when the source is a
850 * manifest (.m3u8 / .mpd). Both libraries are lazy-loaded from jsDelivr
851 * once per page so they don't impact pages that don't use streaming.
852 *
853 * Native HLS playback (Safari) is preferred — hls.js only attaches when
854 * MediaSource is required.
855 */
856function epAttachAdaptiveStreaming(videoEl) {
857  if (!videoEl) return;
858  var src = videoEl.getAttribute('src') || '';
859  if (!src) {
860    var srcEl = videoEl.querySelector('source');
861    if (srcEl) src = srcEl.getAttribute('src') || '';
862  }
863  if (!src) return;
864  var lower = src.toLowerCase().replace(/[?#].*$/, '');
865
866  if (lower.endsWith('.m3u8')) {
867    // Native HLS (Safari) — let the browser handle it.
868    if (videoEl.canPlayType('application/vnd.apple.mpegurl')) return;
869    epLoadScript('https://cdn.jsdelivr.net/npm/hls.js@1', function () {
870      if (typeof window.Hls === 'undefined' || !window.Hls.isSupported()) return;
871      var hls = new window.Hls();
872      hls.loadSource(src);
873      hls.attachMedia(videoEl);
874    });
875    return;
876  }
877
878  if (lower.endsWith('.mpd')) {
879    epLoadScript('https://cdn.jsdelivr.net/npm/dashjs@4/dist/dash.all.min.js', function () {
880      if (typeof window.dashjs === 'undefined') return;
881      var p = window.dashjs.MediaPlayer().create();
882      p.initialize(videoEl, src, false);
883    });
884  }
885}
886
887function epLoadScript(src, onReady) {
888  var existing = document.querySelector('script[data-ep-src="' + src + '"]');
889  if (existing) {
890    if (existing.dataset.epLoaded === '1') {
891      onReady();
892    } else {
893      existing.addEventListener('load', onReady, { once: true });
894    }
895    return;
896  }
897  var s = document.createElement('script');
898  s.src = src;
899  s.async = true;
900  s.dataset.epSrc = src;
901  s.addEventListener('load', function () {
902    s.dataset.epLoaded = '1';
903    onReady();
904  });
905  document.head.appendChild(s);
906}
907
908/**
909 * Action Lock
910 *
911 * Blocks playback until the viewer completes a configured action.
912 * Unlock state persists per video in sessionStorage. Verification is
913 * best-effort (open-window heuristic for shares/links, login round-trip
914 * for the login type).
915 */
916function epInitActionLock(player, wrapper, settings) {
917  var sourceKey = epResumeSourceKey(wrapper) || (wrapper.getAttribute('data-playerid') || '');
918  var storageKey = 'embedpress_unlock::' + sourceKey;
919
920  try {
921    if (window.sessionStorage.getItem(storageKey)) return;
922  } catch (e) {}
923
924  var unlocked = false;
925  var overlay = epBuildActionLockOverlay(wrapper, settings, function () {
926    unlocked = true;
927    try { window.sessionStorage.setItem(storageKey, '1'); } catch (e) {}
928    if (overlay && overlay.parentNode) overlay.remove();
929    try { player.play(); } catch (e) {}
930  });
931
932  // Stop play attempts while locked.
933  player.on('play', function () {
934    if (!unlocked) {
935      try { player.pause(); } catch (e) {}
936    }
937  });
938
939  // Pause now in case autoplay started.
940  try { player.pause(); } catch (e) {}
941}
942
943function epBuildActionLockOverlay(wrapper, settings, onUnlock) {
944  if (wrapper.querySelector('.ep-action-lock')) return null;
945
946  var overlay = document.createElement('div');
947  overlay.className = 'ep-action-lock ep-action-lock--' + settings.type;
948
949  var inner = document.createElement('div');
950  inner.className = 'ep-action-lock__inner';
951
952  if (settings.headline) {
953    var h = document.createElement('p');
954    h.className = 'ep-action-lock__headline';
955    h.textContent = settings.headline;
956    inner.appendChild(h);
957  }
958  if (settings.message) {
959    var m = document.createElement('p');
960    m.className = 'ep-action-lock__message';
961    m.textContent = settings.message;
962    inner.appendChild(m);
963  }
964
965  var actions = document.createElement('div');
966  actions.className = 'ep-action-lock__actions';
967
968  if (settings.type === 'share') {
969    (settings.share_networks || []).forEach(function (net) {
970      var url = epShareUrlFor(net, settings.share_url);
971      if (!url) return;
972      var btn = epOpenWindowButton(net.charAt(0).toUpperCase() + net.slice(1), url, onUnlock);
973      btn.classList.add('ep-action-lock__btn--' + net);
974      actions.appendChild(btn);
975    });
976  } else if (settings.type === 'link') {
977    if (settings.link_url) {
978      var linkBtn = epOpenWindowButton(settings.link_text || 'Open link', settings.link_url, onUnlock);
979      actions.appendChild(linkBtn);
980    }
981  } else if (settings.type === 'login') {
982    if (settings.login_url) {
983      var loginBtn = document.createElement('a');
984      loginBtn.className = 'ep-action-lock__btn ep-action-lock__btn--primary';
985      loginBtn.href = settings.login_url;
986      loginBtn.textContent = 'Log in';
987      actions.appendChild(loginBtn);
988    }
989  }
990
991  inner.appendChild(actions);
992  overlay.appendChild(inner);
993  wrapper.appendChild(overlay);
994  return overlay;
995}
996
997function epShareUrlFor(network, target) {
998  var encoded = encodeURIComponent(target || window.location.href);
999  switch (network) {
1000    case 'facebook':
1001      return 'https://www.facebook.com/sharer/sharer.php?u=' + encoded;
1002    case 'twitter':
1003      return 'https://twitter.com/intent/tweet?url=' + encoded;
1004    case 'linkedin':
1005      return 'https://www.linkedin.com/sharing/share-offsite/?url=' + encoded;
1006    default:
1007      return '';
1008  }
1009}
1010
1011function epOpenWindowButton(label, url, onComplete) {
1012  var btn = document.createElement('button');
1013  btn.type = 'button';
1014  btn.className = 'ep-action-lock__btn ep-action-lock__btn--primary';
1015  btn.textContent = label;
1016  btn.addEventListener('click', function () {
1017    var w = window.open(url, '_blank', 'noopener,noreferrer,width=600,height=520');
1018    btn.disabled = true;
1019    btn.textContent = 'Verifying…';
1020
1021    // Best-effort: unlock once the user returns focus to the host page.
1022    var armed = false;
1023    setTimeout(function () { armed = true; }, 1500);
1024    var onFocus = function () {
1025      if (!armed) return;
1026      window.removeEventListener('focus', onFocus);
1027      onComplete();
1028    };
1029    window.addEventListener('focus', onFocus);
1030
1031    // Fallback: if popup was blocked, navigate parent and unlock.
1032    if (!w) {
1033      window.removeEventListener('focus', onFocus);
1034      onComplete();
1035    }
1036  });
1037  return btn;
1038}
1039
1040/**
1041 * Email Capture
1042 *
1043 * Pauses playback at the configured trigger and shows a form overlay.
1044 * On submit posts to /embedpress/v1/lead and resumes. Submission is
1045 * remembered per video in localStorage so the prompt fires once.
1046 */
1047function epInitEmailCapture(player, wrapper, settings) {
1048  var sourceKey = epResumeSourceKey(wrapper) || (window.location.pathname + ':' + (wrapper.getAttribute('data-playerid') || ''));
1049  var storageKey = 'embedpress_lead::' + sourceKey;
1050
1051  // In the block editor, ignore the "already submitted" localStorage flag so
1052  // toggling the option in the inspector reliably re-shows the form.
1053  // Otherwise a single test submit permanently hides it.
1054  var isEditor = !!(document.body && (
1055    document.body.classList.contains('block-editor-page') ||
1056    document.body.classList.contains('wp-admin') ||
1057    document.querySelector('.block-editor')
1058  ));
1059
1060  // Already submitted? Skip entirely (front-end only).
1061  try {
1062    if (!isEditor && window.localStorage.getItem(storageKey)) return;
1063  } catch (e) {}
1064
1065  var triggered = false;
1066  player.on('timeupdate', function () {
1067    if (triggered) return;
1068    var now = player.currentTime || 0;
1069    var dur = player.duration || 0;
1070    var triggerAt;
1071    if (settings.unit === 'percent' && dur > 0) {
1072      triggerAt = (settings.time / 100) * dur;
1073    } else {
1074      triggerAt = settings.time;
1075    }
1076    if (now < triggerAt) return;
1077    triggered = true;
1078    try { player.pause(); } catch (e) {}
1079    epShowEmailCaptureForm(wrapper, settings, function (submitted) {
1080      if (submitted) {
1081        try { window.localStorage.setItem(storageKey, '1'); } catch (e) {}
1082      }
1083      try { player.play(); } catch (e) {}
1084    });
1085  });
1086}
1087
1088function epShowEmailCaptureForm(wrapper, settings, onDone) {
1089  if (wrapper.querySelector('.ep-lead-form')) return;
1090
1091  var overlay = document.createElement('div');
1092  overlay.className = 'ep-lead-form';
1093
1094  var form = document.createElement('form');
1095  form.className = 'ep-lead-form__inner';
1096  form.setAttribute('novalidate', 'true');
1097
1098  var headline = document.createElement('p');
1099  headline.className = 'ep-lead-form__headline';
1100  headline.textContent = settings.headline || 'Enter your email to keep watching';
1101  form.appendChild(headline);
1102
1103  function epLeadField(labelText, input) {
1104    var field = document.createElement('div');
1105    field.className = 'ep-lead-form__field';
1106    var label = document.createElement('label');
1107    label.className = 'ep-lead-form__label';
1108    label.textContent = labelText;
1109    var fieldId = 'ep-lead-' + Math.random().toString(36).slice(2, 8);
1110    label.setAttribute('for', fieldId);
1111    input.id = fieldId;
1112    field.appendChild(label);
1113    field.appendChild(input);
1114    return field;
1115  }
1116
1117  var nameInput = null;
1118  if (settings.require_name) {
1119    nameInput = document.createElement('input');
1120    nameInput.type = 'text';
1121    nameInput.required = true;
1122    nameInput.placeholder = settings.name_placeholder || 'Your name';
1123    nameInput.className = 'ep-lead-form__input';
1124    form.appendChild(epLeadField(settings.name_label || 'Name', nameInput));
1125  }
1126
1127  var emailInput = document.createElement('input');
1128  emailInput.type = 'email';
1129  emailInput.required = true;
1130  emailInput.placeholder = settings.email_placeholder || '[email protected]';
1131  emailInput.className = 'ep-lead-form__input';
1132  form.appendChild(epLeadField(settings.email_label || 'Email', emailInput));
1133
1134  var error = document.createElement('p');
1135  error.className = 'ep-lead-form__error';
1136  error.style.display = 'none';
1137  form.appendChild(error);
1138
1139  var actions = document.createElement('div');
1140  actions.className = 'ep-lead-form__actions';
1141
1142  var submit = document.createElement('button');
1143  submit.type = 'submit';
1144  submit.className = 'ep-lead-form__btn ep-lead-form__btn--primary';
1145  submit.textContent = settings.button_text || 'Continue';
1146  actions.appendChild(submit);
1147
1148  if (settings.allow_skip) {
1149    var skip = document.createElement('button');
1150    skip.type = 'button';
1151    skip.className = 'ep-lead-form__btn';
1152    skip.textContent = 'Skip';
1153    skip.addEventListener('click', function () {
1154      overlay.remove();
1155      onDone(false);
1156    });
1157    actions.appendChild(skip);
1158  }
1159  form.appendChild(actions);
1160
1161  form.addEventListener('submit', function (e) {
1162    e.preventDefault();
1163    error.style.display = 'none';
1164    var email = (emailInput.value || '').trim();
1165    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
1166      error.textContent = 'Please enter a valid email address.';
1167      error.style.display = 'block';
1168      return;
1169    }
1170    if (nameInput && !(nameInput.value || '').trim()) {
1171      error.textContent = 'Please enter your name.';
1172      error.style.display = 'block';
1173      return;
1174    }
1175    submit.disabled = true;
1176    submit.textContent = 'Sending…';
1177
1178    var body = new FormData();
1179    body.append('email', email);
1180    if (nameInput) body.append('name', nameInput.value.trim());
1181    body.append('video_url', epResumeSourceKey(wrapper) || '');
1182    body.append('page_url', window.location.href);
1183
1184    fetch(settings.rest_url, {
1185      method: 'POST',
1186      headers: { 'X-WP-Nonce': settings.nonce },
1187      body: body
1188    }).then(function (res) {
1189      // Resume even on error; we don't want to trap the viewer.
1190      overlay.remove();
1191      onDone(true);
1192    }).catch(function () {
1193      overlay.remove();
1194      onDone(true);
1195    });
1196  });
1197
1198  overlay.appendChild(form);
1199  wrapper.appendChild(overlay);
1200}
1201
1202/**
1203 * Video Chapters
1204 *
1205 * Renders tick marks on the progress bar at each chapter's start, a
1206 * top-left badge showing the current chapter (toggle to expand the full
1207 * list), and click-to-seek behavior on both ticks and list items.
1208 */
1209function epInitChapters(player, wrapper, settings) {
1210  var items = (settings.items || []).slice().sort(function (a, b) { return a.time - b.time; });
1211  var showTitle = settings.show_title !== false;
1212  var label, list, ticksHost, tooltipEl;
1213  // Anchor overlays to the embed wrapper. CSS gives .ep-embed-content-wraper
1214  // position:relative so top/left on the absolute label resolve here, not on
1215  // some far-away ancestor.
1216  wrapper.classList.add('ep-has-chapters');
1217
1218  function findCurrentIndex(t) {
1219    if (!items.length) return -1;
1220    // Clamp before the first chapter to chapter 0 so the label always
1221    // reflects a chapter once playback starts, instead of going blank
1222    // when the first chapter doesn't start at exactly 0.
1223    if (t < items[0].time) return 0;
1224    var idx = 0;
1225    for (var i = 0; i < items.length; i++) {
1226      if (t >= items[i].time) idx = i; else break;
1227    }
1228    return idx;
1229  }
1230
1231  function buildPanel() {
1232    if (showTitle && !label) {
1233      label = document.createElement('button');
1234      label.type = 'button';
1235      label.className = 'ep-chapter-label';
1236      label.setAttribute('aria-label', 'Toggle chapter list');
1237      label.innerHTML = '<span class="ep-chapter-label__title"></span>
1237<span class="ep-chapter-label__caret">▾</span>';
1238      label.addEventListener('click', function (e) {
1239        e.stopPropagation();
1240        list.classList.toggle('ep-chapter-list--open');
1241      });
1242      wrapper.appendChild(label);
1243    }
1244
1245    list = document.createElement('div');
1246    list.className = 'ep-chapter-list';
1247    items.forEach(function (item, idx) {
1248      var row = document.createElement('button');
1249      row.type = 'button';
1250      row.className = 'ep-chapter-list__item';
1251      row.dataset.idx = idx;
1252      row.innerHTML = '<span class="ep-chapter-list__time">' + epFormatTime(item.time) + '</span>'
1253        + '<span class="ep-chapter-list__title"></span>';
1254      row.querySelector('.ep-chapter-list__title').textContent = item.title;
1255      row.addEventListener('click', function () {
1256        // Seek + start playback. Without play() a chapter click while the
1257        // player is still paused at its poster (or the Cinematic Preview
1258        // overlay) just changes the buffered position silently — viewers
1259        // expect clicking a chapter to take them there AND play.
1260        try { player.currentTime = item.time; } catch (e) {}
1261        try {
1262          var p = player.play();
1263          if (p && typeof p.catch === 'function') p.catch(function () {});
1264        } catch (e) {}
1265        list.classList.remove('ep-chapter-list--open');
1266      });
1267      list.appendChild(row);
1268    });
1269    wrapper.appendChild(list);
1270  }
1271
1272  function buildTicks() {
1273    var dur = player.duration || 0;
1274    if (!dur) return;
1275    var progress = wrapper.querySelector('.plyr__progress');
1276    if (!progress) return;
1277
1278    if (ticksHost) ticksHost.remove();
1279    ticksHost = document.createElement('div');
1280    ticksHost.className = 'ep-chapter-bar';
1281
1282    // Drop chapters whose start is past the video duration — they'd
1283    // compute to >100% and produce calc(6068% - 4px)-style overflowing
1284    // segments. Clamp the resulting percentages to [0,100] as a final
1285    // guard against sub-second `dur` races during YouTube IFrame ready.
1286    var sorted = items
1287      .filter(function (it) { return it && typeof it.time === 'number' && it.time < dur; });
1288    // Prepend a synthetic [0 → first-chapter-start] segment so the bar
1289    // covers the full duration. Carry an empty title so the tooltip
1290    // doesn't lie about which chapter the viewer is over.
1291    if (sorted[0] && sorted[0].time > 0) {
1292      sorted.unshift({ time: 0, title: '' });
1293    }
1294    if (!sorted.length) return;
1295
1296    // Render one DOM segment per chapter — these visually replace Plyr's
1297    // continuous fill. Plyr's native track is hidden via CSS; the input
1298    // and thumb stay interactive (segments use pointer-events: none).
1299    progress.classList.add('ep-chapters-split');
1300
1301    var GAP_PX = 4;
1302    var segs = [];
1303    sorted.forEach(function (item, i) {
1304      var startPct = Math.max(0, Math.min(100, (item.time / dur) * 100));
1305      var endPct = (i + 1 < sorted.length)
1306        ? Math.max(0, Math.min(100, (sorted[i + 1].time / dur) * 100))
1307        : 100;
1308      var spanPct = endPct - startPct;
1309      if (spanPct <= 0) return;
1310
1311      var seg = document.createElement('div');
1312      seg.className = 'ep-chapter-seg';
1313      var leftOffset = (i === 0) ? 0 : (GAP_PX / 2);
1314      var rightOffset = (i === sorted.length - 1) ? 0 : (GAP_PX / 2);
1315      seg.style.left = 'calc(' + startPct + '% + ' + leftOffset + 'px)';
1316      seg.style.width = 'calc(' + spanPct + '% - ' + (leftOffset + rightOffset) + 'px)';
1317
1318      var fill = document.createElement('div');
1319      fill.className = 'ep-chapter-seg__fill';
1320      seg.appendChild(fill);
1321
1322      seg._start = item.time;
1323      seg._end = (i + 1 < sorted.length) ? sorted[i + 1].time : dur;
1324      seg._title = item.title;
1325      seg._fill = fill;
1326
1327      ticksHost.appendChild(seg);
1328      segs.push(seg);
1329    });
1330
1331    function updateFills(t) {
1332      segs.forEach(function (seg) {
1333        var span = seg._end - seg._start;
1334        var local = Math.max(0, Math.min(span, t - seg._start));
1335        seg._fill.style.width = (span > 0 ? (local / span) * 100 : 0) + '%';
1336      });
1337    }
1338
1339    function onMove(e) {
1340      var rect = progress.getBoundingClientRect();
1341      if (!rect.width) return;
1342      var pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
1343      var t = pct * dur;
1344      var hovered = null;
1345      for (var i = 0; i < segs.length; i++) {
1346        if (t >
1346= segs[i]._start && t <= segs[i]._end) { hovered = segs[i]; break; }
1347      }
1348      segs.forEach(function (s) { s.classList.toggle('ep-chapter-seg--hover', s === hovered); });
1349      // Append the chapter title as a second line inside Plyr's existing
1350      // seek tooltip. Plyr binds its own mousemove handler first (during
1351      // Plyr init); ours runs after, so by this point Plyr has already
1352      // written the time string and we can append without a race.
1353      var tip = progress.querySelector('.plyr__tooltip');
1354      if (!tip) return;
1355      // Strip any prior chapter line we appended on the previous move so
1356      // we don't accumulate <br> stacks if Plyr's update is skipped.
1357      var existing = tip.querySelector('.ep-chapter-tooltip-title');
1358      if (existing) existing.remove();
1359      var existingBr = tip.querySelector('br.ep-chapter-tooltip-br');
1360      if (existingBr) existingBr.remove();
1361      if (hovered && hovered._title) {
1362        var br = document.createElement('br');
1363        br.className = 'ep-chapter-tooltip-br';
1364        var titleSpan = document.createElement('span');
1365        titleSpan.className = 'ep-chapter-tooltip-title';
1366        titleSpan.textContent = hovered._title;
1367        tip.appendChild(br);
1368        tip.appendChild(titleSpan);
1369      }
1370    }
1371    function onLeave() {
1372      segs.forEach(function (s) { s.classList.remove('ep-chapter-seg--hover'); });
1373      var tip = progress.querySelector('.plyr__tooltip');
1374      if (tip) {
1375        var existing = tip.querySelector('.ep-chapter-tooltip-title');
1376        if (existing) existing.remove();
1377        var existingBr = tip.querySelector('br.ep-chapter-tooltip-br');
1378        if (existingBr) existingBr.remove();
1379      }
1380    }
1381    progress.addEventListener('mousemove', onMove);
1382    progress.addEventListener('mouseleave', onLeave);
1383
1384    updateFills(player.currentTime || 0);
1385    player.on('timeupdate', function () { updateFills(player.currentTime || 0); });
1386    player.on('seeked', function () { updateFills(player.currentTime || 0); });
1387
1388    progress.appendChild(ticksHost);
1389  }
1390
1391  function refreshLabel() {
1392    if (!label) return;
1393    var idx = findCurrentIndex(player.currentTime || 0);
1394    var titleEl = label.querySelector('.ep-chapter-label__title');
1395    if (idx < 0) {
1396      titleEl.textContent = '';
1397      label.classList.add('ep-chapter-label--hidden');
1398    } else {
1399      titleEl.textContent = items[idx].title;
1400      label.classList.remove('ep-chapter-label--hidden');
1401    }
1402    Array.prototype.forEach.call(list.querySelectorAll('.ep-chapter-list__item'), function (row, i) {
1403      row.classList.toggle('ep-chapter-list__item--active', i === idx);
1404    });
1405  }
1406
1407  buildPanel();
1408  if (player.duration > 0) buildTicks();
1409  player.on('loadedmetadata', buildTicks);
1410  player.on('ready', buildTicks);
1411  player.on('timeupdate', refreshLabel);
1412  player.on('play', refreshLabel);
1413  player.on('seeked', refreshLabel);
1414  refreshLabel();
1415
1416  // Click-outside to close list
1417  document.addEventListener('click', function (e) {
1418    if (!list.classList.contains('ep-chapter-list--open')) return;
1419    if (label && (label.contains(e.target) || list.contains(e.target))) return;
1420    list.classList.remove('ep-chapter-list--open');
1421  });
1422}
1423
1424/**
1425 * Timed CTA
1426 *
1427 * Items fire at their `time` (seconds), render an overlay anchored to the
1428 * bottom of the player, and auto-hide after `duration` (0 = until dismissed
1429 * or video ends). Each item fires at most once per session.
1430 */
1431function epInitTimedCTA(player, wrapper, items) {
1432  // Shallow clone so flags don't mutate the data-options literal.
1433  var queue = items.map(function (it) {
1434    return Object.assign({}, it, { _shown: false, _el: null, _timer: null });
1435  });
1436
1437  player.on('timeupdate', function () {
1438    var now = player.currentTime || 0;
1439    queue.forEach(function (item) {
1440      if (item._shown) return;
1441      if (now < item.time) return;
1442      item._shown = true;
1443      epShowTimedCTA(wrapper, item);
1444    });
1445  });
1446
1447  player.on('seeked', function () {
1448    var now = player.currentTime || 0;
1449    queue.forEach(function (item) {
1450      if (item._shown && now < item.time && item._el) {
1451        item._el.remove();
1452        if (item._timer) clearTimeout(item._timer);
1453        item._shown = false;
1454      }
1455    });
1456  });
1457
1458  player.on('ended', function () {
1459    queue.forEach(function (item) {
1460      if (item._el) item._el.remove();
1461      if (item._timer) clearTimeout(item._timer);
1462    });
1463  });
1464}
1465
1466function epShowTimedCTA(wrapper, item) {
1467  var el = document.createElement('div');
1468  el.className = 'ep-timed-cta';
1469  item._el = el;
1470
1471  var inner = document.createElement('div');
1472  inner.className = 'ep-timed-cta__inner';
1473
1474  if (item.headline) {
1475    var h = document.createElement('p');
1476    h.className = 'ep-timed-cta__headline';
1477    h.textContent = item.headline;
1478    inner.appendChild(h);
1479  }
1480  if (item.button_text && item.button_url) {
1481    var btn = document.createElement('a');
1482    btn.className = 'ep-timed-cta__btn';
1483    btn.href = item.button_url;
1484    btn.target = '_blank';
1485    btn.rel = 'noopener noreferrer';
1486    btn.textContent = item.button_text;
1487    inner.appendChild(btn);
1488  }
1489  el.appendChild(inner);
1490
1491  if (item.dismissible !== false) {
1492    var close = document.createElement('button');
1493    close.type = 'button';
1494    close.className = 'ep-timed-cta__close';
1495    close.setAttribute('aria-label', 'Close');
1496    close.innerHTML = '&times;';
1497    close.addEventListener('click', function () {
1498      el.remove();
1499      if (item._timer) clearTimeout(item._timer);
1500    });
1501    el.appendChild(close);
1502  }
1503
1504  // Stack multiple concurrent CTAs in a single bottom-anchored column so
1505  // they don't overlap when their visible windows intersect.
1506  var stack = wrapper.querySelector(':scope > .ep-timed-cta-stack');
1507  if (!stack) {
1508    stack = document.createElement('div');
1509    stack.className = 'ep-timed-cta-stack';
1510    wrapper.appendChild(stack);
1511  }
1512  stack.appendChild(el);
1513
1514  if (item.duration && item.duration > 0) {
1515    item._timer = setTimeout(function () {
1516      if (el.parentNode) el.remove();
1517    }, item.duration * 1000);
1518  }
1519}
1520
1521/**
1522 * Advanced Privacy Mode
1523 *
1524 * Renders a click-to-load overlay over the wrapper. The iframe's real `src`
1525 * has already been stashed in `data-ep-privacy-src` server-side; here we
1526 * just show a poster + play button and restore the URL on click.
1527 */
1528function epShowPrivacyOverlay(wrapper, options, onConsent) {
1529  if (wrapper.querySelector('.ep-privacy-overlay')) return;
1530  wrapper.style.opacity = '1';
1531
1532  var overlay = document.createElement('div');
1533  overlay.className = 'ep-privacy-overlay';
1534
1535  var poster = options.poster_thumbnail || epGuessYouTubeThumbnail(wrapper);
1536  if (poster) {
1537    overlay.style.backgroundImage = 'url("' + poster.replace(/"/g, '\\"') + '")';
1538    overlay.classList.add('ep-privacy-overlay--has-poster');
1539  }
1540
1541  var play = document.createElement('button');
1542  play.type = 'button';
1543  play.className = 'ep-privacy-overlay__play';
1544  play.setAttribute('aria-label', 'Load and play video');
1545  play.innerHTML = '<svg viewBox="0 0 64 64" width="64" height="64" aria-hidden="true"><circle cx="32" cy="32" r="32" fill="rgba(0,0,0,0.6)"/><polygon points="26,20 26,44 46,32" fill="#fff"/></svg>';
1546
1547  var msg = document.createElement('p');
1548  msg.className = 'ep-privacy-overlay__msg';
1549  msg.textContent = options.privacy_message || 'Click to load. By playing, you accept third-party cookies.';
1550
1551  overlay.appendChild(play);
1552  overlay.appendChild(msg);
1553
1554  overlay.addEventListener('click', function () {
1555    overlay.remove();
1556    onConsent();
1557  });
1558
1559  wrapper.appendChild(overlay);
1560}
1561
1562function epRestorePrivacyIframes(wrapper) {
1563  var iframes = wrapper.querySelectorAll('iframe[data-ep-privacy-src]');
1564  iframes.forEach(function (iframe) {
1565    var src = iframe.getAttribute('data-ep-privacy-src');
1566    iframe.removeAttribute('data-ep-privacy-src');
1567    if (src) iframe.setAttribute('src', src);
1568  });
1569}
1570
1571function epGuessYouTubeThumbnail(wrapper) {
1572  var iframe = wrapper.querySelector('iframe[data-ep-privacy-src]');
1573  if (!iframe) return '';
1574  var src = iframe.getAttribute('data-ep-privacy-src') || '';
1575  var m = src.match(/(?:youtube(?:-nocookie)?\.com\/embed\/|youtu\.be\/)([A-Za-z0-9_-]{6,})/);
1576  return m ? 'https://img.youtube.com/vi/' + m[1] + '/hqdefault.jpg' : '';
1577}
1578
1579/**
1580 * Custom End Screen
1581 *
1582 * Renders a configurable overlay when the video reaches `ended`. Three modes:
1583 *  - message: simple message (+ optional replay)
1584 *  - cta:     message + button linking elsewhere
1585 *  - redirect: redirects after a countdown
1586 */
1587function epInitEndScreen(player, wrapper, settings) {
1588  player.on('ended', function () {
1589    epShowEndScreen(wrapper, settings, function () {
1590      try {
1591        player.currentTime = 0;
1592        player.play();
1593      } catch (e) {}
1594    });
1595  });
1596}
1597
1598function epShowEndScreen(wrapper, settings, onReplay) {
1599  // Avoid duplicates if 'ended' fires twice.
1600  var existing = wrapper.querySelector('.ep-end-screen');
1601  if (existing) existing.remove();
1602
1603  var mode = settings.mode || 'message';
1604  var msg = settings.message || '';
1605  var showReplay = settings.show_replay !== false;
1606
1607  var overlay = document.createElement('div');
1608  overlay.className = 'ep-end-screen ep-end-screen--' + mode;
1609
1610  var inner = document.createElement('div');
1611  inner.className = 'ep-end-screen__inner';
1612
1613  if (msg) {
1614    var p = document.createElement('p');
1615    p.className = 'ep-end-screen__msg';
1616    p.textContent = msg;
1617    inner.appendChild(p);
1618  }
1619
1620  if (mode === 'cta' && settings.button_url && settings.button_text) {
1621    var cta = document.createElement('a');
1622    cta.className = 'ep-end-screen__btn ep-end-screen__btn--primary';
1623    cta.href = settings.button_url;
1624    cta.target = '_blank';
1625    cta.rel = 'noopener noreferrer';
1626    cta.textContent = settings.button_text;
1627    inner.appendChild(cta);
1628  }
1629
1630  var actions = document.createElement('div');
1631  actions.className = 'ep-end-screen__actions';
1632
1633  if (showReplay) {
1634    var replay = document.createElement('button');
1635    replay.type = 'button';
1636    replay.className = 'ep-end-screen__btn';
1637    replay.textContent = 'Replay';
1638    replay.addEventListener('click', function () {
1639      overlay.remove();
1640      onReplay();
1641    });
1642    actions.appendChild(replay);
1643  }
1644
1645  if (mode === 'redirect' && settings.redirect_url) {
1646    var countdown = Math.max(0, parseInt(settings.countdown, 10) || 0);
1647    var countEl = document.createElement('p');
1648    countEl.className = 'ep-end-screen__countdown';
1649    inner.appendChild(countEl);
1650
1651    var redirectNow = function () {
1652      if (settings.redirect_new_window) {
1653        window.open(settings.redirect_url, '_blank', 'noopener,noreferrer');
1654      } else {
1655        window.location.href = settings.redirect_url;
1656      }
1657    };
1658
1659    if (countdown === 0) {
1660      redirectNow();
1661      return;
1662    }
1663
1664    countEl.textContent = 'Redirecting in ' + countdown + 's…';
1665    var remaining = countdown;
1666    var timer = setInterval(function () {
1667      remaining -= 1;
1668      if (remaining <= 0) {
1669        clearInterval(timer);
1670        redirectNow();
1671        return;
1672      }
1673      countEl.textContent = 'Redirecting in ' + remaining + 's…';
1674    }, 1000);
1675
1676    var cancel = document.createElement('button');
1677    cancel.type = 'button';
1678    cancel.className = 'ep-end-screen__btn';
1679    cancel.textContent = 'Cancel';
1680    cancel.addEventListener('click', function () {
1681      clearInterval(timer);
1682      overlay.remove();
1683    });
1684    actions.appendChild(cancel);
1685  }
1686
1687  if (actions.childNodes.length) inner.appendChild(actions);
1688  overlay.appendChild(inner);
1689  wrapper.appendChild(overlay);
1690}
1691
1692function epShowResumePrompt(wrapper, time, onChoice) {
1693  if (wrapper.querySelector('.ep-resume-prompt')) return;
1694  var overlay = document.createElement('div');
1695  overlay.className = 'ep-resume-prompt';
1696  overlay.innerHTML =
1697    '<div class="ep-resume-prompt__inner">' +
1698      '<p class="ep-resume-prompt__msg">Resume at ' + epFormatTime(time) + '?</p>' +
1699      '<div class="ep-resume-prompt__actions">' +
1700        '<button type="button" class="ep-resume-prompt__btn ep-resume-prompt__btn--primary" data-action="resume">Resume</button>' +
1701        '<button type="button" class="ep-resume-prompt__btn" data-action="restart">Start Over</button>' +
1702      '</div>' +
1703    '</div>';
1704  overlay.addEventListener('click', function (e) {
1705    var action = e.target && e.target.getAttribute && e.target.getAttribute('data-action');
1706    if (!action) return;
1707    overlay.remove();
1708    onChoice(action === 'resume');
1709  });
1710  wrapper.appendChild(overlay);
1711}

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.