1/** 2 * @author Magebit <[email protected]> 3 * @copyright Copyright (c) Magebit, Ltd. (https://magebit.com) 4 * @license https://magebit.com/code-license 5 * 6 * Video embed (YouTube / Vimeo) behind a poster image, driven by the 7 * providers' player APIs instead of raw postMessage. 8 * 9 * Flow (same on every device, no UA sniffing): 10 * 1. The iframe is pre-mounted paused when the widget scrolls near the 11 * viewport, so the player boots while the user is still reading. 12 * 2. Once the player reports ready, the poster stays painted on top but 13 * turns click-transparent â the tap lands on the player's own play 14 * button underneath. That gesture happens inside the iframe, which 15 * every browser honors (iOS refuses gestures forwarded from the 16 * parent page, so a programmatic play() there is not reliable). 17 * 3. The player's state events are the single source of truth: PLAYING / 18 * BUFFERING removes the poster, step changes pause via the API. 19 * 20 * Data-driven from the root element so this file stays static: 21 * data-embed-src - embed URL (enablejsapi/playsinline added server-side) 22 */ 23(() => { 24 let ytApi = null; 25 let vimeoApi = null; 26 27 const loadYouTubeApi = () => { 28 if (window.YT && window.YT.Player) return Promise.resolve(window.YT); 29 if (ytApi) return ytApi; 30 31 ytApi = new Promise((resolve) => { 32 const previous = window.onYouTubeIframeAPIReady; 33 window.onYouTubeIframeAPIReady = () => { 34 if (typeof previous === 'function') previous(); 35 resolve(window.YT); 36 }; 37 38 const script = document.createElement('script'); 39 script.src = 'https://www.youtube.com/iframe_api'; 40 script.async = true; 41 document.head.appendChild(script); 42 }); 43 44 return ytApi; 45 }; 46 47 const loadVimeoApi = () => { 48 if (window.Vimeo && window.Vimeo.Player) return Promise.resolve(window.Vimeo); 49 if (vimeoApi) return vimeoApi; 50 51 vimeoApi = new Promise((resolve, reject) => { 52 const script = document.createElement('script'); 53 script.src = 'https://player.vimeo.com/api/player.js'; 54 script.async = true; 55 script.onload = () => resolve(window.Vimeo); 56 script.onerror = reject; 57 document.head.appendChild(script); 58 }); 59 60 return vimeoApi; 61 }; 62 63 /** 64 * Both adapters expose the same surface: {play, pause, destroy}. 65 * handlers.onReady(adapter) fires once the player is interactive, 66 * handlers.onPlaying() whenever playback starts / resumes buffering. 67 */ 68 const createYouTubeAdapter = (iframe, handlers) => loadYouTubeApi().then((YT) => { 69 const player = new YT.Player(iframe, { 70 events: { 71 onReady: () => handlers.onReady(adapter), 72 onStateChange: (event) => { 73 if (event.data === YT.PlayerState.PLAYING 74 || event.data === YT.PlayerState.BUFFERING 75 ) { 76 handlers.onPlaying(); 77 } 78 } 79 } 80 }); 81 82 const adapter = { 83 play: () => player.playVideo(), 84 pause: () => player.pauseVideo(), 85 destroy: () => player.destroy() 86 }; 87 88 return adapter; 89 }); 90 91 const createVimeoAdapter = (iframe, handlers) => loadVimeoApi().then((Vimeo) => { 92 const player = new Vimeo.Player(iframe); 93 const adapter = { 94 play: () => player.play().catch(() => {}), 95 pause: () => player.pause().catch(() => {}), 96 destroy: () => player.destroy().catch(() => {}) 97 }; 98 99 player.on('play', () => handlers.onPlaying()); 100 player.ready().then(() => handlers.onReady(adapter)); 101 102 return adapter; 103 }); 104 105 function initIframeEmbed() { 106 return { 107 mounted: false, 108 ready: false, 109 playing: false, 110 pendingPlay: false, 111 _adapter: null, 112 _observer: null, 113 _wasVisible: false, 114 // Bumped on teardown so callbacks from a discarded player 115 // can't mutate the state of its replacement. 116 _generation: 0, 117 118 init() { 119 this._observer = new IntersectionObserver((entries) => { 120 const e = entries[0]; 121 if (!e) return; 122 123 // Pre-mount the paused player as the widget approaches the 124 // viewport so it is ready before the user can tap it. 125 if (e.isIntersecting && !this.mounted) { 126 this.mounted = true; 127 } 128 129 // display:none on an ancestor (wizard step switch, modal 130 // close) collapses our box to 0x0; scroll-out leaves it 131 // sized. A ready player is just paused so the user can 132 // resume on return; one still booting is torn down and 133 // remounted fresh next time.
134 const inLayout = e.boundingClientRect.width > 0 135 && e.boundingClientRect.height > 0; 136 137 if (this._wasVisible && !inLayout && this.mounted) { 138 if (this.ready) { 139 this._adapter && this._adapter.pause(); 140 } else { 141 this._teardown(); 142 } 143 } 144 145 this._wasVisible = inLayout; 146 }, { rootMargin: '200px' }); 147 this._observer.observe(this.$root); 148 149 // The observer above only fires when our box collapses to 0x0. 150 // A wizard step switch hides the step via x-show (display:none) 151 // but doesn't reliably trigger it, so pause on an explicit 152 // step-change signal too (modal close still goes via the observer). 153 this._onStepChange = () => { 154 if (this.ready && this._adapter) { 155 this._adapter.pause(); 156 } 157 }; 158 window.addEventListener('engraving:step-changed', this._onStepChange); 159 }, 160 161 destroy() { 162 if (this._observer) this._observer.disconnect(); 163 if (this._onStepChange) { 164 window.removeEventListener('engraving:step-changed', this._onStepChange); 165 } 166 this._teardown(); 167 }, 168 169 // Only reachable before the player is ready (afterwards the poster 170 // is click-transparent): mount if needed and remember the intent, 171 // the queued play() fires from onReady. 172 posterClick() { 173 this.pendingPlay = true; 174 175 if (!this.mounted) { 176 this.mounted = true; 177 } else if (this.ready && this._adapter) { 178 this._adapter.play(); 179 } 180 }, 181 182 onIframeLoad() { 183 const iframe = this.$root.querySelector('iframe'); 184 if (!iframe) return; 185 186 const generation = ++this._generation; 187 const src = iframe.src || ''; 188 const handlers = { 189 onReady: (adapter) => { 190 if (generation !== this._generation) return; 191 this._adapter = adapter; 192 this.ready = true; 193 194 if (this.pendingPlay) { 195 adapter.play(); 196 } 197 }, 198 onPlaying: () => { 199 if (generation !== this._generation) return; 200 this.playing = true; 201 this.pendingPlay = false; 202 } 203 }; 204 205 if (/youtube(-nocookie)?\.com/.test(src)) { 206 createYouTubeAdapter(iframe, handlers).catch(() => {}); 207 } else if (/vimeo\.com/.test(src)) { 208 createVimeoAdapter(iframe, handlers).catch(() => {}); 209 } 210 }, 211 212 embedSrc() { 213 // The player is pre-mounted, so a leftover autoplay param 214 // (admin-pasted, or HTML cached before autoplay was dropped 215 // server-side) would fire a blocked play attempt whose 216 // BUFFERING event reads as "playback started" and removes 217 // the poster prematurely. 218 const src = (this.$root.dataset.embedSrc || '') 219 .replace('?autoplay=1&', '?') 220 .replace('&autoplay=1', '') 221 .replace('?autoplay=1', ''); 222 223 // The widget API requires origin to match the embedding page, 224 // and it can only be known client-side. 225 if (/youtube(-nocookie)?\.com/.test(src)) { 226 return src 227 + (src.includes('?') ? '&' : '?') 228 + 'origin=' + encodeURIComponent(window.location.origin); 229 } 230 231 return src; 232 }, 233 234 _teardown() { 235 this._generation++; 236 237 if (this._adapter) { 238 try { 239 this._adapter.destroy(); 240 } catch { 241 // player already gone with its DOM node 242 } 243 this._adapter = null; 244 } 245 246 this.mounted = false;
247 this.ready = false; 248 this.playing = false; 249 this.pendingPlay = false; 250 } 251 }; 252 } 253 254 if (window.Alpine) { 255 Alpine.data('initIframeEmbed', initIframeEmbed); 256 } else { 257 window.addEventListener('alpine:init', () => { 258 Alpine.data('initIframeEmbed', initIframeEmbed); 259 }, { once: true }); 260 } 261})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.