PageSourceSearch

https://dphx.org/wp-content/plugins/ytpc-playlist/assets/script.js?ver=1789575362

js dphx.org collected 2026-10-02 03:01:09 UTC 15,172 bytes, 603 lines download raw bytes

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, "&amp;")
116      .replace(/</g, "&lt;")
117      .replace(/>/g, "&gt;")
118      .replace(/"/g, "&quot;")
119      .replace(/'/g, "&#039;");
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">&#9899; 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">&#128308; 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.