1/** 2 * Video Service for Ziptrak Project 3 * Handles site-wide video detection, dynamic custom overlay creation, 4 * metadata extraction (title, duration, thumbnail), and playback control. 5 */ 6 7const VIDEOS_CONFIG = { 8 selectors: { 9 videoItem: '.zw-videos__item, [data-video-item]', 10 videoCover: '.zw-videos__cover, [data-video-cover]', 11 videoThumbnail: '.zw-videos__thumbnail', 12 videoPlayer: '.zw-videos__player, video', 13 videoTitle: '.zw-videos__title', 14 videoDuration: '.zw-videos__duration', 15 videoLink: '.zw-videos__link', 16 iframeWrapper: '.zw-videos__embed', 17 playButton: '.zw-videos__play', 18 coverVideoBackground: '.wp-block-cover__video-background', 19 tabletVideoFrame: '.tablet-video-frame' 20 }, 21 classes: { 22 item: 'zw-videos__item', 23 cover: 'zw-videos__cover', 24 thumbnail: 'zw-videos__thumbnail', 25 play: 'zw-videos__play', 26 info: 'zw-videos__info', 27 title: 'zw-videos__title', 28 duration: 'zw-videos__duration', 29 link: 'zw-videos__link', 30 linkYoutube: 'zw-videos__link--youtube', 31 linkVimeo: 'zw-videos__link--vimeo', 32 linkTwitter: 'zw-videos__link--twitter', 33 player: 'zw-videos__player', 34 embed: 'zw-videos__embed', 35 isPlaying: 'is-playing', 36 }, 37 patterns: { 38 youtube: /(?:youtube(?:-nocookie)?\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?|shorts)\/|.*[?&]v=)|youtu\.be\/)([\w-]{11})/i, 39 vimeo: /(?:vimeo\.com\/(?:channels\/(?:\w+\/)?|groups\/[^\/]+\/videos\/|album\/(?:\d+)\/video\/|video\/|)|player\.vimeo\.com\/video\/)(\d+)/i, 40 twitter: /(?:twitter\.com|x\.com)\/[a-zA-Z0-9_]+\/status\/(\d+)/i, 41 }, 42}; 43 44class ZiptrakVideosService { 45 constructor() { 46 this.bindEvents(); 47 } 48 49 /** 50 * Public initialization method 51 * @param {HTMLElement|Document} container 52 */ 53 init(container = document) { 54 this.wrapStandaloneElements(container); 55 this.setupVideoItems(container); 56 } 57 58 /** 59 * Automatically wraps standalone <video> or YouTube/Vimeo <iframe> elements 60 * if they are not already inside a video item container. 61 * @param {HTMLElement|Document} container 62 */ 63 wrapStandaloneElements(container = document) { 64 // 1. Standalone <video> 65 const videos = Array.from(container.querySelectorAll('video')).filter((el) => this._isStandalone(el)); 66 67 videos.forEach((video) => { 68 video.removeAttribute('controls'); 69 video.classList.add(VIDEOS_CONFIG.classes.player); 70 this._wrapElement(video); 71 }); 72 73 // 2.1 Standalone <iframe> (YouTube / Vimeo) 74 const iframes = Array.from(container.querySelectorAll('iframe')).filter((iframe) => { 75 if (!this._isStandalone(iframe)) { 76 return false; 77 } 78 79 return this._isYouTube(iframe.src) || this._isVimeo(iframe.src); 80 }); 81 82 // 2.2 Standalone Twitter blockquotes 83 const tweets = Array.from(container.querySelectorAll('blockquote.twitter-tweet')).filter((el) => this._isStandalone(el)); 84 85 [...iframes, ...tweets].forEach((iframe) => { 86 const embedWrapper = document.createElement('div'); 87 embedWrapper.className = VIDEOS_CONFIG.classes.embed; 88 89 iframe.parentNode.insertBefore(embedWrapper, iframe); 90 embedWrapper.appendChild(iframe); 91 92 this._wrapElement(embedWrapper); 93 }); 94 } 95 96 /** 97 * Check if element should be wrapped with custom video item 98 * @param {HTMLElement} element 99 * @returns {boolean} 100 */ 101 _isStandalone(element) { 102 return ( 103 !element.closest(VIDEOS_CONFIG.selectors.tabletVideoFrame) && 104 !element.closest(VIDEOS_CONFIG.selectors.videoItem) && 105 !element.closest(VIDEOS_CONFIG.selectors.coverVideoBackground) && 106 !element.hasAttribute('data-no-overlay') 107 ); 108 } 109 110 /** 111 * Helper to wrap element into standard video item container 112 * @param {HTMLElement} element 113 * @returns {HTMLElement} 114 */ 115 _wrapElement(element) { 116 const itemWrapper = document.createElement('div'); 117 itemWrapper.className = VIDEOS_CONFIG.classes.item; 118 itemWrapper.setAttribute('data-video-item', ''); 119 120 element.parentNode.insertBefore(itemWrapper, element); 121 itemWrapper.appendChild(element); 122 123 return itemWrapper; 124 } 125 126 /** 127 * Setup overlays and metadata for all video items 128 * @param {HTMLElement|Document} container 129 */ 130 setupVideoItems(container = document) { 131 const items = Array.from(container.querySelectorAll(VIDEOS_CONFIG.selectors.videoItem)); 132
133 items.forEach((videoItem) => { 134 if (videoItem.dataset.videoInitialized === 'true') { 135 return; 136 } 137 138 videoItem.dataset.videoInitialized = 'true'; 139 this.buildCoverOverlay(videoItem); 140 }); 141 } 142 143 /** 144 * Build Custom Overlay structure and delegate metadata processing 145 * @param {HTMLElement} videoItem 146 */ 147 buildCoverOverlay(videoItem) { 148 if (videoItem.querySelector(VIDEOS_CONFIG.selectors.videoCover)) { 149 return; 150 } 151 152 const localVideo = videoItem.querySelector('video'); 153 const iframe = videoItem.querySelector('iframe'); 154 const twitterQuote = videoItem.querySelector('blockquote.twitter-tweet'); 155 156 const targetEl = iframe || localVideo || twitterQuote; 157 158 const twitterUrl = twitterQuote ? Array.from(twitterQuote.querySelectorAll('a')).pop()?.href : ''; 159 const videoUrl = iframe?.src || localVideo?.currentSrc || localVideo?.src || twitterUrl || ''; 160 161 if (localVideo) { 162 localVideo.removeAttribute('controls'); 163 } 164 165 // Create DOM structure for overlay without mutating content here 166 const elements = this._createOverlayElements(videoItem); 167 168 // Collect custom user dataset attributes 169 const customData = { 170 title: targetEl?.dataset?.title || videoItem?.dataset?.title || '', 171 duration: targetEl?.dataset?.duration || videoItem?.dataset?.duration || '', 172 thumbnail: targetEl?.dataset?.thumbnail || videoItem?.dataset?.thumbnail || '', 173 }; 174 175 // Delegate to target processors (DOM updates happen inside them) 176 if (localVideo) { 177 this._setupLocalVideoMetadata(localVideo, videoUrl, customData, elements); 178 } else { 179 this._setupEmbedVideoMetadata(videoUrl, iframe || twitterQuote, customData, elements); 180 } 181 } 182 183 /** 184 * Create DOM elements structure for cover overlay 185 * @param {HTMLElement} videoItem 186 * @returns {Object} Elements references 187 * @private 188 */ 189 _createOverlayElements(videoItem) { 190 const cover = document.createElement('div'); 191 cover.className = VIDEOS_CONFIG.classes.cover; 192 cover.setAttribute('data-video-cover', ''); 193 194 const playBtn = document.createElement('div'); 195 playBtn.className = VIDEOS_CONFIG.classes.play; 196 playBtn.setAttribute('aria-label', 'Play Video'); 197 cover.appendChild(playBtn); 198 199 const info = document.createElement('div'); 200 info.className = VIDEOS_CONFIG.classes.info; 201 202 const titleEl = document.createElement('span'); 203 titleEl.className = VIDEOS_CONFIG.classes.title; 204 205 const durationEl = document.createElement('span'); 206 durationEl.className = VIDEOS_CONFIG.classes.duration; 207 208 info.appendChild(titleEl); 209 info.appendChild(durationEl); 210 cover.appendChild(info); 211 videoItem.prepend(cover); 212 213 return {cover, info, titleEl, durationEl}; 214 } 215 216 /** 217 * Helper to set or update thumbnail image 218 * @param {HTMLElement} cover 219 * @param {string} url 220 * @param {string} [alt=''] 221 * @private 222 */ 223 _setThumbnail(cover, url, alt = '') { 224 if (!url) { 225 return; 226 } 227 228 let thumbImg = cover.querySelector(`.${VIDEOS_CONFIG.classes.videoThumbnail}`); 229 230 if (thumbImg) { 231 thumbImg.src = url; 232 233 if (alt) { 234 thumbImg.alt = alt; 235 } 236 } else { 237 thumbImg = document.createElement('img'); 238 thumbImg.className = VIDEOS_CONFIG.classes.thumbnail; 239 thumbImg.src = url; 240 thumbImg.alt = alt; 241 thumbImg.loading = 'lazy'; 242 cover.prepend(thumbImg); 243 } 244 } 245 246 /** 247 * Helper to inject external platform link (YouTube / Vimeo) 248 * @param {HTMLElement} info 249 * @param {HTMLElement} durationEl 250 * @param {string} href 251 * @param {string} typeClass 252 * @param {string} label 253 * @private 254 */ 255 _injectExternalLink(info, durationEl, href, typeClass, label) { 256 if (!href || info.querySelector(VIDEOS_CONFIG.selectors.videoLink)) { 257 return; 258 } 259 260 const linkEl = document.createElement('a'); 261 linkEl.className = `${VIDEOS_CONFIG.classes.link} ${typeClass}`; 262 linkEl.href = href; 263 linkEl.target = '_blank'; 264 linkEl.rel = 'noopener noreferrer'; 265 linkEl.setAttribute('aria-label', label); 266 linkEl.addEventListener('click', (e) => e.stopPropagation()); 267 268 info.insertBefore(linkEl, durationEl); 269 } 270 271 /** 272 * Setup metadata for native HTML5 video 273 * @param {HTMLVideoElement} localVideo 274 * @param {string} videoUrl 275 * @param {Object} customData 276 * @param {Object} elements 277 * @private 278 */ 279 _setupLocalVideoMetadata(localVideo, videoUrl, customData, {titleEl, durationEl}) { 280 // 1. Set Title 281 if (customData.title) { 282 titleEl.textContent = customData.title; 283 } 284 285 // 2. Set Duration 286 const updateDuration = () => { 287 if (localVideo.duration && isFinite(localVideo.duration)) { 288 durationEl.textContent = this.formatDuration(localVideo.duration); 289 } 290 }; 291 292 if (localVideo.readyState >= 1) { 293 updateDuration(); 294 } else { 295 localVideo.addEventListener('loadedmetadata', updateDuration, {once: true}); 296 } 297 } 298
299 /** 300 * Setup metadata & external links for embedded video players 301 * @param {string} videoUrl 302 * @param {HTMLIFrameElement} targetEl 303 * @param {Object} customData 304 * @param {Object} elements 305 * @private 306 */ 307 _setupEmbedVideoMetadata(videoUrl, targetEl, customData, elements) { 308 const ytId = this._getVideoId(videoUrl, 'youtube'); 309 const vimeoId = this._getVideoId(videoUrl, 'vimeo'); 310 const twitterId = this._getVideoId(videoUrl, 'twitter'); 311 312 if (ytId) { 313 this._processYouTubeMetadata(ytId, targetEl, customData, elements); 314 } else if (vimeoId) { 315 this._processVimeoMetadata(vimeoId, customData, elements); 316 } else if (twitterId) { 317 this._processTwitterMetadata(twitterId, videoUrl, customData, elements); 318 } 319 } 320 321 /** 322 * Process YouTube platform logic & oEmbed fetch 323 * @param {string} ytId 324 * @param {HTMLIFrameElement} iframe 325 * @param {Object} customData 326 * @param {Object} elements 327 * @private 328 */ 329 _processYouTubeMetadata(ytId, iframe, customData, {cover, info, titleEl, durationEl}) { 330 const ytUrl = `https://www.youtube.com/watch?v=${ytId}`; 331 332 // 1. Set Thumbnail 333 this._setThumbnail(cover, customData.thumbnail || `https://img.youtube.com/vi/${ytId}/hqdefault.jpg`); 334 335 // 2. Inject External Link 336 this._injectExternalLink(info, durationEl, ytUrl, VIDEOS_CONFIG.classes.linkYoutube, 'Watch on YouTube'); 337 338 // 3. Set Duration 339 if (customData.duration) { 340 durationEl.textContent = customData.duration; 341 } 342 343 // 4. Set Title 344 if (customData.title) { 345 titleEl.textContent = customData.title; 346 } else if (iframe?.title) { 347 titleEl.textContent = iframe.title; 348 } else { 349 fetch(`https://www.youtube.com/oembed?url=${encodeURIComponent(ytUrl)}&format=json`) 350 .then((res) => (res.ok ? res.json() : null)) 351 .then((data) => { 352 if (data?.title) { 353 titleEl.textContent = data.title; 354 } 355 }) 356 .catch(() => { 357 }); 358 } 359 } 360 361 /** 362 * Process Vimeo platform logic & oEmbed fetch 363 * @param {string} vimeoId 364 * @param {Object} customData 365 * @param {Object} elements 366 * @private 367 */ 368 _processVimeoMetadata(vimeoId, customData, {cover, info, titleEl, durationEl}) { 369 const vimeoUrl = `https://vimeo.com/${vimeoId}`; 370 371 // 1. Set Thumbnail 372 this._setThumbnail(cover, customData.thumbnail || `https://vumbnail.com/${vimeoId}.jpg`); 373 374 // 2. Inject External Link 375 this._injectExternalLink(info, durationEl, vimeoUrl, VIDEOS_CONFIG.classes.linkVimeo, 'Watch on Vimeo'); 376 377 // 3. Set Duration 378 if (customData.duration) { 379 durationEl.textContent = customData.duration; 380 } 381 382 // 4. Set Title 383 if (customData.title) { 384 titleEl.textContent = customData.title; 385 } 386 387 // 4. Fetch missing values via oEmbed 388 if (!customData.title || !customData.duration || !customData.thumbnail) { 389 fetch(`https://vimeo.com/api/oembed.json?url=${encodeURIComponent(vimeoUrl)}`) 390 .then((res) => (res.ok ? res.json() : null)) 391 .then((data) => { 392 if (!data) { 393 return; 394 } 395 396 if (!customData.title && data.title) { 397 titleEl.textContent = data.title; 398 } 399 400 if (!customData.duration && data.duration) { 401 durationEl.textContent = this.formatDuration(data.duration); 402 } 403 404 if (!customData.thumbnail && data.thumbnail_url) { 405 this._setThumbnail(cover, data.thumbnail_url, data.title); 406 } 407 }) 408 .catch(() => { 409 }); 410 } 411 } 412 413 /** 414 * Process Twitter platform logic 415 */ 416 _processTwitterMetadata(twitterId, tweetUrl, customData, {cover, info, titleEl, durationEl}) { 417 // 1. Set Thumbnail 418 this._setThumbnail(cover, customData.thumbnail); 419 420 // 2. Inject External Link 421 this._injectExternalLink(info, durationEl, tweetUrl, VIDEOS_CONFIG.classes.linkTwitter, 'Watch on X/Twitter'); 422 423 // 3. Set Duration 424 if (customData.duration) { 425 durationEl.textContent = customData.duration; 426 } 427 428 // 4. Set Title via oEmbed API 429 if (customData.title) { 430 titleEl.textContent = customData.title; 431 } else { 432 fetch(`https://publish.twitter.com/oembed?url=${encodeURIComponent(tweetUrl)}`) 433 .then((res) => (res.ok ? res.json() : null)) 434 .then((data) => { 435 if (data?.author_name) { 436 titleEl.textContent = `${data.author_name}`; 437 } 438 }) 439 .catch(() => { 440 titleEl.textContent = 'Twitter / X Video'; 441 }); 442 } 443 } 444
445 /** 446 * Helper to format seconds into MM:SS or H:MM:SS 447 * @param {number} seconds 448 * @returns {string} 449 */ 450 formatDuration(seconds) { 451 if (!seconds || isNaN(seconds) || !isFinite(seconds)) { 452 return ''; 453 } 454 455 const totalSecs = Math.floor(seconds); 456 const hours = Math.floor(totalSecs / 3600); 457 const mins = Math.floor((totalSecs % 3600) / 60); 458 const remSecs = totalSecs % 60; 459 460 if (hours > 0) { 461 return `${hours}:${mins.toString().padStart(2, '0')}:${remSecs.toString().padStart(2, '0')}`; 462 } 463 464 return `${mins}:${remSecs.toString().padStart(2, '0')}`; 465 } 466 467 /** 468 * Attach global click listeners via Event Delegation 469 */ 470 bindEvents() { 471 document.addEventListener('click', (event) => { 472 const videoCover = event.target.closest(VIDEOS_CONFIG.selectors.videoCover); 473 474 if (videoCover) { 475 const videoItem = videoCover.closest(VIDEOS_CONFIG.selectors.videoItem); 476 477 if (videoItem) { 478 this.playVideo(videoItem); 479 } 480 } 481 }); 482 } 483 484 /** 485 * Activate video player and hide overlay cover 486 * @param {HTMLElement} videoItem 487 */ 488 playVideo(videoItem) { 489 if (!videoItem) { 490 return; 491 } 492 493 videoItem.classList.add(VIDEOS_CONFIG.classes.isPlaying); 494 495 // 1. Handle HTML5 Local MP4 Player 496 const localVideo = videoItem.querySelector('video'); 497 if (localVideo) { 498 this.playLocalVideo(localVideo); 499 return; 500 } 501 502 // 2. Handle Twitter/X 503 if (videoItem.querySelector('blockquote.twitter-tweet')) { 504 return; 505 } 506 507 // 2. Handle YouTube / Vimeo / oEmbed iFrames 508 const iframe = videoItem.querySelector(`${VIDEOS_CONFIG.selectors.iframeWrapper} iframe, iframe`); 509 if (iframe) { 510 this.playIframeVideo(iframe); 511 } 512 } 513 514 /** 515 * Handle native HTML5 video playback 516 * @param {HTMLVideoElement} video 517 */ 518 playLocalVideo(video) { 519 video.setAttribute('controls', 'true'); 520 video.play().catch((err) => console.warn('Autoplay failed:', err)); 521 } 522 523 /** 524 * Handle iframe playback (YouTube / Vimeo) 525 * @param {HTMLIFrameElement} iframe 526 */ 527 playIframeVideo(iframe) { 528 this.ensureAutoplayPermissions(iframe); 529 530 const src = iframe.getAttribute('src') || ''; 531 532 if (src && !src.includes('autoplay=1')) { 533 const separator = src.includes('?') ? '&' : '?'; 534 const params = this._isYouTube(src) ? 'autoplay=1&enablejsapi=1' : 'autoplay=1'; 535 iframe.setAttribute('src', `${src}${separator}${params}`); 536 } 537 538 iframe.focus(); 539 } 540 541 /** 542 * Ensure allow attribute contains autoplay permissions 543 * @param {HTMLIFrameElement} iframe 544 */ 545 ensureAutoplayPermissions(iframe) { 546 const allow = iframe.getAttribute('allow') || ''; 547 548 if (!allow.includes('autoplay')) { 549 const permissions = 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture'; 550 iframe.setAttribute('allow', allow ? `${allow}; ${permissions}` : permissions); 551 } 552 } 553 554 /** 555 * Extract Video ID from URL for YouTube or Vimeo 556 * @param {string} url 557 * @param {'youtube'|'vimeo'} [platform] 558 * @returns {string|null} 559 */ 560 _getVideoId(url, platform) { 561 if (!url) return null; 562 563 const platforms = platform ? [platform] : ['youtube', 'vimeo', 'twitter']; 564 565 for (const key of platforms) { 566 const pattern = VIDEOS_CONFIG.patterns[key]; 567 const match = pattern ? url.match(pattern) : null; 568 569 if (match?.[1]) { 570 return match[1]; 571 } 572 } 573 574 return null; 575 } 576 577 /** 578 * Check if URL is YouTube 579 * @param {string} url 580 * @returns {boolean} 581 */ 582 _isYouTube(url) { 583 return Boolean(this._getVideoId(url, 'youtube')); 584 } 585 586 /** 587 * Check if URL is Vimeo 588 * @param {string} url 589 * @returns {boolean} 590 */ 591 _isVimeo(url) { 592 return Boolean(this._getVideoId(url, 'vimeo')); 593 } 594 595 /** 596 * Check if URL is Twitter 597 * @param {string} url 598 * @returns {boolean} 599 */ 600 _isTwitter(url) { 601 return Boolean(this._getVideoId(url, 'twitter')); 602 } 603} 604 605// Global initialization 606document.addEventListener('DOMContentLoaded', () => { 607 const videoService = new ZiptrakVideosService(); 608 videoService.init(); 609});
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.