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 = '×'; 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.