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) + '">← 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">…</span>'; 297 } else if (i === total - 2 && current < total - 4) { 298 html += '<span class="page-numbers dots">…</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 →</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, '&') 441 .replace(/</g, '<') 442 .replace(/>/g, '>') 443 .replace(/"/g, '"') 444 .replace(/'/g, '''); 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.