1const API_KEY = 2 (window.ytpcData && window.ytpcData.apiKey) || 3 ""; 4 5let youtubeApiPromise = null; 6 7// Load the YouTube IFrame API once. 8function loadYouTubeAPI() { 9 if (window.YT && window.YT.Player) { 10 return Promise.resolve(); 11 } 12 13 if (youtubeApiPromise) { 14 return youtubeApiPromise; 15 } 16 17 youtubeApiPromise = new Promise((resolve) => { 18 const existingScript = document.querySelector( 19 'script[src="https://www.youtube.com/iframe_api"]' 20 ); 21 22 const previousReadyHandler = window.onYouTubeIframeAPIReady; 23 24 window.onYouTubeIframeAPIReady = () => { 25 if (typeof previousReadyHandler === "function") { 26 previousReadyHandler(); 27 } 28 29 resolve(); 30 }; 31 32 if (!existingScript) { 33 const tag = document.createElement("script"); 34 tag.src = "https://www.youtube.com/iframe_api"; 35 document.head.appendChild(tag); 36 } 37 }); 38 39 return youtubeApiPromise; 40} 41 42document.addEventListener("DOMContentLoaded", () => { 43 loadYouTubeAPI().then(() => { 44 if (!window.YT || !window.YT.Player) { 45 console.error("YouTube API failed to initialize"); 46 return; 47 } 48 49 document.querySelectorAll(".yt-component").forEach((el, i) => { 50 initComponent(el, i); 51 }); 52 }); 53}); 54 55function initComponent(container, index) { 56 const MAX = Math.max(1, parseInt(container.dataset.max, 10) || 10); 57 const ENABLE_LIVE = container.dataset.live === "true"; 58 const CHANNEL_ID = container.dataset.channel || ""; 59 const LIVE_ONLY = container.dataset.liveOnly === "true"; 60 const SHOW_PLACEHOLDER = container.dataset.placeholder !== "false"; 61 const SHOW_PLAYLIST = container.dataset.showPlaylist !== "false"; 62 const requestedStartVideo = String(container.dataset.startVideo || "").trim(); 63 const START_VIDEO = /^[A-Za-z0-9_-]{11}$/.test(requestedStartVideo) 64 ? requestedStartVideo 65 : ""; 66 67 let player; 68 let playlistVideos = []; 69 let currentIndex = 0; 70 const videoCache = {}; 71 72 // Layout 73 if (LIVE_ONLY) { 74 container.innerHTML = ` 75 <div class="yt-live-only"> 76 <div class="live-wrapper"></div> 77 </div> 78 `; 79 } else { 80 container.innerHTML = ` 81 <div class="yt-layout${SHOW_PLAYLIST ? "" : " yt-player-only"}"> 82 <div class="yt-main"> 83 <div class="live-wrapper"></div> 84 85 <div class="video-wrapper"> 86 <div id="player-${index}"></div> 87 </div> 88 <h3 class="video-title"></h3> 89 <div class="video-description"></div> 90 </div> 91 92 ${SHOW_PLAYLIST ? ` 93 <div class="yt-sidebar"> 94 <div class="playlist"></div> 95 </div> 96 ` : ""} 97 </div> 98 `; 99 } 100 101 const titleEl = container.querySelector(".video-title"); 102 const descEl = container.querySelector(".video-description"); 103 const playlistEl = container.querySelector(".playlist"); 104 105 function getVideoId(item) { 106 return ( 107 item?.contentDetails?.videoId || 108 item?.snippet?.resourceId?.videoId || 109 "" 110 ); 111 } 112 113 function escapeHtml(value) { 114 return String(value ?? "") 115 .replace(/&/g, "&") 116 .replace(/</g, "<") 117 .replace(/>/g, ">") 118 .replace(/"/g, """) 119 .replace(/'/g, "'"); 120 } 121 122 function formatDate(dateString) { 123 if (!dateString) { 124 return ""; 125 } 126 127 const date = new Date(dateString); 128 129 if (Number.isNaN(date.getTime())) { 130 return ""; 131 } 132 133 return date.toLocaleDateString("en-US", { 134 year: "numeric", 135 month: "short", 136 day: "numeric" 137 }); 138 } 139 140 async function fetchYouTubeJson(resource, params) { 141 if (!API_KEY) { 142 throw new Error("YouTube API key is missing."); 143 } 144 145 const searchParams = new URLSearchParams({ 146 ...params, 147 key: API_KEY 148 }); 149 150 const response = await fetch( 151 `https://www.googleapis.com/youtube/v3/${resource}?${searchParams.toString()}`, 152 { 153 method: "GET", 154 credentials: "omit" 155 } 156 ); 157 158 let data; 159 160 try { 161 data = await response.json(); 162 } catch (error) { 163 throw new Error("YouTube returned an invalid response."); 164 } 165 166 if (!response.ok) { 167 throw new Error( 168 data?.error?.message || 169 response.statusText || 170 "YouTube request failed." 171 ); 172 } 173 174 return data; 175 } 176 177 async function fetchLiveVideo(channelId) { 178 const liveContainer = container.querySelector(".live-wrapper"); 179 180 if (!liveContainer) { 181 return; 182 } 183 184 liveContainer.innerHTML = 185 '<div class="live-placeholder">Checking for live stream...</div>'; 186 187 try { 188 const data = await fetchYouTubeJson("search", { 189 part: "snippet", 190 channelId, 191 eventType: "live", 192 type: "video" 193 }); 194 195 if (!Array.isArray(data.items) || !data.items.length) { 196 if (SHOW_PLACEHOLDER) { 197 renderLivePlaceholder(); 198 } else { 199 liveContainer.innerHTML = ""; 200 } 201 return; 202 } 203 204 const liveVideo = data.items[0]; 205 const videoId = liveVideo?.id?.videoId; 206 207 if (!videoId) { 208 if (SHOW_PLACEHOLDER) { 209 renderLivePlaceholder(); 210 } else { 211 liveContainer.innerHTML = ""; 212 } 213 return; 214 } 215 216 renderLiveVideo(videoId, liveVideo?.snippet?.title || "Live stream"); 217 } catch (error) { 218 console.error("Live fetch error:", error); 219 220 if (SHOW_PLACEHOLDER) { 221 renderLivePlaceholder(); 222 } else { 223 liveContainer.innerHTML = ""; 224 } 225 } 226 } 227 228 function renderLivePlaceholder() { 229 const liveContainer = container.querySelector(".live-wrapper"); 230 231 if (!liveContainer) { 232 return; 233 } 234 235 liveContainer.innerHTML = ` 236 <div class="live-placeholder"> 237 <div class="live-offline">⚫ Currently Offline</div> 238 <p>No live stream is happening right now. Check back later.</p> 239 </div> 240 `; 241 } 242 243 function renderLiveVideo(videoId, title) { 244 const liveContainer = container.querySelector(".live-wrapper"); 245 246 if (!liveContainer) { 247 return; 248 } 249 250 liveContainer.innerHTML = ` 251 <div class="live-banner">
252 <span class="live-title-wrap">🔴 Live Now | ${escapeHtml(title)}</span> 253 </div> 254 <div class="video-wrapper"> 255 <iframe 256 src="https://www.youtube.com/embed/${encodeURIComponent(videoId)}?autoplay=1&mute=1&playsinline=1&rel=0&modestbranding=1" 257 frameborder="0" 258 allow="autoplay; encrypted-media" 259 allowfullscreen> 260 </iframe> 261 </div> 262 `; 263 } 264 265 // Live-only mode does not need an IFrame Player instance. 266 if (LIVE_ONLY) { 267 if (CHANNEL_ID) { 268 fetchLiveVideo(CHANNEL_ID); 269 } else if (SHOW_PLACEHOLDER) { 270 renderLivePlaceholder(); 271 } 272 273 return; 274 } 275 276 player = new window.YT.Player(`player-${index}`, { 277 playerVars: { 278 autoplay: 0, 279 rel: 0, 280 modestbranding: 1, 281 iv_load_policy: 3 282 }, 283 events: { 284 onReady: () => { 285 // Live status should not depend on the playlist request succeeding. 286 if (ENABLE_LIVE && CHANNEL_ID) { 287 fetchLiveVideo(CHANNEL_ID); 288 } 289 290 fetchPlaylist(); 291 }, 292 onStateChange 293 } 294 }); 295 296 async function fetchPlaylist() { 297 try { 298 if (typeof window.ytpcLoadMergedPlaylistItems !== "function") { 299 throw new Error( 300 "Multi-playlist loader is unavailable. Make sure merge-loader.js is enqueued before script.js." 301 ); 302 } 303 304 const items = await window.ytpcLoadMergedPlaylistItems(container); 305 306 // Do not sort here. The loader has already applied round_robin, 307 // append, newest, or shuffle ordering. 308 playlistVideos = items 309 .filter((item) => { 310 const title = String(item?.snippet?.title || "").trim(); 311 312 return ( 313 Boolean(getVideoId(item)) && 314 title !== "Private video" && 315 title !== "Deleted video" 316 ); 317 }) 318 .slice(0, MAX); 319 320 if (!playlistVideos.length) { 321 renderPlaylistMessage("No public videos were found."); 322 return; 323 } 324 325 const requestedStartIndex = START_VIDEO 326 ? playlistVideos.findIndex((item) => getVideoId(item) === START_VIDEO) 327 : -1; 328 329 currentIndex = requestedStartIndex >= 0 ? requestedStartIndex : 0; 330 renderPlaylist(); 331 332 const initialItem = playlistVideos[currentIndex]; 333 const initialVideoId = getVideoId(initialItem); 334 const videoIds = playlistVideos.map(getVideoId).filter(Boolean); 335 336 // Hydrate titles, descriptions, and publication dates without blocking 337 // the initial player and sidebar render. 338 hydrateVideoCache(videoIds) 339 .then(() => { 340 renderPlaylist(); 341 updateVideoInfo(initialVideoId); 342 }) 343 .catch((error) => { 344 console.error("Batch video fetch error:", error); 345 updateVideoInfo(initialVideoId); 346 }); 347 348 player.cueVideoById({ 349 videoId: initialVideoId, 350 startSeconds: 0 351 }); 352 353 titleEl.innerHTML = ` 354 <div class="video-date">Loading...</div> 355 <div class="video-title-text">${escapeHtml(initialItem?.snippet?.title || "")}</div> 356 `; 357 358 descEl.innerHTML = "<em>Loading description...</em>"; 359 } catch (error) { 360 console.error("Playlist fetch error:", error); 361 renderPlaylistMessage("Unable to load playlist videos."); 362 363 if (titleEl) { 364 titleEl.textContent = "Playlist unavailable"; 365 } 366 367 if (descEl) { 368 descEl.textContent = error?.message || "The playlist could not be loaded."; 369 } 370 } 371 } 372 373 async function hydrateVideoCache(videoIds) { 374 const uniqueIds = Array.from(new Set(videoIds.filter(Boolean))); 375 const chunks = []; 376 377 for (let offset = 0; offset < uniqueIds.length; offset += 50) { 378 chunks.push(uniqueIds.slice(offset, offset + 50)); 379 } 380 381 const settledResults = await Promise.allSettled( 382 chunks.map((chunk) => 383 fetchYouTubeJson("videos", { 384 part: "snippet,status", 385 id: chunk.join(",") 386 }) 387 ) 388 ); 389 390 let successCount = 0; 391 392 settledResults.forEach((result) => { 393 if (result.status !== "fulfilled") { 394 console.warn("Video metadata batch failed:", result.reason); 395 return; 396 } 397 398 const videos = Array.isArray(result.value?.items) 399 ? result.value.items 400 : []; 401 402 videos.forEach((video) => { 403 if (!video?.id || !video?.snippet) { 404 return; 405 } 406 407 videoCache[video.id] = { 408 ...video.snippet, 409 publishAt: video.status?.publishAt || "" 410 }; 411 412 successCount += 1; 413 }); 414 }); 415 416 if (successCount === 0 && uniqueIds.length > 0) { 417 throw new Error("No video metadata could be loaded."); 418 } 419 } 420 421 function onStateChange(event) { 422 if (event.data === window.YT.PlayerState.PLAYING) { 423 const id = player.getVideoData().video_id; 424 425 if (!id) { 426 return; 427 } 428 429 currentIndex = playlistVideos.findIndex( 430 (video) => getVideoId(video) === id 431 ); 432 433 updateVideoInfo(id); 434 renderPlaylist(); 435 } 436 437 if (event.data === window.YT.PlayerState.ENDED) { 438 playNext(); 439 } 440 } 441 442 function playNext() { 443 const next = currentIndex + 1; 444 445 if (next < playlistVideos.length) { 446 const nextVideoId = getVideoId(playlistVideos[next]); 447 448 if (!nextVideoId) { 449 return; 450 } 451 452 player.loadVideoById(nextVideoId); 453 currentIndex = next; 454 } 455 } 456 457 async function updateVideoInfo(videoId = null) { 458 const id = videoId || player.getVideoData()?.video_id; 459 460 if (!id) { 461 return; 462 } 463 464 if (videoCache[id]) { 465 applyVideoData(videoCache[id]); 466 return; 467 } 468 469 try { 470 const data = await fetchYouTubeJson("videos", { 471 part: "snippet,status", 472 id 473 }); 474 475 if (!Array.isArray(data.items) || !data.items.length) { 476 descEl.innerHTML = "<em>No description available</em>"; 477 return; 478 } 479 480 const item = data.items[0]; 481 const videoData = { 482 ...item.snippet, 483 publishAt: item.status?.publishAt || "" 484 }; 485 486 videoCache[id] = videoData; 487 applyVideoData(videoData); 488 } catch (error) { 489 console.error("Video fetch error:", error); 490 descEl.innerHTML = "<em>Error loading description</em>"; 491 } 492 } 493 494 function applyVideoData(videoData) { 495 const publishDate = videoData.publishAt || videoData.publishedAt; 496 const formattedDate = formatDate(publishDate); 497 498 titleEl.innerHTML = ` 499 <div class="video-date">${escapeHtml(formattedDate)}</div> 500 <div class="video-title-text">${escapeHtml(videoData.title || "")}</div> 501 `; 502 503 descEl.style.opacity = 0; 504 505 setTimeout(() => { 506 const safeDescription = escapeHtml(videoData.description || "") 507 .replace(/\r?\n/g, "<br>"); 508 509 descEl.innerHTML = safeDescription || "<em>No description available</em>"; 510 descEl.style.opacity = 1; 511 }, 150); 512 } 513 514 function renderPlaylistMessage(message) { 515 if (!playlistEl) { 516 return; 517 } 518 519 playlistEl.innerHTML = ` 520 <div class="playlist-message">${escapeHtml(message)}</div> 521 `; 522 } 523 524 function renderPlaylist() { 525 if (!playlistEl) { 526 return; 527 } 528 529 playlistEl.innerHTML = ""; 530 531 playlistVideos.forEach((item, itemIndex) => { 532 const videoId = getVideoId(item); 533 534 if (!videoId) { 535 return; 536 } 537 538 const snippet = item?.snippet || {}; 539 const thumbnail = 540 snippet?.thumbnails?.medium?.url || 541 snippet?.thumbnails?.high?.url || 542 snippet?.thumbnails?.default?.url || 543 ""; 544
545 const row = document.createElement("div"); 546 row.className = "playlist-item"; 547 548 if (itemIndex === currentIndex) { 549 row.classList.add("active"); 550 } 551 552 if (thumbnail) { 553 const image = document.createElement("img"); 554 image.src = thumbnail; 555 image.alt = ""; 556 image.loading = "lazy"; 557 row.appendChild(image); 558 } 559 560 const text = document.createElement("p"); 561 text.appendChild(document.createTextNode(snippet.title || "Untitled video")); 562 563 const date = document.createElement("span"); 564 date.className = "playlist-date"; 565 date.textContent = getDisplayDate(videoId, item); 566 text.appendChild(date); 567 row.appendChild(text); 568 569 row.addEventListener("click", () => { 570 if (!player) { 571 return; 572 } 573 574 player.loadVideoById(videoId); 575 currentIndex = itemIndex; 576 updateVideoInfo(videoId); 577 renderPlaylist(); 578 }); 579 580 playlistEl.appendChild(row); 581 }); 582 } 583 584 function getDisplayDate(videoId, item) { 585 const cachedDate = 586 videoCache[videoId]?.publishAt || 587 videoCache[videoId]?.publishedAt; 588 589 if (cachedDate) { 590 return formatDate(cachedDate); 591 } 592 593 // contentDetails.videoPublishedAt is the video's publication date. 594 // snippet.publishedAt is only a fallback and can be the date the video 595 // was added to the source playlist. 596 const fallbackDate = 597 item?.contentDetails?.videoPublishedAt || 598 item?.snippet?.publishedAt || 599 ""; 600 601 return formatDate(fallbackDate); 602 } 603}
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.