文件操作 - initplyr.js
返回文件管理
返回主菜单
删除本文件
文件: /storage/v12552/creatormedianetwork/public_html/wp-content/plugins/embedpress/assets/js/initplyr.js
编辑文件内容
/** * 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. */ var playerInit = []; // Event listener for when the DOM content is loaded document.addEventListener('DOMContentLoaded', function () { const overlayMask = document.createElement('div'); overlayMask.className = 'overlay-mask'; // Select all embed wrappers with the class 'ep-embed-content-wraper' let embedWrappers = document.querySelectorAll('.ep-embed-content-wraper'); // Initialize the player for each embed wrapper embedWrappers.forEach(wrapper => { initPlayer(wrapper); }); // Mutation observer to detect any changes in the DOM const observer = new MutationObserver(mutations => { mutations.forEach(mutation => { const addedNodes = Array.from(mutation.addedNodes); addedNodes.forEach(node => { traverseAndInitPlayer(node); }); }); }); // Start observing changes in the entire document body and its subtree observer.observe(document.body, { childList: true, subtree: true }); // Recursive function to traverse the DOM and initialize the player for each embed wrapper function traverseAndInitPlayer(node) { if (node.nodeType === Node.ELEMENT_NODE && node.classList.contains('ep-embed-content-wraper')) { initPlayer(node); } if (node.hasChildNodes()) { node.childNodes.forEach(childNode => { traverseAndInitPlayer(childNode); }); } } }); // Function to initialize the player for a given wrapper function initPlayer(wrapper) { // One-shot guard: the MutationObserver below watches the entire body // subtree, so any DOM injection inside the wrapper (Plyr's own controls, // chapter label, end-screen overlay, etc.) re-fires the callback. Without // this guard initPlayer ran twice on the same wrapper and every Pro // feature dispatched twice — visible as duplicate chapter labels, two // email-capture forms stacked, etc. if (wrapper.classList.contains('plyr-initialized') || wrapper.dataset.epInitialized === '1') { return; } wrapper.dataset.epInitialized = '1'; const playerId = wrapper.getAttribute('data-playerid'); // Get the options for the player from the wrapper's data attribute let options = document.querySelector(`[data-playerid='${playerId}']`)?.getAttribute('data-options'); if (!options) { return false; } // Parse the options string into a JSON object if (typeof options === 'string') { try { options = JSON.parse(options); } catch (e) { return; } } else { return; } // Advanced Privacy Mode: defer Plyr init + show click-to-load overlay. // Iframes have already had `src` neutralized server-side; we restore on click. if (options.privacy_mode && wrapper.classList.contains('ep-privacy-pending')) { epShowPrivacyOverlay(wrapper, options, function () { wrapper.classList.remove('ep-privacy-pending'); epRestorePrivacyIframes(wrapper); // Mark wrapper so the recursive init knows to auto-play once Plyr // is ready. The click on the overlay is a user gesture, so the // play() promise is allowed here. wrapper.setAttribute('data-ep-autoplay-after-init', '1'); wrapper.classList.remove('plyr-initialized'); // Reset the one-shot guard set above; otherwise the recursive // initPlayer call bails immediately and the raw YouTube iframe is // left exposed (no Plyr controls). Bug seen in Elementor preview. delete wrapper.dataset.epInitialized; initPlayer(wrapper); }); return; } if (!options.poster_thumbnail) { wrapper.style.opacity = "1"; } // Create DOM elements from the icon strings const pipPlayIconElement = document.createElement('div'); pipPlayIconElement.className = 'pip-play'; 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>'; pipPlayIconElement.style.display = 'none'; const pipPauseIconElement = document.createElement('div'); pipPauseIconElement.className = 'pip-pause'; 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>'; const pipCloseElement = document.createElement('div'); pipCloseElement.className = 'pip-close'; 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>'; // Check if the player has not been initialized for this wrapper if (playerId && !wrapper.classList.contains('plyr-initialized')) { let selector = `[data-playerid='${playerId}'] .ose-embedpress-responsive`; if (options.self_hosted && options.hosted_format === 'video') { selector = `[data-playerid='${playerId}'] .ose-embedpress-responsive video`; } else if (options.self_hosted && options.hosted_format === 'audio') { selector = `[data-playerid='${playerId}'] .ose-embedpress-responsive audio`; wrapper.style.opacity = "1"; } // Adaptive Streaming (Pro): attach hls.js / dash.js when source is .m3u8 / .mpd. if (options.adaptive_streaming && options.self_hosted && options.hosted_format === 'video') { var videoEl = wrapper.querySelector('.ose-embedpress-responsive video'); epAttachAdaptiveStreaming(videoEl); } // Set the main color of the player document.querySelector(`[data-playerid='${playerId}']`).style.setProperty('--plyr-color-main', options.player_color); 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'); // Set the poster thumbnail for the player if (document.querySelector(`[data-playerid='${playerId}'] iframe`)) { document.querySelector(`[data-playerid='${playerId}'] iframe`).setAttribute('data-poster', options.poster_thumbnail); } if (document.querySelector(`[data-playerid='${playerId}'] video`)) { document.querySelector(`[data-playerid='${playerId}'] video`).setAttribute('data-poster', options.poster_thumbnail); } // Define the controls to be displayed const controls = [ 'play-large', options.restart ? 'restart' : '', options.rewind ? 'rewind' : '', 'play', options.fast_forward ? 'fast-forward' : '', 'progress', 'current-time', 'duration', 'mute', 'volume', 'captions', 'settings', options.pip ? 'pip' : '', 'airplay', options.download ? 'download' : '', options.fullscreen ? 'fullscreen' : '', ].filter(control => control !== ''); // Detect if we're on iOS const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; // Detect if this is a YouTube video const isYouTube = document.querySelector(`[data-playerid='${playerId}'] iframe[src*="youtube"]`) !== null; // For iOS YouTube videos, we need to use fallback fullscreen instead of native // because webkitEnterFullscreen() doesn't work on iframes const shouldUseFallbackFullscreen = isIOS && isYouTube; // Create a new Plyr player instance with the specified options and controls const player = new Plyr(selector, { controls: controls, seekTime: 10, poster: options.poster_thumbnail, storage: { enabled: true, key: 'plyr_volume' }, displayDuration: true, tooltips: { controls: options.player_tooltip, seek: options.player_tooltip }, hideControls: options.hide_controls, // iOS fullscreen configuration - use fallback for YouTube on iOS fullscreen: { enabled: options.fullscreen !== false, fallback: true, iosNative: !shouldUseFallbackFullscreen // Disable iosNative for YouTube on iOS }, // Enable playsinline for iOS devices to allow custom controls playsinline: true, youtube: { ...(options.autoplay && { autoplay: options.autoplay }), ...(options.start && { start: options.start }), ...(options.end && { end: options.end }), ...(options.rel && { rel: options.rel }), ...(options.fullscreen && { fs: options.fullscreen }) }, vimeo: { byline: false, portrait: false, title: false, speed: true, transparent: false, controls: false, ...(options.t && { t: options.t }), ...(options.vautoplay && { autoplay: options.vautoplay }), ...(options.autopause && { autopause: options.autopause }), ...(options.dnt && { dnt: options.dnt }), } }); playerInit[playerId] = player; // Privacy Mode: if the click-to-load overlay just consented, kick off // playback automatically once the player is ready. Without this the // viewer has to click the main play button — a double-click experience. if (wrapper.getAttribute('data-ep-autoplay-after-init') === '1') { wrapper.removeAttribute('data-ep-autoplay-after-init'); var autoPlay = function () { try { player.play(); } catch (e) {} }; if (typeof player.once === 'function') { player.once('ready', autoPlay); player.once('canplay', autoPlay); } else { setTimeout(autoPlay, 100); } } // Each Pro feature initializes independently — a failure in one must // never prevent the next one from running. (Card 81243 acceptance: // "every feature should work independently".) function epSafeInit(name, fn) { try { fn(); } catch (err) { if (window.console && window.console.error) { window.console.error('[EmbedPress] ' + name + ' init failed:', err); } } } if (options.auto_resume) { epSafeInit('auto_resume', function () { epInitAutoResume(player, wrapper, options); }); } if (options.end_screen) { epSafeInit('end_screen', function () { epInitEndScreen(player, wrapper, options.end_screen); }); } if (options.timed_cta && options.timed_cta.length) { epSafeInit('timed_cta', function () { epInitTimedCTA(player, wrapper, options.timed_cta); }); } if (options.chapters && options.chapters.items && options.chapters.items.length) { epSafeInit('chapters', function () { epInitChapters(player, wrapper, options.chapters); }); } if (options.email_capture) { epSafeInit('email_capture', function () { epInitEmailCapture(player, wrapper, options.email_capture); }); } if (options.action_lock) { epSafeInit('action_lock', function () { epInitActionLock(player, wrapper, options.action_lock); }); } if (options.lms_tracking) { epSafeInit('lms_tracking', function () { epInitLmsTracking(player, wrapper, options.lms_tracking); }); } if (options.heatmap) { epSafeInit('heatmap', function () { epInitHeatmap(player, wrapper, options.heatmap); }); } // Resolve a real video title (YouTube/Vimeo iframe API) once the // player is ready, then cache on the wrapper. Heatmap + completion // beacons read this so the analytics dashboards stop displaying // YouTube's generic "YouTube video player" iframe placeholder. // // Skip in the editor (Gutenberg iframed canvas / Elementor preview): // the analytics beacons never fire there, and probing the YouTube // IFRAME API across a same-origin-but-iframed canvas surfaces // cross-origin SecurityErrors + occasional "Error 153 player // configuration" overlays. Only the live frontend needs this. if (!epIsInEditor()) { epSafeInit('video_title', function () { epBootstrapVideoTitle(player, wrapper); }); } // iOS YouTube fullscreen fix: Ensure iframe has proper attributes if (shouldUseFallbackFullscreen) { const iframe = document.querySelector(`[data-playerid='${playerId}'] iframe[src*="youtube"]`); if (iframe) { // Ensure the iframe allows fullscreen iframe.setAttribute('allowfullscreen', ''); iframe.setAttribute('webkitallowfullscreen', ''); iframe.setAttribute('mozallowfullscreen', ''); // Add iOS-specific class for styling iframe.classList.add('ios-youtube-iframe'); // Listen for fullscreen events to handle iOS-specific behavior player.on('enterfullscreen', () => { // Force viewport meta tag update for better fullscreen experience const viewport = document.querySelector('meta[name="viewport"]'); if (viewport) { const originalContent = viewport.getAttribute('content'); viewport.setAttribute('data-original-content', originalContent); viewport.setAttribute('content', 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover'); } }); player.on('exitfullscreen', () => { // Restore original viewport meta tag const viewport = document.querySelector('meta[name="viewport"]'); if (viewport && viewport.hasAttribute('data-original-content')) { viewport.setAttribute('content', viewport.getAttribute('data-original-content')); viewport.removeAttribute('data-original-content'); } }); } } // Mark the wrapper as initialized wrapper.classList.add('plyr-initialized'); const posterElement = wrapper.querySelector('.plyr__poster'); if (posterElement) { const interval = setInterval(() => { const computedBg = window.getComputedStyle(posterElement).getPropertyValue('background-image'); if (posterElement.style.backgroundImage || (computedBg && computedBg !== 'none')) { wrapper.style.opacity = '1'; clearInterval(interval); } }, 200); // Fallback: ensure the player becomes visible even if poster never loads setTimeout(() => { clearInterval(interval); wrapper.style.opacity = '1'; }, 5000); } else { // No poster element found — show the player immediately wrapper.style.opacity = '1'; } } // Check for the existence of the player's pip button at regular intervals const pipInterval = setInterval(() => { let playerPip = document.querySelector(`[data-playerid="${playerId}"] [data-plyr="pip"]`); if (playerPip) { clearInterval(pipInterval); let options = document.querySelector(`[data-playerid="${playerId}"]`).getAttribute('data-options'); options = JSON.parse(options); if (!options.self_hosted) { const iframeSelector = document.querySelector(`[data-playerid="${playerId}"] .plyr__video-wrapper`); // Add click event listener to toggle the pip mode playerPip.addEventListener('click', () => { iframeSelector.classList.toggle('pip-mode'); let parentElement = iframeSelector.parentElement; while (parentElement) { parentElement.style.zIndex = '9999'; parentElement = parentElement.parentElement; } }); if (options.pip) { iframeSelector.appendChild(pipPlayIconElement); iframeSelector.appendChild(pipPauseIconElement); iframeSelector.appendChild(pipCloseElement); const pipPlay = document.querySelector(`[data-playerid="${playerId}"] .plyr__video-wrapper .pip-play`); const pipPause = document.querySelector(`[data-playerid="${playerId}"] .plyr__video-wrapper .pip-pause`); const pipClose = document.querySelector(`[data-playerid="${playerId}"] .plyr__video-wrapper .pip-close`); pipClose.addEventListener('click', () => { iframeSelector.classList.remove('pip-mode'); }); iframeSelector.addEventListener('click', () => { const ariaPressedValue = document.querySelector(`[data-playerid="${playerId}"] .plyr__controls [data-plyr="play"]`).getAttribute('aria-pressed'); if (ariaPressedValue === 'true') { pipPause.style.display = 'none'; pipPlay.style.display = 'flex'; } else { pipPlay.style.display = 'none'; pipPause.style.display = 'flex'; } }); } } } }, 200); } /** * Auto Resume Playback * * Persists the current playhead in localStorage and prompts the viewer * to resume on revisit. Only active for sources where Plyr exposes * `currentTime` and `duration` (self-hosted video/audio, Vimeo, YouTube). * * Storage key includes the source URL so each video has its own slot. * Entries older than the TTL or beyond 95% completion are discarded. */ function epInitAutoResume(player, wrapper, options) { var TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days var COMPLETE_PCT = 0.95; var threshold = Math.max(5, parseInt(options.auto_resume_threshold, 10) || 30); var sourceKey = epResumeSourceKey(wrapper); if (!sourceKey) return; var storageKey = 'embedpress_resume::' + sourceKey; function readEntry() { try { var raw = window.localStorage.getItem(storageKey); if (!raw) return null; var entry = JSON.parse(raw); if (!entry || typeof entry.t !== 'number') return null; if (Date.now() - entry.savedAt > TTL_MS) { window.localStorage.removeItem(storageKey); return null; } return entry; } catch (e) { return null; } } function writeEntry(t, duration) { try { window.localStorage.setItem(storageKey, JSON.stringify({ t: t, d: duration || 0, savedAt: Date.now() })); } catch (e) { /* quota — ignore */ } } function clearEntry() { try { window.localStorage.removeItem(storageKey); } catch (e) {} } // Save position periodically while playing. var lastSaved = 0; player.on('timeupdate', function () { var now = player.currentTime || 0; var dur = player.duration || 0; if (now < threshold) return; if (dur && now / dur >= COMPLETE_PCT) return; if (Math.abs(now - lastSaved) < 5) return; // throttle to ~5s lastSaved = now; writeEntry(now, dur); }); player.on('ended', clearEntry); // Show resume prompt once metadata is ready. function maybePrompt() { var entry = readEntry(); if (!entry || entry.t < threshold) return; var dur = player.duration || entry.d || 0; if (dur && entry.t / dur >= COMPLETE_PCT) { clearEntry(); return; } epShowResumePrompt(wrapper, entry.t, function (resume) { if (resume) { try { player.currentTime = entry.t; } catch (e) {} } else { clearEntry(); try { player.currentTime = 0; } catch (e) {} } // Auto-play after either choice — the click was a user gesture, so // the play promise is allowed here. Without this the viewer has to // click the main play button afterwards (double-click). try { player.play(); } catch (e) {} }); } if (player.duration > 0) { maybePrompt(); } else { player.once('loadedmetadata', maybePrompt); // YouTube fires 'ready' before duration on some browsers. player.once('ready', function () { if (player.duration > 0) maybePrompt(); }); } } function epResumeSourceKey(wrapper) { var media = wrapper.querySelector('video, audio, iframe'); if (!media) return ''; var src = media.getAttribute('src') || media.currentSrc || ''; if (!src && media.querySelector) { var srcEl = media.querySelector('source'); if (srcEl) src = srcEl.getAttribute('src') || ''; } return src.replace(/[?#].*$/, ''); } function epFormatTime(seconds) { seconds = Math.max(0, Math.floor(seconds)); var h = Math.floor(seconds / 3600); var m = Math.floor((seconds % 3600) / 60); var s = seconds % 60; var pad = function (n) { return n < 10 ? '0' + n : '' + n; }; return h > 0 ? h + ':' + pad(m) + ':' + pad(s) : m + ':' + pad(s); } /** * Resolve a real, human-readable video title for analytics beacons. * * The dumb sources (iframe `title`, video `title`/`aria-label`, Plyr's * own `player.title`) are unreliable for embedded providers — YouTube * always rewrites its own iframe title to "YouTube video player", * Vimeo to "vimeo-player", Wistia to "Wistia video player". So the * heatmap + completion dashboards used to show every row as * "YouTube video player". We filter those generic placeholders out * and prefer the iframe-API-resolved title cached on the wrapper. */ var EP_GENERIC_TITLES = /^(youtube\s*video\s*player|vimeo[-\s]*player|wistia\s*video\s*player|video\s*player|youtube|vimeo|wistia)$/i; function epIsGenericTitle(t) { if (!t) return true; return EP_GENERIC_TITLES.test(String(t).trim()); } function epResolveVideoTitle(player, wrapper) { // 1. Cached title set by epBootstrapVideoTitle (YouTube/Vimeo iframe API). if (wrapper && wrapper.__epVideoTitle && !epIsGenericTitle(wrapper.__epVideoTitle)) { return wrapper.__epVideoTitle; } // 2. Plyr's resolved title (rare for embedded providers). if (player && player.title && !epIsGenericTitle(player.title)) { return player.title; } // 3. Iframe / video element attributes — only if non-generic. try { var iframe = wrapper.querySelector('iframe'); var videoEl = wrapper.querySelector('video'); var t = (iframe && iframe.getAttribute('title')) || (videoEl && (videoEl.getAttribute('title') || videoEl.getAttribute('aria-label'))) || ''; if (!epIsGenericTitle(t)) return t; } catch (e) {} return ''; } /** * After the player is ready, ask YouTube/Vimeo for the actual title * and cache it on the wrapper. The heatmap beacon fires every 30s and * the completion beacon at end / on unload — by the time either runs * the iframe API has had plenty of time to settle. */ /** * Detect whether we're running inside the WordPress block editor's * iframed canvas or Elementor's preview frame. Used to short-circuit * cross-origin probes (YouTube IFRAME API, etc.) that throw or * surface the "Error 153 Video player configuration error" overlay * when called from a nested cross-origin context. */ function epIsInEditor() { try { if (window.parent !== window) { // Block editor iframed canvas — body has class "block-editor-iframe__body" // (WP 6.x) or there's a parent with `wp-admin` flag. if (document.body && /\bblock-editor(-iframe)?__body\b/.test(document.body.className || '')) return true; // Elementor preview iframe. if (document.body && /\belementor-editor-active\b/.test(document.body.className || '')) return true; // Gutenberg classic editor iframe (rare, but harmless). try { if (window.parent.wp && window.parent.wp.data && typeof window.parent.wp.data.select === 'function') return true; } catch (e) {} // cross-origin parent — definitely live frontend } } catch (e) {} return false; } function epBootstrapVideoTitle(player, wrapper) { if (!player || !wrapper || wrapper.__epVideoTitle) return; var run = function () { try { // YouTube — Plyr exposes the underlying YouTube player via // `player.embed`. getVideoData() returns { title, video_id, ... }. if (player.embed && typeof player.embed.getVideoData === 'function') { var data = player.embed.getVideoData(); if (data && data.title && !epIsGenericTitle(data.title)) { wrapper.__epVideoTitle = data.title; return; } } // Vimeo — `player.embed` is a Vimeo Player SDK instance with // a Promise-returning getVideoTitle(). if (player.embed && typeof player.embed.getVideoTitle === 'function') { var p = player.embed.getVideoTitle(); if (p && typeof p.then === 'function') { p.then(function (title) { if (title && !epIsGenericTitle(title)) wrapper.__epVideoTitle = title; }).catch(function () {}); return; } } // Self-hosted / fallback: trust Plyr's title. if (player.title && !epIsGenericTitle(player.title)) { wrapper.__epVideoTitle = player.title; } } catch (e) {} }; if (typeof player.once === 'function') { player.once('ready', function () { setTimeout(run, 250); }); player.once('canplay', function () { setTimeout(run, 250); }); } else { setTimeout(run, 1000); } } /** * Drop-off Heatmap * * Posts the viewer's current 1-percent bucket to /heatmap/sample at most * once per `interval` seconds while the video is playing. No personal * data is sent — only the URL of the video and the bucket index. */ function epInitHeatmap(player, wrapper, settings) { var videoUrl = epResumeSourceKey(wrapper) || ''; if (!videoUrl) return; var lastSent = 0; var lastBucket = -1; var interval = (settings.interval || 30) * 1000; player.on('timeupdate', function () { var dur = player.duration || 0; if (!dur) return; var now = Date.now(); if (now - lastSent < interval) return; var pct = Math.min(99, Math.max(0, Math.floor((player.currentTime / dur) * 100))); if (pct === lastBucket) return; // skip if viewer hasn't moved out of bucket lastBucket = pct; lastSent = now; var body = new FormData(); body.append('video_url', videoUrl); var videoTitle = epResolveVideoTitle(player, wrapper); if (videoTitle) body.append('video_title', videoTitle); body.append('bucket', pct); fetch(settings.rest_url, { method: 'POST', headers: { 'X-WP-Nonce': settings.nonce }, body: body, credentials: 'same-origin', keepalive: true }).catch(function () {}); }); } /** * Course Completion Tracking * * Two signals flow to /completion: * * 1. Threshold cross — watched/total exceeds the configured threshold * AND anti-skip passes. Fires `embedpress:video-completed`, hits the * server which calls the LMS adapter once per (user, video). * 2. Progress beacons — sent on pause, ended, and beforeunload via * navigator.sendBeacon with progress_only=1. Bumps the per-user * max_watched_seconds map so the dashboard sees real watch depth * even for non-completers, and reflects "watched 100%" for users * who keep going past a 50% threshold. * * Idempotency lives on the server (per-user user-meta) — no * sessionStorage gate here, because a failed first attempt (network * blip, anti-skip rejection) used to lock the user out for the rest * of the session. `fired` is page-load scoped only — it stops the * threshold-cross from re-firing on every timeupdate after it crosses. */ function epInitLmsTracking(player, wrapper, settings) { // Position threshold: how far through the video the viewer must be // for the completion event to fire. Configurable (default 90%). var positionThreshold = (settings.threshold || 90) / 100; // Anti-skip floor: cumulative watched time must be at least this // fraction of duration. Cap at 0.85 and otherwise scale to ~90% of // the position threshold — leaves 10% slop for the unavoidable // first-tick miss + dropped YouTube timeupdates (delta>=5 are // excluded), so a natural watch-through to 50% still satisfies a 50% // target. Server uses the same formula. var antiSkipFloor = Math.min(0.85, positionThreshold * 0.9); var watched = 0; var lastT = null; var fired = false; var lastBeaconWatched = 0; function tick() { var t = player.currentTime || 0; if (lastT !== null) { var delta = t - lastT; // Count only forward, small steps as watched (skip cuts forward). // <5 because Plyr's YouTube timeupdate fires at a coarser rate // than HTML5 video — bursts of 2–4s deltas are routine. if (delta > 0 && delta < 5) watched += delta; } lastT = t; if (fired) return; var dur = player.duration || 0; if (!dur) return; var ratio = watched / dur; if (t / dur >= positionThreshold && ratio >= antiSkipFloor) { fired = true; epReportCompletion(wrapper, settings, watched, dur, false); lastBeaconWatched = watched; } } function sendProgressBeacon() { var dur = player.duration || 0; if (!dur || watched <= 0) return; // Throttle: skip if watched hasn't grown by at least 1s since the // last beacon. Avoids back-to-back pause+beforeunload duplicates. if (watched - lastBeaconWatched < 1) return; lastBeaconWatched = watched; epReportCompletion(wrapper, settings, watched, dur, true); } player.on('timeupdate', tick); player.on('seeking', function () { lastT = null; }); // pause counting across seeks player.on('pause', sendProgressBeacon); player.on('ended', function () { var dur = player.duration || 0; // Natural `ended` is the strongest signal of a watch-through. Anti- // skip floor still applies (so seek-to-last-second + ended doesn't // credit completion), but positionThreshold is trivially met at end. if (!fired && dur && watched / dur >= antiSkipFloor) { fired = true; epReportCompletion(wrapper, settings, watched, dur, false); lastBeaconWatched = watched; } else { sendProgressBeacon(); } }); // Capture progress for users who close the tab mid-watch. sendBeacon // inside epReportCompletion survives the unload. window.addEventListener('beforeunload', sendProgressBeacon); // Safari iOS doesn't fire beforeunload reliably — pagehide is the // documented replacement. window.addEventListener('pagehide', sendProgressBeacon); } function epReportCompletion(wrapper, settings, watched, total, progressOnly) { // At fire time the iframe is fully loaded, so the resume key is reliable. // Fall back through every plausible identifier so the LMS adapter always // has *something* to match the lesson on. var iframe = wrapper.querySelector('iframe'); var videoEl = wrapper.querySelector('video'); var videoUrl = epResumeSourceKey(wrapper) || (iframe && iframe.getAttribute('data-ep-privacy-src')) || (iframe && iframe.src && iframe.src.replace(/[?#].*$/, '')) || ''; // `player` isn't passed in here — the resolver tolerates a null player // and falls back to the wrapper-cached title set on `ready`. var videoTitle = epResolveVideoTitle(null, wrapper); var pageId = document.body && document.body.className ? (document.body.className.match(/postid-(\d+)/) || [])[1] || '' : ''; var detail = { video_url: videoUrl, video_title: videoTitle, page_id: pageId, watched_seconds: Math.round(watched), total_seconds: Math.round(total), }; // Public JS event — only on actual completion, not on every progress // beacon. LMS plugins listening client-side expect a single fire. if (!progressOnly) { document.dispatchEvent(new CustomEvent('embedpress:video-completed', { detail: detail })); } var body = new FormData(); body.append('video_url', detail.video_url); if (videoTitle) body.append('video_title', videoTitle); if (pageId) body.append('page_id', pageId); body.append('watched_seconds', detail.watched_seconds); body.append('total_seconds', detail.total_seconds); if (settings && settings.threshold) { body.append('threshold', (settings.threshold / 100).toFixed(2)); } if (progressOnly) body.append('progress_only', '1'); // Progress beacons must survive page unload — sendBeacon is the only // transport that does. It can't set X-WP-Nonce, so we put _wpnonce // in the form body (WP REST accepts both transports for cookie auth). if (progressOnly && navigator && typeof navigator.sendBeacon === 'function') { if (settings && settings.nonce) body.append('_wpnonce', settings.nonce); try { navigator.sendBeacon(settings.rest_url, body); } catch (e) {} return; } fetch(settings.rest_url, { method: 'POST', headers: { 'X-WP-Nonce': settings.nonce }, body: body, credentials: 'same-origin', keepalive: true }).catch(function () {}); } /** * Adaptive Streaming * * Wires hls.js / dash.js into the <video> element when the source is a * manifest (.m3u8 / .mpd). Both libraries are lazy-loaded from jsDelivr * once per page so they don't impact pages that don't use streaming. * * Native HLS playback (Safari) is preferred — hls.js only attaches when * MediaSource is required. */ function epAttachAdaptiveStreaming(videoEl) { if (!videoEl) return; var src = videoEl.getAttribute('src') || ''; if (!src) { var srcEl = videoEl.querySelector('source'); if (srcEl) src = srcEl.getAttribute('src') || ''; } if (!src) return; var lower = src.toLowerCase().replace(/[?#].*$/, ''); if (lower.endsWith('.m3u8')) { // Native HLS (Safari) — let the browser handle it. if (videoEl.canPlayType('application/vnd.apple.mpegurl')) return; epLoadScript('https://cdn.jsdelivr.net/npm/hls.js@1', function () { if (typeof window.Hls === 'undefined' || !window.Hls.isSupported()) return; var hls = new window.Hls(); hls.loadSource(src); hls.attachMedia(videoEl); }); return; } if (lower.endsWith('.mpd')) { epLoadScript('https://cdn.jsdelivr.net/npm/dashjs@4/dist/dash.all.min.js', function () { if (typeof window.dashjs === 'undefined') return; var p = window.dashjs.MediaPlayer().create(); p.initialize(videoEl, src, false); }); } } function epLoadScript(src, onReady) { var existing = document.querySelector('script[data-ep-src="' + src + '"]'); if (existing) { if (existing.dataset.epLoaded === '1') { onReady(); } else { existing.addEventListener('load', onReady, { once: true }); } return; } var s = document.createElement('script'); s.src = src; s.async = true; s.dataset.epSrc = src; s.addEventListener('load', function () { s.dataset.epLoaded = '1'; onReady(); }); document.head.appendChild(s); } /** * Action Lock * * Blocks playback until the viewer completes a configured action. * Unlock state persists per video in sessionStorage. Verification is * best-effort (open-window heuristic for shares/links, login round-trip * for the login type). */ function epInitActionLock(player, wrapper, settings) { var sourceKey = epResumeSourceKey(wrapper) || (wrapper.getAttribute('data-playerid') || ''); var storageKey = 'embedpress_unlock::' + sourceKey; try { if (window.sessionStorage.getItem(storageKey)) return; } catch (e) {} var unlocked = false; var overlay = epBuildActionLockOverlay(wrapper, settings, function () { unlocked = true; try { window.sessionStorage.setItem(storageKey, '1'); } catch (e) {} if (overlay && overlay.parentNode) overlay.remove(); try { player.play(); } catch (e) {} }); // Stop play attempts while locked. player.on('play', function () { if (!unlocked) { try { player.pause(); } catch (e) {} } }); // Pause now in case autoplay started. try { player.pause(); } catch (e) {} } function epBuildActionLockOverlay(wrapper, settings, onUnlock) { if (wrapper.querySelector('.ep-action-lock')) return null; var overlay = document.createElement('div'); overlay.className = 'ep-action-lock ep-action-lock--' + settings.type; var inner = document.createElement('div'); inner.className = 'ep-action-lock__inner'; if (settings.headline) { var h = document.createElement('p'); h.className = 'ep-action-lock__headline'; h.textContent = settings.headline; inner.appendChild(h); } if (settings.message) { var m = document.createElement('p'); m.className = 'ep-action-lock__message'; m.textContent = settings.message; inner.appendChild(m); } var actions = document.createElement('div'); actions.className = 'ep-action-lock__actions'; if (settings.type === 'share') { (settings.share_networks || []).forEach(function (net) { var url = epShareUrlFor(net, settings.share_url); if (!url) return; var btn = epOpenWindowButton(net.charAt(0).toUpperCase() + net.slice(1), url, onUnlock); btn.classList.add('ep-action-lock__btn--' + net); actions.appendChild(btn); }); } else if (settings.type === 'link') { if (settings.link_url) { var linkBtn = epOpenWindowButton(settings.link_text || 'Open link', settings.link_url, onUnlock); actions.appendChild(linkBtn); } } else if (settings.type === 'login') { if (settings.login_url) { var loginBtn = document.createElement('a'); loginBtn.className = 'ep-action-lock__btn ep-action-lock__btn--primary'; loginBtn.href = settings.login_url; loginBtn.textContent = 'Log in'; actions.appendChild(loginBtn); } } inner.appendChild(actions); overlay.appendChild(inner); wrapper.appendChild(overlay); return overlay; } function epShareUrlFor(network, target) { var encoded = encodeURIComponent(target || window.location.href); switch (network) { case 'facebook': return 'https://www.facebook.com/sharer/sharer.php?u=' + encoded; case 'twitter': return 'https://twitter.com/intent/tweet?url=' + encoded; case 'linkedin': return 'https://www.linkedin.com/sharing/share-offsite/?url=' + encoded; default: return ''; } } function epOpenWindowButton(label, url, onComplete) { var btn = document.createElement('button'); btn.type = 'button'; btn.className = 'ep-action-lock__btn ep-action-lock__btn--primary'; btn.textContent = label; btn.addEventListener('click', function () { var w = window.open(url, '_blank', 'noopener,noreferrer,width=600,height=520'); btn.disabled = true; btn.textContent = 'Verifying…'; // Best-effort: unlock once the user returns focus to the host page. var armed = false; setTimeout(function () { armed = true; }, 1500); var onFocus = function () { if (!armed) return; window.removeEventListener('focus', onFocus); onComplete(); }; window.addEventListener('focus', onFocus); // Fallback: if popup was blocked, navigate parent and unlock. if (!w) { window.removeEventListener('focus', onFocus); onComplete(); } }); return btn; } /** * Email Capture * * Pauses playback at the configured trigger and shows a form overlay. * On submit posts to /embedpress/v1/lead and resumes. Submission is * remembered per video in localStorage so the prompt fires once. */ function epInitEmailCapture(player, wrapper, settings) { var sourceKey = epResumeSourceKey(wrapper) || (window.location.pathname + ':' + (wrapper.getAttribute('data-playerid') || '')); var storageKey = 'embedpress_lead::' + sourceKey; // In the block editor, ignore the "already submitted" localStorage flag so // toggling the option in the inspector reliably re-shows the form. // Otherwise a single test submit permanently hides it. var isEditor = !!(document.body && ( document.body.classList.contains('block-editor-page') || document.body.classList.contains('wp-admin') || document.querySelector('.block-editor') )); // Already submitted? Skip entirely (front-end only). try { if (!isEditor && window.localStorage.getItem(storageKey)) return; } catch (e) {} var triggered = false; player.on('timeupdate', function () { if (triggered) return; var now = player.currentTime || 0; var dur = player.duration || 0; var triggerAt; if (settings.unit === 'percent' && dur > 0) { triggerAt = (settings.time / 100) * dur; } else { triggerAt = settings.time; } if (now < triggerAt) return; triggered = true; try { player.pause(); } catch (e) {} epShowEmailCaptureForm(wrapper, settings, function (submitted) { if (submitted) { try { window.localStorage.setItem(storageKey, '1'); } catch (e) {} } try { player.play(); } catch (e) {} }); }); } function epShowEmailCaptureForm(wrapper, settings, onDone) { if (wrapper.querySelector('.ep-lead-form')) return; var overlay = document.createElement('div'); overlay.className = 'ep-lead-form'; var form = document.createElement('form'); form.className = 'ep-lead-form__inner'; form.setAttribute('novalidate', 'true'); var headline = document.createElement('p'); headline.className = 'ep-lead-form__headline'; headline.textContent = settings.headline || 'Enter your email to keep watching'; form.appendChild(headline); function epLeadField(labelText, input) { var field = document.createElement('div'); field.className = 'ep-lead-form__field'; var label = document.createElement('label'); label.className = 'ep-lead-form__label'; label.textContent = labelText; var fieldId = 'ep-lead-' + Math.random().toString(36).slice(2, 8); label.setAttribute('for', fieldId); input.id = fieldId; field.appendChild(label); field.appendChild(input); return field; } var nameInput = null; if (settings.require_name) { nameInput = document.createElement('input'); nameInput.type = 'text'; nameInput.required = true; nameInput.placeholder = settings.name_placeholder || 'Your name'; nameInput.className = 'ep-lead-form__input'; form.appendChild(epLeadField(settings.name_label || 'Name', nameInput)); } var emailInput = document.createElement('input'); emailInput.type = 'email'; emailInput.required = true; emailInput.placeholder = settings.email_placeholder || 'you@example.com'; emailInput.className = 'ep-lead-form__input'; form.appendChild(epLeadField(settings.email_label || 'Email', emailInput)); var error = document.createElement('p'); error.className = 'ep-lead-form__error'; error.style.display = 'none'; form.appendChild(error); var actions = document.createElement('div'); actions.className = 'ep-lead-form__actions'; var submit = document.createElement('button'); submit.type = 'submit'; submit.className = 'ep-lead-form__btn ep-lead-form__btn--primary'; submit.textContent = settings.button_text || 'Continue'; actions.appendChild(submit); if (settings.allow_skip) { var skip = document.createElement('button'); skip.type = 'button'; skip.className = 'ep-lead-form__btn'; skip.textContent = 'Skip'; skip.addEventListener('click', function () { overlay.remove(); onDone(false); }); actions.appendChild(skip); } form.appendChild(actions); form.addEventListener('submit', function (e) { e.preventDefault(); error.style.display = 'none'; var email = (emailInput.value || '').trim(); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { error.textContent = 'Please enter a valid email address.'; error.style.display = 'block'; return; } if (nameInput && !(nameInput.value || '').trim()) { error.textContent = 'Please enter your name.'; error.style.display = 'block'; return; } submit.disabled = true; submit.textContent = 'Sending…'; var body = new FormData(); body.append('email', email); if (nameInput) body.append('name', nameInput.value.trim()); body.append('video_url', epResumeSourceKey(wrapper) || ''); body.append('page_url', window.location.href); fetch(settings.rest_url, { method: 'POST', headers: { 'X-WP-Nonce': settings.nonce }, body: body }).then(function (res) { // Resume even on error; we don't want to trap the viewer. overlay.remove(); onDone(true); }).catch(function () { overlay.remove(); onDone(true); }); }); overlay.appendChild(form); wrapper.appendChild(overlay); } /** * Video Chapters * * Renders tick marks on the progress bar at each chapter's start, a * top-left badge showing the current chapter (toggle to expand the full * list), and click-to-seek behavior on both ticks and list items. */ function epInitChapters(player, wrapper, settings) { var items = (settings.items || []).slice().sort(function (a, b) { return a.time - b.time; }); var showTitle = settings.show_title !== false; var label, list, ticksHost, tooltipEl; // Anchor overlays to the embed wrapper. CSS gives .ep-embed-content-wraper // position:relative so top/left on the absolute label resolve here, not on // some far-away ancestor. wrapper.classList.add('ep-has-chapters'); function findCurrentIndex(t) { if (!items.length) return -1; // Clamp before the first chapter to chapter 0 so the label always // reflects a chapter once playback starts, instead of going blank // when the first chapter doesn't start at exactly 0. if (t < items[0].time) return 0; var idx = 0; for (var i = 0; i < items.length; i++) { if (t >= items[i].time) idx = i; else break; } return idx; } function buildPanel() { if (showTitle && !label) { label = document.createElement('button'); label.type = 'button'; label.className = 'ep-chapter-label'; label.setAttribute('aria-label', 'Toggle chapter list'); label.innerHTML = '<span class="ep-chapter-label__title"></span><span class="ep-chapter-label__caret">▾</span>'; label.addEventListener('click', function (e) { e.stopPropagation(); list.classList.toggle('ep-chapter-list--open'); }); wrapper.appendChild(label); } list = document.createElement('div'); list.className = 'ep-chapter-list'; items.forEach(function (item, idx) { var row = document.createElement('button'); row.type = 'button'; row.className = 'ep-chapter-list__item'; row.dataset.idx = idx; row.innerHTML = '<span class="ep-chapter-list__time">' + epFormatTime(item.time) + '</span>' + '<span class="ep-chapter-list__title"></span>'; row.querySelector('.ep-chapter-list__title').textContent = item.title; row.addEventListener('click', function () { // Seek + start playback. Without play() a chapter click while the // player is still paused at its poster (or the Cinematic Preview // overlay) just changes the buffered position silently — viewers // expect clicking a chapter to take them there AND play. try { player.currentTime = item.time; } catch (e) {} try { var p = player.play(); if (p && typeof p.catch === 'function') p.catch(function () {}); } catch (e) {} list.classList.remove('ep-chapter-list--open'); }); list.appendChild(row); }); wrapper.appendChild(list); } function buildTicks() { var dur = player.duration || 0; if (!dur) return; var progress = wrapper.querySelector('.plyr__progress'); if (!progress) return; if (ticksHost) ticksHost.remove(); ticksHost = document.createElement('div'); ticksHost.className = 'ep-chapter-bar'; // Drop chapters whose start is past the video duration — they'd // compute to >100% and produce calc(6068% - 4px)-style overflowing // segments. Clamp the resulting percentages to [0,100] as a final // guard against sub-second `dur` races during YouTube IFrame ready. var sorted = items .filter(function (it) { return it && typeof it.time === 'number' && it.time < dur; }); // Prepend a synthetic [0 → first-chapter-start] segment so the bar // covers the full duration. Carry an empty title so the tooltip // doesn't lie about which chapter the viewer is over. if (sorted[0] && sorted[0].time > 0) { sorted.unshift({ time: 0, title: '' }); } if (!sorted.length) return; // Render one DOM segment per chapter — these visually replace Plyr's // continuous fill. Plyr's native track is hidden via CSS; the input // and thumb stay interactive (segments use pointer-events: none). progress.classList.add('ep-chapters-split'); var GAP_PX = 4; var segs = []; sorted.forEach(function (item, i) { var startPct = Math.max(0, Math.min(100, (item.time / dur) * 100)); var endPct = (i + 1 < sorted.length) ? Math.max(0, Math.min(100, (sorted[i + 1].time / dur) * 100)) : 100; var spanPct = endPct - startPct; if (spanPct <= 0) return; var seg = document.createElement('div'); seg.className = 'ep-chapter-seg'; var leftOffset = (i === 0) ? 0 : (GAP_PX / 2); var rightOffset = (i === sorted.length - 1) ? 0 : (GAP_PX / 2); seg.style.left = 'calc(' + startPct + '% + ' + leftOffset + 'px)'; seg.style.width = 'calc(' + spanPct + '% - ' + (leftOffset + rightOffset) + 'px)'; var fill = document.createElement('div'); fill.className = 'ep-chapter-seg__fill'; seg.appendChild(fill); seg._start = item.time; seg._end = (i + 1 < sorted.length) ? sorted[i + 1].time : dur; seg._title = item.title; seg._fill = fill; ticksHost.appendChild(seg); segs.push(seg); }); function updateFills(t) { segs.forEach(function (seg) { var span = seg._end - seg._start; var local = Math.max(0, Math.min(span, t - seg._start)); seg._fill.style.width = (span > 0 ? (local / span) * 100 : 0) + '%'; }); } function onMove(e) { var rect = progress.getBoundingClientRect(); if (!rect.width) return; var pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); var t = pct * dur; var hovered = null; for (var i = 0; i < segs.length; i++) { if (t >= segs[i]._start && t <= segs[i]._end) { hovered = segs[i]; break; } } segs.forEach(function (s) { s.classList.toggle('ep-chapter-seg--hover', s === hovered); }); // Append the chapter title as a second line inside Plyr's existing // seek tooltip. Plyr binds its own mousemove handler first (during // Plyr init); ours runs after, so by this point Plyr has already // written the time string and we can append without a race. var tip = progress.querySelector('.plyr__tooltip'); if (!tip) return; // Strip any prior chapter line we appended on the previous move so // we don't accumulate <br> stacks if Plyr's update is skipped. var existing = tip.querySelector('.ep-chapter-tooltip-title'); if (existing) existing.remove(); var existingBr = tip.querySelector('br.ep-chapter-tooltip-br'); if (existingBr) existingBr.remove(); if (hovered && hovered._title) { var br = document.createElement('br'); br.className = 'ep-chapter-tooltip-br'; var titleSpan = document.createElement('span'); titleSpan.className = 'ep-chapter-tooltip-title'; titleSpan.textContent = hovered._title; tip.appendChild(br); tip.appendChild(titleSpan); } } function onLeave() { segs.forEach(function (s) { s.classList.remove('ep-chapter-seg--hover'); }); var tip = progress.querySelector('.plyr__tooltip'); if (tip) { var existing = tip.querySelector('.ep-chapter-tooltip-title'); if (existing) existing.remove(); var existingBr = tip.querySelector('br.ep-chapter-tooltip-br'); if (existingBr) existingBr.remove(); } } progress.addEventListener('mousemove', onMove); progress.addEventListener('mouseleave', onLeave); updateFills(player.currentTime || 0); player.on('timeupdate', function () { updateFills(player.currentTime || 0); }); player.on('seeked', function () { updateFills(player.currentTime || 0); }); progress.appendChild(ticksHost); } function refreshLabel() { if (!label) return; var idx = findCurrentIndex(player.currentTime || 0); var titleEl = label.querySelector('.ep-chapter-label__title'); if (idx < 0) { titleEl.textContent = ''; label.classList.add('ep-chapter-label--hidden'); } else { titleEl.textContent = items[idx].title; label.classList.remove('ep-chapter-label--hidden'); } Array.prototype.forEach.call(list.querySelectorAll('.ep-chapter-list__item'), function (row, i) { row.classList.toggle('ep-chapter-list__item--active', i === idx); }); } buildPanel(); if (player.duration > 0) buildTicks(); player.on('loadedmetadata', buildTicks); player.on('ready', buildTicks); player.on('timeupdate', refreshLabel); player.on('play', refreshLabel); player.on('seeked', refreshLabel); refreshLabel(); // Click-outside to close list document.addEventListener('click', function (e) { if (!list.classList.contains('ep-chapter-list--open')) return; if (label && (label.contains(e.target) || list.contains(e.target))) return; list.classList.remove('ep-chapter-list--open'); }); } /** * Timed CTA * * Items fire at their `time` (seconds), render an overlay anchored to the * bottom of the player, and auto-hide after `duration` (0 = until dismissed * or video ends). Each item fires at most once per session. */ function epInitTimedCTA(player, wrapper, items) { // Shallow clone so flags don't mutate the data-options literal. var queue = items.map(function (it) { return Object.assign({}, it, { _shown: false, _el: null, _timer: null }); }); player.on('timeupdate', function () { var now = player.currentTime || 0; queue.forEach(function (item) { if (item._shown) return; if (now < item.time) return; item._shown = true; epShowTimedCTA(wrapper, item); }); }); player.on('seeked', function () { var now = player.currentTime || 0; queue.forEach(function (item) { if (item._shown && now < item.time && item._el) { item._el.remove(); if (item._timer) clearTimeout(item._timer); item._shown = false; } }); }); player.on('ended', function () { queue.forEach(function (item) { if (item._el) item._el.remove(); if (item._timer) clearTimeout(item._timer); }); }); } function epShowTimedCTA(wrapper, item) { var el = document.createElement('div'); el.className = 'ep-timed-cta'; item._el = el; var inner = document.createElement('div'); inner.className = 'ep-timed-cta__inner'; if (item.headline) { var h = document.createElement('p'); h.className = 'ep-timed-cta__headline'; h.textContent = item.headline; inner.appendChild(h); } if (item.button_text && item.button_url) { var btn = document.createElement('a'); btn.className = 'ep-timed-cta__btn'; btn.href = item.button_url; btn.target = '_blank'; btn.rel = 'noopener noreferrer'; btn.textContent = item.button_text; inner.appendChild(btn); } el.appendChild(inner); if (item.dismissible !== false) { var close = document.createElement('button'); close.type = 'button'; close.className = 'ep-timed-cta__close'; close.setAttribute('aria-label', 'Close'); close.innerHTML = '×'; close.addEventListener('click', function () { el.remove(); if (item._timer) clearTimeout(item._timer); }); el.appendChild(close); } // Stack multiple concurrent CTAs in a single bottom-anchored column so // they don't overlap when their visible windows intersect. var stack = wrapper.querySelector(':scope > .ep-timed-cta-stack'); if (!stack) { stack = document.createElement('div'); stack.className = 'ep-timed-cta-stack'; wrapper.appendChild(stack); } stack.appendChild(el); if (item.duration && item.duration > 0) { item._timer = setTimeout(function () { if (el.parentNode) el.remove(); }, item.duration * 1000); } } /** * Advanced Privacy Mode * * Renders a click-to-load overlay over the wrapper. The iframe's real `src` * has already been stashed in `data-ep-privacy-src` server-side; here we * just show a poster + play button and restore the URL on click. */ function epShowPrivacyOverlay(wrapper, options, onConsent) { if (wrapper.querySelector('.ep-privacy-overlay')) return; wrapper.style.opacity = '1'; var overlay = document.createElement('div'); overlay.className = 'ep-privacy-overlay'; var poster = options.poster_thumbnail || epGuessYouTubeThumbnail(wrapper); if (poster) { overlay.style.backgroundImage = 'url("' + poster.replace(/"/g, '\\"') + '")'; overlay.classList.add('ep-privacy-overlay--has-poster'); } var play = document.createElement('button'); play.type = 'button'; play.className = 'ep-privacy-overlay__play'; play.setAttribute('aria-label', 'Load and play video'); 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>'; var msg = document.createElement('p'); msg.className = 'ep-privacy-overlay__msg'; msg.textContent = options.privacy_message || 'Click to load. By playing, you accept third-party cookies.'; overlay.appendChild(play); overlay.appendChild(msg); overlay.addEventListener('click', function () { overlay.remove(); onConsent(); }); wrapper.appendChild(overlay); } function epRestorePrivacyIframes(wrapper) { var iframes = wrapper.querySelectorAll('iframe[data-ep-privacy-src]'); iframes.forEach(function (iframe) { var src = iframe.getAttribute('data-ep-privacy-src'); iframe.removeAttribute('data-ep-privacy-src'); if (src) iframe.setAttribute('src', src); }); } function epGuessYouTubeThumbnail(wrapper) { var iframe = wrapper.querySelector('iframe[data-ep-privacy-src]'); if (!iframe) return ''; var src = iframe.getAttribute('data-ep-privacy-src') || ''; var m = src.match(/(?:youtube(?:-nocookie)?\.com\/embed\/|youtu\.be\/)([A-Za-z0-9_-]{6,})/); return m ? 'https://img.youtube.com/vi/' + m[1] + '/hqdefault.jpg' : ''; } /** * Custom End Screen * * Renders a configurable overlay when the video reaches `ended`. Three modes: * - message: simple message (+ optional replay) * - cta: message + button linking elsewhere * - redirect: redirects after a countdown */ function epInitEndScreen(player, wrapper, settings) { player.on('ended', function () { epShowEndScreen(wrapper, settings, function () { try { player.currentTime = 0; player.play(); } catch (e) {} }); }); } function epShowEndScreen(wrapper, settings, onReplay) { // Avoid duplicates if 'ended' fires twice. var existing = wrapper.querySelector('.ep-end-screen'); if (existing) existing.remove(); var mode = settings.mode || 'message'; var msg = settings.message || ''; var showReplay = settings.show_replay !== false; var overlay = document.createElement('div'); overlay.className = 'ep-end-screen ep-end-screen--' + mode; var inner = document.createElement('div'); inner.className = 'ep-end-screen__inner'; if (msg) { var p = document.createElement('p'); p.className = 'ep-end-screen__msg'; p.textContent = msg; inner.appendChild(p); } if (mode === 'cta' && settings.button_url && settings.button_text) { var cta = document.createElement('a'); cta.className = 'ep-end-screen__btn ep-end-screen__btn--primary'; cta.href = settings.button_url; cta.target = '_blank'; cta.rel = 'noopener noreferrer'; cta.textContent = settings.button_text; inner.appendChild(cta); } var actions = document.createElement('div'); actions.className = 'ep-end-screen__actions'; if (showReplay) { var replay = document.createElement('button'); replay.type = 'button'; replay.className = 'ep-end-screen__btn'; replay.textContent = 'Replay'; replay.addEventListener('click', function () { overlay.remove(); onReplay(); }); actions.appendChild(replay); } if (mode === 'redirect' && settings.redirect_url) { var countdown = Math.max(0, parseInt(settings.countdown, 10) || 0); var countEl = document.createElement('p'); countEl.className = 'ep-end-screen__countdown'; inner.appendChild(countEl); var redirectNow = function () { if (settings.redirect_new_window) { window.open(settings.redirect_url, '_blank', 'noopener,noreferrer'); } else { window.location.href = settings.redirect_url; } }; if (countdown === 0) { redirectNow(); return; } countEl.textContent = 'Redirecting in ' + countdown + 's…'; var remaining = countdown; var timer = setInterval(function () { remaining -= 1; if (remaining <= 0) { clearInterval(timer); redirectNow(); return; } countEl.textContent = 'Redirecting in ' + remaining + 's…'; }, 1000); var cancel = document.createElement('button'); cancel.type = 'button'; cancel.className = 'ep-end-screen__btn'; cancel.textContent = 'Cancel'; cancel.addEventListener('click', function () { clearInterval(timer); overlay.remove(); }); actions.appendChild(cancel); } if (actions.childNodes.length) inner.appendChild(actions); overlay.appendChild(inner); wrapper.appendChild(overlay); } function epShowResumePrompt(wrapper, time, onChoice) { if (wrapper.querySelector('.ep-resume-prompt')) return; var overlay = document.createElement('div'); overlay.className = 'ep-resume-prompt'; overlay.innerHTML = '<div class="ep-resume-prompt__inner">' + '<p class="ep-resume-prompt__msg">Resume at ' + epFormatTime(time) + '?</p>' + '<div class="ep-resume-prompt__actions">' + '<button type="button" class="ep-resume-prompt__btn ep-resume-prompt__btn--primary" data-action="resume">Resume</button>' + '<button type="button" class="ep-resume-prompt__btn" data-action="restart">Start Over</button>' + '</div>' + '</div>'; overlay.addEventListener('click', function (e) { var action = e.target && e.target.getAttribute && e.target.getAttribute('data-action'); if (!action) return; overlay.remove(); onChoice(action === 'resume'); }); wrapper.appendChild(overlay); }
修改文件时间
将文件时间修改为当前时间的前一年
删除文件