PluginProbe ʕ •ᴥ•ʔ
EmbedPress – PDF Embedder, Embed PDF viewer, YouTube Videos, 3D FlipBook, Social feeds & more / trunk
EmbedPress – PDF Embedder, Embed PDF viewer, YouTube Videos, 3D FlipBook, Social feeds & more vtrunk
4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 trunk 1.0.0 1.1.0 1.1.1 1.1.2 1.1.3 1.2.0 1.3.0 1.3.1 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.5.0 1.6.0 1.6.1 1.6.2 1.6.3 1.7.0 1.7.1 1.7.2 1.7.3 1.7.4 1.7.5 2.0.0 2.0.1 2.0.2 2.0.3 2.1.0 2.1.1 2.1.2 2.1.3 2.1.4 2.1.5 2.1.6 2.2.0 2.2.1 2.2.2 2.3.0 2.3.1 2.3.2 2.3.3 2.4.0 2.4.1 2.5.0 2.5.1 2.5.2 2.5.3 2.5.4 2.5.5 2.6.0 2.6.1 2.6.2 2.7.0 2.7.1 2.7.2 2.7.3 2.7.4 2.7.5 2.7.6 2.7.7 3.0.0 3.0.1 3.0.2 3.0.3 3.0.4 3.1.0 3.1.1 3.1.2 3.1.3 3.2.0 3.2.1 3.3.0 3.3.1 3.3.2 3.3.3 3.3.4 3.3.5 3.3.6 3.3.7 3.4.0 3.4.1 3.4.2 3.4.3 3.5.0 3.5.1 3.5.2 3.5.3 3.6.0 3.6.1 3.6.2 3.6.3 3.6.4 3.6.5 3.6.6 3.6.7 3.6.8 3.7.0 3.7.1 3.7.2 3.7.3 3.8.0 3.8.1 3.8.2 3.8.3 3.8.4 3.8.5 3.9.0 3.9.1 3.9.10 3.9.11 3.9.12 3.9.13 3.9.14 3.9.15 3.9.16 3.9.17 3.9.2 3.9.3 3.9.4 3.9.5 3.9.6 3.9.7 3.9.8 3.9.9 4.0.0 4.0.1 4.0.10 4.0.11 4.0.12 4.0.13 4.0.14 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.0.7 4.0.8 4.0.9 4.1.0 4.1.1 4.1.10 4.1.2 4.1.3 4.1.4 4.1.5 4.1.6 4.1.7 4.1.8 4.1.9 4.2.0 4.2.1 4.2.2 4.2.3 4.2.4 4.2.5 4.2.6 4.2.7 4.2.8 4.2.9 4.3.0 4.3.1 4.4.0 4.4.1 4.4.10 4.4.11 4.4.2 4.4.3 4.4.4 4.4.5 4.4.6 4.4.7 4.4.8 4.4.9 4.5.0 4.5.1
embedpress / assets / js / initplyr.js
embedpress / assets / js Last commit date
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 = '&times;';
1497 close.addEventListener('click', function () {
1498 el.remove();
1499 if (item._timer) clearTimeout(item._timer);
1500 });
1501 el.appendChild(close);
1502 }
1503
1504 // Stack multiple concurrent CTAs in a single bottom-anchored column so
1505 // they don't overlap when their visible windows intersect.
1506 var stack = wrapper.querySelector(':scope > .ep-timed-cta-stack');
1507 if (!stack) {
1508 stack = document.createElement('div');
1509 stack.className = 'ep-timed-cta-stack';
1510 wrapper.appendChild(stack);
1511 }
1512 stack.appendChild(el);
1513
1514 if (item.duration && item.duration > 0) {
1515 item._timer = setTimeout(function () {
1516 if (el.parentNode) el.remove();
1517 }, item.duration * 1000);
1518 }
1519 }
1520
1521 /**
1522 * Advanced Privacy Mode
1523 *
1524 * Renders a click-to-load overlay over the wrapper. The iframe's real `src`
1525 * has already been stashed in `data-ep-privacy-src` server-side; here we
1526 * just show a poster + play button and restore the URL on click.
1527 */
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