PageSourceSearch

https://www.ziptrak.com.au/wp-content/themes/wp-theme-ziptrak/incl…/js/components/videos.service.js?ver=1.0.0

js ziptrak.com.au collected 2026-10-08 01:07:22 UTC 17,064 bytes, 609 lines download raw bytes

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.