PageSourceSearch

https://opensearch.org/wp-content/themes/salient-child/vc-addons/js/youtube-playlist.js?ver=1.0.0

js opensearch.org collected 2026-09-24 08:04:56 UTC 18,001 bytes, 449 lines download raw bytes

1/**
2 * LF YOUTUBE PLAYLIST — CLIENT-SIDE FETCH + RENDER + PAGINATION + MODAL
3 *
4 * READS data-* ATTRIBUTES FROM THE SKELETON DIV RENDERED BY PHP.
5 * FETCHES ALL PLAYLIST VIDEOS FROM THE YOUTUBE DATA API v3 IN THE BROWSER
6 * SO THE HTTP REFERER HEADER IS PRESENT AND API KEY RESTRICTIONS WORK.
7 *
8 * GRID:       USES lf-grid / lf-grid--3 FROM linux-foundation-components.css.
9 * PAGINATION: MATCHES lf-pagination / paginate_links() HTML STRUCTURE.
10 * CACHE:      2-HOUR LOCALSTORAGE CACHE KEYED BY PLAYLIST ID + CACHE BUST.
11 */
12(function () {
13    'use strict';
14
15    var CACHE_TTL = 2 * 60 * 60 * 1000; // 2 HOURS IN MILLISECONDS.
16    var YT_API = 'https://www.googleapis.com/youtube/v3/';
17
18    // ----------------------------------------------------------------
19    // INIT
20    // ----------------------------------------------------------------
21
22    document.addEventListener('DOMContentLoaded', function () {
23
24        // REMOVE DUPLICATE MODALS IF SHORTCODE APPEARS MULTIPLE TIMES.
25        var modals = document.querySelectorAll('.lf-ytp-modal');
26        for (var m = 1; m < modals.length; m++) {
27            modals[m].parentNode.removeChild(modals[m]);
28        }
29
30        // INIT EACH GRID.
31        var grids = document.querySelectorAll('.lf-ytp[data-playlist-id]');
32        grids.forEach(function (grid) {
33            initGrid(grid);
34        });
35
36        initModal();
37    });
38
39    // ----------------------------------------------------------------
40    // GRID INIT
41    // ----------------------------------------------------------------
42
43    function initGrid(grid) {
44        var apiKey = grid.dataset.apiKey || '';
45        var playlistId = grid.dataset.playlistId || '';
46        var perPage = parseInt(grid.dataset.count, 10) || 9;
47        var cacheBust = grid.dataset.cacheBust || '1';
48
49        if (!apiKey || !playlistId) return;
50
51        var cacheKey = 'lf_ytp_' + playlistId + '_' + cacheBust;
52        var cached = getCache(cacheKey);
53
54        if (cached) {
55            renderGrid(grid, cached, perPage);
56            return;
57        }
58
59        fetchAllVideos(apiKey, playlistId)
60            .then(function (videos) {
61                setCache(cacheKey, videos);
62                renderGrid(grid, videos, perPage);
63            })
64            .catch(function (err) {
65                grid.innerHTML = '<div class="lf-ytp-error"><strong>YouTube Playlist:</strong> ' + escHtml(err.message || 'Failed to load videos.') + '</div>';
66            });
67    }
68
69    // ----------------------------------------------------------------
70    // YOUTUBE API — FETCH ALL VIDEOS CHAINING nextPageToken
71    // ----------------------------------------------------------------
72
73    function fetchAllVideos(apiKey, playlistId) {
74        var allItems = [];
75
76        function fetchPage(pageToken) {
77            var url = YT_API + 'playlistItems'
78                + '?part=snippet'
79                + '&playlistId=' + encodeURIComponent(playlistId)
80                + '&maxResults=50'
81                + '&key=' + encodeURIComponent(apiKey)
82                + (pageToken ? '&pageToken=' + encodeURIComponent(pageToken) : '');
83
84            return fetch(url)
85                .then(function (res) { return res.json(); })
86                .then(function (data) {
87                    if (data.error) {
88                        throw new Error(data.error.message || 'YouTube API error.');
89                    }
90                    if (data.items) {
91                        allItems = allItems.concat(data.items);
92                    }
93                    if (data.nextPageToken) {
94                        return fetchPage(data.nextPageToken);
95                    }
96                    return allItems;
97                });
98        }
99
100        return fetchPage(null).then(function (items) {
101            if (!items.length) {
102                throw new Error('No videos found in this playlist.');
103            }
104
105            // COLLECT ALL VIDEO IDS FOR STATISTICS CALL.
106            var videoIds = items.map(function (item) {
107                return (item.snippet.resourceId || {}).videoId || '';
108            }
108).filter(Boolean);
109
110            // FETCH STATS IN BATCHES OF 50.
111            return fetchAllStats(apiKey, videoIds).then(function (statsById) {
112                items.forEach(function (item) {
113                    var vid = (item.snippet.resourceId || {}).videoId || '';
114                    item.statistics = statsById[vid] || {};
115                });
116                return items;
117            });
118        });
119    }
120
121    function fetchAllStats(apiKey, videoIds) {
122        var statsById = {};
123        var batches = [];
124
125        for (var i = 0; i < videoIds.length; i += 50) {
126            batches.push(videoIds.slice(i, i + 50));
127        }
128
129        var promises = batches.map(function (batch) {
130            var url = YT_API + 'videos'
131                + '?part=statistics'
132                + '&id=' + encodeURIComponent(batch.join(','))
133                + '&key=' + encodeURIComponent(apiKey);
134
135            return fetch(url)
136                .then(function (res) { return res.json(); })
137                .then(function (data) {
138                    if (data.items) {
139                        data.items.forEach(function (s) {
140                            statsById[s.id] = s.statistics || {};
141                        });
142                    }
143                });
144        });
145
146        return Promise.all(promises).then(function () { return statsById; });
147    }
148
149    // ----------------------------------------------------------------
150    // RENDER GRID + PAGINATION
151    // ----------------------------------------------------------------
152
153    function renderGrid(grid, videos, perPage) {
154        var total = videos.length;
155        var totalPages = Math.ceil(total / perPage);
156        var html = '';
157
158        // GRID CARDS — ALL RENDERED, NON-PAGE-0 GET HIDDEN CLASS.
159        html += '<div class="lf-grid lf-grid--3">';
160        videos.forEach(function (item, index) {
161            var cardPage = Math.floor(index / perPage);
162            html += buildCard(item, index, cardPage);
163        });
164        html += '</div>';
165
166        // PAGINATION.
167        if (totalPages > 1) {
168            html += '<div class="lf-pagination lf-ytp__pagination" data-total-pages="' + totalPages + '">';
169            html += buildPaginationHtml(0, totalPages);
170            html += '</div>';
171        }
172
173        grid.innerHTML = html;
174
175        // STORE DATA ON GRID FOR PAGINATION HANDLER.
176        grid.dataset.totalPages = totalPages;
177        grid.dataset.perPage = perPage;
178
179        // BIND PAGINATION.
180        var pagination = grid.querySelector('.lf-ytp__pagination');
181        if (pagination) {
182            bindPagination(grid, pagination, totalPages);
183        }
184    }
185
186    // ----------------------------------------------------------------
187    // BUILD CARD HTML
188    // ----------------------------------------------------------------
189
190    function buildCard(item, index, cardPage) {
191        var snippet = item.snippet || {};
192        var statistics = item.statistics || {};
193        var videoId = (snippet.resourceId || {}).videoId || '';
194        var titleRaw = snippet.title || '';
195        var published = snippet.publishedAt || '';
196        var thumbs = snippet.thumbnails || {};
197
198        // BEST AVAILABLE THUMBNAIL.
199        var thumbUrl = '';
200        var sizes = ['maxres', 'standard', 'high', 'medium', 'default'];
201        for (var i = 0; i < sizes.length; i++) {
202            if (thumbs[sizes[i]] && thumbs[sizes[i]].url) {
203                thumbUrl = thumbs[sizes[i]].url;
204                break;
205            }
206        }
207
208        // VIEW COUNT.
209        var viewsLabel = '';
210        if (statistics.viewCount !== undefined) {
211            viewsLabel = parseInt(statistics.viewCount, 10).toLocaleString() + ' views';
212        }
213
214        var timeSince = published ? timeSinceDate(published) : '';
215        var watchUrl = 'https://www.youtube.com/watch?v=' + encodeURIComponent(videoId);
216        var embedUrl = 'https://www.youtube.com/embed/' + encodeURIComponent(videoId) + '?autoplay=1&rel=0';
217        var hidden = cardPage > 0 ? ' lf-ytp__card--hidden' : '';
218        var loading = index === 0 ? 'eager' : 'lazy';
219
220        var thumbHtml = thumbUrl
221            ? '<img class="lf-ytp__thumb" src="' + escAttr(thumbUrl) + '" alt="' + escAttr(titleRaw) + '" loading="' + loading + '" width="480" height="270" />'
222            : '<div class="lf-ytp__thumb lf-ytp__thumb--placeholder"></div>';
223
224        var html = '';
225        html += '<article class="lf-ytp__card' + hidden + '" data-page="' + cardPage + '">';
226        html += '<div class="lf-ytp__thumb-wrap">';
227        html += thumbHtml;
228        html += '<button class="lf-ytp__play-btn" aria-label="Play ' + escAttr(titleRaw) + '" data-embed-url="' + escAttr(embedUrl) + '" data-title="' + escAttr(titleRaw) + '">';
229        html += '<svg class="lf-ytp__play-icon" viewBox="0 0 68 48" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">';
230        html += '<path class="lf-ytp__play-bg" d="M66.52 7.74c-.78-2.93-2.49-5.41-5.42-6.19C55.79.13 34 0 34 0S12.21.13 6.9 1.55c-2.93.78-4.63 3.26-5.42 6.19C.06 13.05 0 24 0 24s.06 10.95 1.48 16.26c.78 2.93 2.49 5.41 5.42 6.19C12.21 47.87 34 48 34 48s21.79-.13 27.1-1.55c2.93-.78 4.64-3.26 5.42-6.19C67.94 34.95 68 24 68 24s-.06-10.95-1.48-16.26z"/>';
231        html += '<path class="lf-ytp__play-arrow" d="M45 24 27 14v20z"/>';
232        html += '</svg>';
233        html += '</button>';
234        html += '</div>';
235        html += '<div class="lf-ytp__body">';
236        html += '<h3 class="lf-ytp__title"><a href="' + escAttr(watchUrl) + '" target="_blank" rel="noopener noreferrer">' + escHtml(titleRaw) + '</a></h3>';
237        html += '<div class="lf-ytp__meta">';
238        if (viewsLabel) {
239            html += '<span class="lf-ytp__meta-views">' + escHtml(viewsLabel) + '</span>';
240        }
241        if (timeSince) {
242            html += '<time class="lf-ytp__meta-date" datetime="' + escAttr(published) + '">' + escHtml(timeSince) + '</time>';
243        }
244        html += '</div>';
245        html += '</div>';
246        html += '</article>';
247
248        return html;
249    }
250
251    // ----------------------------------------------------------------
252    // PAGINATION
253    // ----------------------------------------------------------------
254
255    function bindPagination(grid, pagination, totalPages) {
256        pagination.addEventListener('click', function (e) {
257            var link = e.target.closest('a.page-numbers');
258            if (!link) return;
259            e.preventDefault();
260            var page = parseInt(link.dataset.page, 10);
261            if (isNaN(page)) return;
262
263            // SHOW/HIDE CARDS.
264            var cards = grid.querySelectorAll('.lf-ytp__card');
265            cards.forEach(function (card) {
266                if (parseInt(card.dataset.page, 10) === page) {
267                    card.classList.remove('lf-ytp__card--hidden');
268                } else {
269                    card.classList.add('lf-ytp__card--hidden');
270                }
271            });
272
273            // REBUILD PAGINATION HTML.
274            pagination.innerHTML = buildPaginationHtml(page, totalPages);
275
276            // SCROLL WITH OFFSET.
277            var offset = grid.getBoundingClientRect().top + window.pageYOffset - 250;
278            window.scrollTo({ top: offset, behavior: 'smooth' });
279        });
280    }
281
282    function buildPaginationHtml(current, total) {
283        var html = '';
284
285        // PREV.
286        if (current > 0) {
287            html += '<a class="prev page-numbers" href="#" data-page="' + (current - 1) + '">&larr; Prev</a>';
288        }
289
290        // PAGE NUMBERS WITH ELLIPSIS.
291        for (var i = 0; i < total; i++) {
292            var show = (i === 0 || i === total - 1 || Math.abs(i - current) <= 1);
293
294            if (!show) {
295                if (i === 1 && current > 3) {
296                    html += '<span class="page-numbers dots">&hellip;</span>';
297                } else if (i === total - 2 && current < total - 4) {
298                    html += '<span class="page-numbers dots">&hellip;</span>';
299                }
300                continue;
301            }
302
303            if (i === current) {
304                html += '<span aria-current="page" class="page-numbers current">' + (i + 1) + '</span>';
305            } else {
306                html += '<a class="page-numbers" href="#" data-page="' + i + '">' + (i + 1) + '</a>';
307            }
308        }
309
310        // NEXT.
311        if (current < total - 1) {
312            html += '<a class="next page-numbers" href="#" data-page="' + (current + 1) + '">Next &rarr;</a>';
313        }
314
315        return html;
316    }
317
318    // ----------------------------------------------------------------
319    // MODAL
320    // ----------------------------------------------------------------
321
322    function initModal() {
323        var modal = document.getElementById('lf-ytp-modal');
324        var iframe = modal ? modal.querySelector('.lf-ytp-modal__iframe') : null;
325        var titleEl = modal ? modal.querySelector('.lf-ytp-modal__title') : null;
326        var lastOpener = null;
327
328        if (!modal) return;
329
330        function openModal(embedUrl, title, opener) {
331            lastOpener = opener || null;
332            iframe.src = embedUrl;
333            iframe.title = title || '';
334            if (titleEl) titleEl.textContent = title || '';
335            modal.removeAttribute('hidden');
336            document.body.style.overflow = 'hidden';
337            var closeBtn = modal.querySelector('.lf-ytp-modal__close');
338            if (closeBtn) closeBtn.focus();
339        }
340
341        function closeModal() {
342            modal.setAttribute('hidden', '');
343            document.body.style.overflow = '';
344            iframe.src = '';
345            iframe.title = '';
346            if (titleEl) titleEl.textContent = '';
347            if (lastOpener) {
348                lastOpener.focus();
349                lastOpener = null;
350            }
351        }
352
353        document.addEventListener('click', function (e) {
354            var playBtn = e.target.closest('.lf-ytp__play-btn');
355            if (playBtn) {
356                e.preventDefault();
357                openModal(playBtn.dataset.embedUrl || '', playBtn.dataset.title || '', playBtn);
358                return;
359            }
360            if (e.target.closest('.lf-ytp-modal__close')) {
361                closeModal();
362                return;
363            }
364            if (e.target.closest('.lf-ytp-modal__backdrop')) {
365                closeModal();
366            }
367        });
368
369        document.addEventListener('keydown', function (e) {
370            if (modal.hasAttribute('hidden')) return;
371            if (e.key === 'Escape' || e.key === 'Esc') {
372                closeModal();
373                return;
374            }
375            if (e.key === 'Tab') {
376                var focusable = Array.from(modal.querySelectorAll(
377                    'button:not([disabled]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"]), iframe'
378                ));
379                var first = focusable[0];
380                var last = focusable[focusable.length - 1];
381                if (e.shiftKey && document.activeElement === first) {
382                    e.preventDefault();
383                    last.focus();
384                } else if (!e.shiftKey && document.activeElement === last) {
385                    e.preventDefault();
386                    first.focus();
387                }
388            }
389        });
390    }
391
392    // ----------------------------------------------------------------
393    // LOCALSTORAGE CACHE
394    // ----------------------------------------------------------------
395
396    function getCache(key) {
397        try {
398            var raw = localStorage.getItem(key);
399            if (!raw) return null;
400            var entry = JSON.parse(raw);
401            if (Date.now() > entry.expires) {
402                localStorage.removeItem(key);
403                return null;
404            }
405            return entry.data;
406        } catch (e) {
407            return null;
408        }
409    }
410
411    function setCache(key, data) {
412        try {
413            localStorage.setItem(key, JSON.stringify({
414                expires: Date.now() + CACHE_TTL,
415                data: data,
416            }));
417        } catch (e) {
418            // LOCALSTORAGE UNAVAILABLE OR FULL — SILENTLY SKIP.
419        }
420    }
421
422    // ----------------------------------------------------------------
423    // HELPERS
424    // ----------------------------------------------------------------
425
426    function timeSinceDate(isoDate) {
427        var diff = Math.floor((Date.now() - new Date(isoDate).getTime()) / 1000);
428        var m = 60, h = m * 60, d = h * 24, w = d * 7, mo = d * 30, y = d * 365;
429        if (diff < m) return 'Just now';
430        if (diff < h) { var n = Math.floor(diff / m); return n + ' minute' + (n > 1 ? 's' : '') + ' ago'; }
431        if (diff < d) { var n = Math.floor(diff / h); return n + ' hour' + (n > 1 ? 's' : '') + ' ago'; }
432        if (diff < w) { var n = Math.floor(diff / d); return n + ' day' + (n > 1 ? 's' : '') + ' ago'; }
433        if (diff < mo) { var n = Math.floor(diff / w); return n + ' week' + (n > 1 ? 's' : '') + ' ago'; }
434        if (diff < y) { var n = Math.floor(diff / mo); return n + ' month' + (n > 1 ? 's' : '') + ' ago'; }
435        var n = Math.floor(diff / y); return n + ' year' + (n > 1 ? 's' : '') + ' ago';
436    }
437
438    function escHtml(str) {
439        return String(str)
440            .replace(/&/g, '&amp;')
441            .replace(/</g, '&lt;')
442            .replace(/>/g, '&gt;')
443            .replace(/"/g, '&quot;')
444            .replace(/'/g, '&#039;');
445    }
446
447    function escAttr(str) { return escHtml(str); }
448
449})();

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.