diff --git a/src/library/index.js b/src/library/index.js index c1b7768..2c5cba5 100644 --- a/src/library/index.js +++ b/src/library/index.js @@ -4,30 +4,35 @@ import { generateSortTypes } from "./sorting"; -const config = { - videoElement: "ytd-playlist-video-renderer", - videoElementsContainer: "ytd-playlist-video-list-renderer #contents", - timestampContainer: "ytd-thumbnail-overlay-time-status-renderer", - metadataContainer: { - main: ".immersive-header-content .metadata-action-bar", - fallback: "ytd-playlist-sidebar-renderer #items" - }, - statsContainer: { - main: ".metadata-stats yt-formatted-string", - fallback: "#stats yt-formatted-string" - }, - playlistSummaryContainer: { - main: "#ytpdc-playlist-summary-new", - fallback: "#ytpdc-playlist-summary-old" - }, - // Design anchor = Element that helps distinguish between old & new layout +const elementSelectors = { + timestamp: "ytd-thumbnail-overlay-time-status-renderer", + // Design anchor = Element that helps distinguish between old & new layouts designAnchor: { old: "ytd-playlist-sidebar-renderer", new: "ytd-playlist-header-renderer" + }, + playlistSummary: { + old: "#ytpdc-playlist-summary-old", + new: "#ytpdc-playlist-summary-new" + }, + playlistMetadata: { + old: "ytd-playlist-sidebar-renderer #items", + new: ".immersive-header-content .metadata-action-bar" + }, + video: "ytd-playlist-video-renderer", + playlist: "ytd-playlist-video-list-renderer #contents" +}; + +const main = () => { + if ( + window.location.pathname === "/playlist" && + window.location.search.startsWith("?list=") + ) { + checkPlaylistReady(); } }; -const pollPlaylistReady = () => { +const checkPlaylistReady = () => { displayLoader(); const maxPollCount = 60; @@ -37,7 +42,7 @@ const pollPlaylistReady = () => { if (pollCount >= maxPollCount) clearInterval(playlistPoll); if ( - document.querySelector(config.timestampContainer) && + document.querySelector(elementSelectors.timestamp) && countUnavailableTimestamps() === countUnavailableVideos() ) { clearInterval(playlistPoll); @@ -49,42 +54,30 @@ const pollPlaylistReady = () => { }; const displayLoader = () => { - const playlistSummary = document.querySelector( - isNewDesign() - ? config.playlistSummaryContainer.main - : config.playlistSummaryContainer.fallback - ); + const playlistSummaryElement = getPlaylistSummaryElement(); + if (!playlistSummaryElement) return; - if (playlistSummary) { - const loader = document.createElement("div"); - loader.id = "ytpdc-loader"; - loader.textContent = "Calculating..."; + const loaderElement = document.createElement("div"); + loaderElement.id = "ytpdc-loader"; + loaderElement.textContent = "Calculating..."; - playlistSummary.innerHTML = ""; - playlistSummary.appendChild(loader); - } + playlistSummaryElement.innerHTML = ""; + playlistSummaryElement.appendChild(loaderElement); }; -const displayMessages = (messages) => { - const playlistSummary = document.querySelector( - isNewDesign() - ? config.playlistSummaryContainer.main - : config.playlistSummaryContainer.fallback - ); - - if (playlistSummary) { - const container = document.createElement("div"); - container.id = "messages-container"; +const getPlaylistSummaryElement = () => { + const selector = + elementSelectors.playlistSummary[isNewDesign() ? "new" : "old"]; + return document.querySelector(selector); +}; - messages.forEach((message) => { - const item = document.createElement("p"); - item.textContent = message; - container.appendChild(item); - }); +const isNewDesign = () => { + const designAnchors = { + new: document.querySelector(elementSelectors.designAnchor.new), + old: document.querySelector(elementSelectors.designAnchor.old) + }; - playlistSummary.innerHTML = ""; - playlistSummary.appendChild(container); - } + return designAnchors.new && designAnchors.old.getAttribute("hidden") !== null; }; /** @@ -97,6 +90,55 @@ const countUnavailableTimestamps = () => { .filter((timestamp) => timestamp === null).length; }; +const getVideos = () => { + const playlistElement = document.querySelector(elementSelectors.playlist); + const videos = playlistElement.getElementsByTagName(elementSelectors.video); + return [...videos]; +}; + +/** + * Extracts a timestamp from a video container element + * @param {Element} video + * @returns {number} + */ +const getTimestampFromVideo = (video) => { + if (!video) return null; + + const timestampElement = video.querySelector(elementSelectors.timestamp); + if (!timestampElement) return null; + + const timestamp = timestampElement.innerText; + if (!timestamp) return null; + + const timestampAsSeconds = convertTimestampToSeconds(timestamp); + return timestampAsSeconds; +}; + +/** + * Converts a textual timestamp formatted as hh:mm:ss to its numerical value + * represented in seconds + * @param {string} timestamp + * @returns {number} + */ +const convertTimestampToSeconds = (timestamp) => { + let timeComponents = timestamp + .split(":") + .map((timeComponent) => parseInt(timeComponent, 10)); + + let seconds = 0; + let minutes = 1; + + while (timeComponents.length > 0) { + let timeComponent = timeComponents.pop(); + if (isNaN(timeComponent)) continue; + + seconds += minutes * timeComponent; + minutes *= 60; + } + + return seconds; +}; + const countUnavailableVideos = () => { const unavailableVideoTitles = [ "[Private video]", @@ -107,7 +149,9 @@ const countUnavailableVideos = () => { "[Age restricted]" ]; - const videoTitles = document.querySelectorAll("a#video-title"); + const videoTitles = document.querySelectorAll( + `${elementSelectors.playlist} #video-title` + ); let unavailableVideosCount = 0; @@ -121,9 +165,8 @@ const countUnavailableVideos = () => { }; const processPlaylist = () => { - configurePage(); + setupPage(); const playlistObserver = setupPlaylistObserver(); - setupEventListeners(); const videos = getVideos(); const timestamps = videos.map(getTimestampFromVideo); const totalDurationInSeconds = @@ -131,41 +174,78 @@ const processPlaylist = () => { ? timestamps.reduce((a, b) => a + b) : 0; const playlistDuration = convertSecondsToTimestamp(totalDurationInSeconds); - const playlistSummary = createPlaylistSummary({ + addPlaylistSummaryToPage({ timestamps, playlistDuration, playlistObserver }); - addSummaryToPage(playlistSummary); }; -const configurePage = () => { - if (window.ytpdc) return; +const setupPage = () => { + if (window.ytpdc && window.ytpdc.pageSetupDone) return; + window.ytpdc = { - playlistObserver: false, - setupEventListeners: false, + pageSetupDone: false, + playlistObserver: null, sortDropdown: { used: false, element: null }, lastVideoInteractedWith: null }; + + const onYoutubeNavigationFinished = () => { + if (window.ytpdc.playlistObserver) { + window.ytpdc.playlistObserver?.disconnect(); + window.ytpdc.playlistObserver = null; + } + + main(); + }; + + document.addEventListener( + "yt-navigate-finish", + onYoutubeNavigationFinished, + false + ); + + const onPlaylistInteractedWith = (event) => { + window.ytpdc.lastVideoInteractedWith = event.target.closest( + elementSelectors.video + ); + }; + + document + .querySelector(elementSelectors.playlist) + ?.addEventListener("click", onPlaylistInteractedWith); + + window.ytpdc.pageSetupDone = true; }; /** - * Checks whether enough conditions hold true to request a page reload - * when the playlist is mutated - * @param {MutationRecord} mutation - * @returns {boolean} - */ -const shouldRequestPageReload = (mutation) => { - return ( - mutation.addedNodes.length === 0 && - mutation.removedNodes.length === 1 && - mutation.removedNodes[0]?.tagName.toLowerCase() === config.videoElement && - window.ytpdc.sortDropdown.used && - !window.ytpdc.lastVideoInteractedWith - ); + * Sets up a mutation observer on the playlist to detect when video(s) are + * added or removed. + * Upon detection it conditionally triggers a re-processing of the playlist + * @returns {{ + disconnect: () => void, + reconnect: () => void + }} + */ +const setupPlaylistObserver = () => { + if (window.ytpdc.playlistObserver) return window.ytpdc.playlistObserver; + + const playlistElement = document.querySelector(elementSelectors.playlist); + if (!playlistElement) return null; + + const playlistObserver = new MutationObserver(onPlaylistMutated); + playlistObserver.observe(playlistElement, { childList: true }); + window.ytpdc.playlistObserver = playlistObserver; + + return { + disconnect: () => playlistObserver.disconnect(), + reconnect: () => + playlistObserver.observe(playlistElement, { childList: true }) + }; }; /** @@ -175,7 +255,7 @@ const shouldRequestPageReload = (mutation) => { * @returns {MutationCallback} */ const onPlaylistMutated = (mutationList, observer) => { - const playlistElement = document.querySelector(config.videoElementsContainer); + const playlistElement = document.querySelector(elementSelectors.playlist); if (mutationList.length === 1 && mutationList[0].type === "childList") { const mutation = mutationList[0]; @@ -218,87 +298,41 @@ const onPlaylistMutated = (mutationList, observer) => { }; /** - * Sets up a mutation observer on the playlist to detect when video(s) are - * added or removed. - * Upon detection it triggers a re-processing of the playlist. - * @returns {{ - disconnect: () => void, - reconnect: () => void - }} - */ -const setupPlaylistObserver = () => { - if (window.ytpdc.playlistObserver) return window.ytpdc.playlistObserver; - - const playlistElement = document.querySelector(config.videoElementsContainer); - if (!playlistElement) return null; - - const playlistObserver = new MutationObserver(onPlaylistMutated); - playlistObserver.observe(playlistElement, { childList: true }); - window.ytpdc.playlistObserver = playlistObserver; - - return { - disconnect: () => playlistObserver.disconnect(), - reconnect: () => - playlistObserver.observe(playlistElement, { childList: true }) - }; -}; - -const setupEventListeners = () => { - if (window.ytpdc.setupEventListeners) return; - window.ytpdc.setupEventListeners = true; - - const onYoutubeNavigationFinished = () => { - if (window.ytpdc.playlistObserver) { - window.ytpdc.playlistObserver?.disconnect(); - window.ytpdc.playlistObserver = null; - } - - main(); - }; - - document.addEventListener( - "yt-navigate-finish", - onYoutubeNavigationFinished, - false - ); - - const onPlaylistInteractedWith = (event) => { - window.ytpdc.lastVideoInteractedWith = event.target.closest( - config.videoElement - ); - }; - - document - .querySelector(config.videoElementsContainer) - ?.addEventListener("click", onPlaylistInteractedWith); -}; - -const getVideos = () => { - const videoElementsContainer = document.querySelector( - config.videoElementsContainer - ); - const videos = videoElementsContainer.getElementsByTagName( - config.videoElement + * Checks whether enough conditions hold true when the playlist is mutated + * to request a page reload + * @param {MutationRecord} mutation + * @returns {boolean} + */ +const shouldRequestPageReload = (mutation) => { + return ( + mutation.addedNodes.length === 0 && + mutation.removedNodes.length === 1 && + mutation.removedNodes[0]?.tagName.toLowerCase() === + elementSelectors.video && + window.ytpdc.sortDropdown.used && + !window.ytpdc.lastVideoInteractedWith ); - return [...videos]; }; /** - * Extracts a timestamp from a video container element - * @param {Element} video - * @returns {string} + * Display a list of messages within the playlist summary element + * @param {string[]} messages */ -const getTimestampFromVideo = (video) => { - if (!video) return null; +const displayMessages = (messages) => { + const playlistSummaryElement = getPlaylistSummaryElement(); + if (!playlistSummaryElement) return; - const timestampContainer = video.querySelector(config.timestampContainer); - if (!timestampContainer) return null; + const containerElement = document.createElement("div"); + containerElement.id = "messages-container"; - const timestamp = timestampContainer.innerText; - if (!timestamp) return null; + messages.forEach((message) => { + const messageElement = document.createElement("p"); + messageElement.textContent = message; + containerElement.appendChild(messageElement); + }); - const timestampInSeconds = convertTimestampToSeconds(timestamp); - return timestampInSeconds; + playlistSummaryElement.innerHTML = ""; + playlistSummaryElement.appendChild(containerElement); }; /** @@ -315,26 +349,54 @@ const convertSecondsToTimestamp = (seconds) => { return `${hours}:${minutes}:${remainingSeconds}`; }; -const createPlaylistSummary = ({ +const addPlaylistSummaryToPage = ({ timestamps, playlistDuration, playlistObserver }) => { - const container = document.createElement("div"); - container.id = ( - isNewDesign() - ? config.playlistSummaryContainer.main - : config.playlistSummaryContainer.fallback - ).replace("#", ""); - container.classList.add("container"); + const playlistSummaryElement = createPlaylistSummaryElement({ + timestamps, + playlistDuration, + playlistObserver + }); + + const existingPlaylistSummaryElement = getPlaylistSummaryElement(); + + if (existingPlaylistSummaryElement) { + existingPlaylistSummaryElement.replaceWith(playlistSummaryElement); + } else { + const metadataElement = document.querySelector( + elementSelectors.playlistMetadata[isNewDesign() ? "new" : "old"] + ); + if (!metadataElement) return null; + + metadataElement.parentElement.insertBefore( + playlistSummaryElement, + metadataElement.nextElementSibling + ); + } +}; + +const createPlaylistSummaryElement = ({ + timestamps, + playlistDuration, + playlistObserver +}) => { + const newDesign = isNewDesign(); + + const containerElement = document.createElement("div"); + containerElement.id = elementSelectors.playlistSummary[ + newDesign ? "new" : "old" + ].replace("#", ""); + containerElement.classList.add("container"); // Fallback styles for old design - if (!isNewDesign()) { + if (!newDesign) { if (isDarkMode()) { - container.style.color = "white"; + containerElement.style.color = "white"; } else { - container.style.background = "rgba(0,0,0,0.8)"; - container.style.color = "white"; + containerElement.style.background = "rgba(0,0,0,0.8)"; + containerElement.style.color = "white"; } } @@ -343,14 +405,14 @@ const createPlaylistSummary = ({ `${playlistDuration}`, "#86efac" ); - container.appendChild(totalDuration); + containerElement.appendChild(totalDuration); const videosCounted = createSummaryItem( "Videos counted:", `${timestamps.length}`, "#fdba74" ); - container.appendChild(videosCounted); + containerElement.appendChild(videosCounted); const totalVideosInPlaylist = countTotalVideosInPlaylist(); const videosNotCounted = createSummaryItem( @@ -360,64 +422,46 @@ const createPlaylistSummary = ({ }`, "#fca5a5" ); - container.appendChild(videosNotCounted); + containerElement.appendChild(videosNotCounted); if (totalVideosInPlaylist <= 100) { if (window.ytpdc.sortDropdown.element) { - container.appendChild(window.ytpdc.sortDropdown.element); + containerElement.appendChild(window.ytpdc.sortDropdown.element); } else { const sortDropdown = createSortDropdown(playlistObserver); window.ytpdc.sortDropdown.element = sortDropdown; - container.appendChild(sortDropdown); + containerElement.appendChild(sortDropdown); } } if (totalVideosInPlaylist >= 100) { - const tooltip = document.createElement("div"); - tooltip.id = "ytpdc-playlist-summary-tooltip"; + const tooltipElement = document.createElement("div"); + tooltipElement.id = "ytpdc-playlist-summary-tooltip"; - const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - icon.setAttribute("preserveAspectRatio", "xMidYMid meet"); - icon.setAttribute("viewBox", "0 0 24 24"); - icon.innerHTML = ``; - tooltip.appendChild(icon); + tooltipElement.appendChild(iconElement); - const tooltipText = document.createElement("p"); - tooltipText.textContent = "Scroll down to count more videos"; - tooltip.appendChild(tooltipText); + const textElement = document.createElement("p"); + textElement.textContent = "Scroll down to count more videos"; + tooltipElement.appendChild(textElement); - container.appendChild(tooltip); + containerElement.appendChild(tooltipElement); } - return container; + return containerElement; }; -/** - * Converts a textual timestamp formatted as hh:mm:ss to its numerical value - * represented in seconds - * @param {string} timestamp - * @returns {number} - */ -const convertTimestampToSeconds = (timestamp) => { - let timeComponents = timestamp - .split(":") - .map((timeComponent) => parseInt(timeComponent, 10)); - - let seconds = 0; - let minutes = 1; - - while (timeComponents.length > 0) { - let timeComponent = timeComponents.pop(); - if (isNaN(timeComponent)) continue; - - seconds += minutes * timeComponent; - minutes *= 60; - } - - return seconds; +const isDarkMode = () => { + return document.documentElement.getAttribute("dark") !== null; }; const createSummaryItem = (label, value, valueColor = "#facc15") => { @@ -438,55 +482,16 @@ const createSummaryItem = (label, value, valueColor = "#facc15") => { return container; }; -const addSummaryToPage = (summary) => { - const newDesign = isNewDesign(); - - let metadataSection = document.querySelector( - newDesign - ? config.metadataContainer.main - : config.metadataContainer.fallback - ); - if (!metadataSection) return null; - - const previousSummary = document.querySelector( - newDesign - ? config.playlistSummaryContainer.main - : config.playlistSummaryContainer.fallback - ); - - if (previousSummary) { - previousSummary.parentNode.removeChild(previousSummary); - } - - metadataSection.parentNode.insertBefore(summary, metadataSection.nextSibling); -}; - const countTotalVideosInPlaylist = () => { - const totalVideosStat = document.querySelector( - isNewDesign() ? config.statsContainer.main : config.statsContainer.fallback - ); - - if (!totalVideosStat) return null; - - const totalVideosCount = parseInt( - totalVideosStat.innerText.replace(/\D/g, "") + const statsElement = document.querySelector( + isNewDesign() + ? ".metadata-stats yt-formatted-string" + : "#stats yt-formatted-string" ); - return totalVideosCount; -}; - -const isDarkMode = () => { - return document.documentElement.getAttribute("dark") !== null; -}; - -const isNewDesign = () => { - const newDesignAnchor = document.querySelector(config.designAnchor.new); - const oldDesignAnchor = document.querySelector(config.designAnchor.old); + if (!statsElement) return null; - const isNewDesign = - newDesignAnchor && oldDesignAnchor.getAttribute("hidden") !== null; - - return isNewDesign; + return parseInt(statsElement.innerText.replace(/\D/g, "")); }; const createSortDropdown = (playlistObserver) => { @@ -514,20 +519,15 @@ const createSortDropdown = (playlistObserver) => { playlistObserver?.disconnect(); - const videoElementsContainer = document.querySelector( - config.videoElementsContainer - ); - - const videos = videoElementsContainer.getElementsByTagName( - config.videoElement - ); + const playlistElement = document.querySelector(elementSelectors.playlist); + const videos = playlistElement.getElementsByTagName(elementSelectors.video); const [sortType, sortOrder] = event.target.value.split(":"); const SortStrategy = sortTypes[sortType].strategy; const playlistSorter = new PlaylistSorter(new SortStrategy(), sortOrder); const sortedVideos = playlistSorter.sort(videos); - videoElementsContainer.replaceChildren(...sortedVideos); + playlistElement.replaceChildren(...sortedVideos); playlistObserver?.reconnect(); }); @@ -551,13 +551,4 @@ const createSortDropdown = (playlistObserver) => { return container; }; -const main = () => { - if ( - window.location.pathname === "/playlist" && - window.location.search.startsWith("?list=") - ) { - pollPlaylistReady(); - } -}; - -export { main, config, getTimestampFromVideo }; +export { elementSelectors, main, getTimestampFromVideo }; diff --git a/src/library/sorting.js b/src/library/sorting.js index 9beae6c..1b7b531 100644 --- a/src/library/sorting.js +++ b/src/library/sorting.js @@ -1,4 +1,4 @@ -import { config, getTimestampFromVideo } from "./index"; +import { elementSelectors, getTimestampFromVideo } from "./index"; class PlaylistSorter { constructor(strategy, sortOrder) { @@ -225,7 +225,7 @@ const generateSortTypes = () => ({ strategy: SortByIndexStrategy }, duration: { - enabled: videoHasElement(config.timestampContainer), + enabled: videoHasElement(elementSelectors.timestamp), label: { asc: "Duration (Shortest)", desc: "Duration (Longest)" @@ -267,7 +267,7 @@ const generateSortTypes = () => ({ * @returns {boolean} */ const videoHasElement = (identifier) => { - const videoElement = document.querySelector(config.videoElement); + const videoElement = document.querySelector(elementSelectors.video); return videoElement && videoElement.querySelector(identifier); };