chunks
1 week ago
vendor
8 months ago
admin.build.js
1 week ago
admin.js
3 months ago
analytics-tracker.js
4 weeks ago
analytics.build.js
1 week ago
blocks.build.js
1 week ago
carousel.js
1 year ago
custom-player.build.js
1 week ago
documents-viewer-script.js
3 months ago
ep-pdf-lightbox.js
3 months ago
ep-view-count.js
1 week ago
ep-yt-queue.js
4 weeks ago
feature-notices.js
7 months ago
front.js
4 weeks ago
frontend.build.js
3 months ago
gallery-justify.js
1 week ago
gutneberg-script.js
2 months ago
index.html
7 years ago
initCarousel.js
2 years ago
initplyr.js
2 months ago
instafeed.js
4 weeks ago
instagram-shortcode-generator.js
1 month ago
lazy-load.js
6 months ago
license.js
3 months ago
meetup-timezone.js
3 months ago
onboarding.build.js
1 week ago
pdf-gallery-elementor-editor.js
3 months ago
pdf-gallery.js
3 months ago
preview.js
8 months ago
settings.js
3 months ago
sponsored.js
9 months ago
initplyr.js
1712 lines
| 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 | */ |
| 4 | var playerInit = []; |
| 5 | |
| 6 | |
| 7 | // Event listener for when the DOM content is loaded |
| 8 | document.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 |
| 54 | function 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 | */ |
| 439 | function 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 | |
| 527 | function 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 | |
| 538 | function 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 | */ |
| 558 | var EP_GENERIC_TITLES = /^(youtube\s*video\s*player|vimeo[-\s]*player|wistia\s*video\s*player|video\s*player|youtube|vimeo|wistia)$/i; |
| 559 | |
| 560 | function epIsGenericTitle(t) { |
| 561 | if (!t) return true; |
| 562 | return EP_GENERIC_TITLES.test(String(t).trim()); |
| 563 | } |
| 564 | |
| 565 | function 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 | */ |
| 599 | function 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 | |
| 616 | function 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 | */ |
| 661 | function 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; // 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 | */ |
| 714 | function 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 | |
| 786 | function 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 | */ |
| 856 | function 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 | |
| 887 | function 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 | */ |
| 916 | function 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 | |
| 943 | function 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 | |
| 997 | function 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 | |
| 1011 | function 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 | */ |
| 1047 | function 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 | |
| 1088 | function 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 || 'you@example.com'; |
| 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 | */ |
| 1209 | function 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><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 >= 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 | */ |
| 1431 | function 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 | |
| 1466 | function 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 | */ |
| 1528 | function 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 | |
| 1562 | function 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 | |
| 1571 | function 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 | */ |
| 1587 | function 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 | |
| 1598 | function 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 | |
| 1692 | function 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 | } |
| 1712 |