Youtube Playlist Duration Calculator (Forked)

Calculate the duration of a playlist and display it next to the number of videos. Also detects and displays video playback speed.

질문, 리뷰하거나, 이 스크립트를 신고하세요.
// ==UserScript==
// @name         Youtube Playlist Duration Calculator (Forked)
// @namespace    http://tampermonkey.net/
// @version      1.1.6
// @description  Calculate the duration of a playlist and display it next to the number of videos. Also detects and displays video playback speed.
// @author       HellFiveOsborn (Forked from DenverCoder1)
// @icon         https://i.imgur.com/FwUCnbF.png
// @source       https://greasyfork.org/pt-BR/scripts/407457-youtube-playlist-duration-calculator
// @match        https://www.youtube.com/playlist?list=*
// @match        https://www.youtube.com/watch?v=*&list=*
// @match        https://www.youtube.com/watch?v=*
// @exclude      https://www.youtube.com/shorts/*
// @exclude      https://www.youtube.com/
// @exclude      https://www.youtube.com/feed/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
  "use strict";

  /**
   * Converts a time string in the format "HH:MM:SS" or "MM:SS" or "SS" to the total number of seconds.
   *
   * @param {string} timeString - The time string to convert, formatted as "HH:MM:SS", "MM:SS", or "SS".
   * @returns {number} The total number of seconds represented by the input time string.
   */
  function timeToSeconds(timeString) {
    const parts = timeString.split(':').map(Number).reverse(); // Split by ':' and reverse the array
    let seconds = 0;
    if (parts[0]) seconds += parts[0]; // Add seconds
    if (parts[1]) seconds += parts[1] * 60; // Add minutes converted to seconds
    if (parts[2]) seconds += parts[2] * 3600; // Add hours converted to seconds
    return seconds;
  }

  /**
   * Calculate the duration of a playlist
   *
   * @returns {string} Duration of the playlist in a human readable format
   */
  function calculateDuration() {
    // get data object stored on Youtube's website
    const data = window.ytInitialData;

    // Extracts the two main content structures from the data object using destructuring.
    const { twoColumnBrowseResultsRenderer, twoColumnWatchNextResults } = data.contents || {};

    // Safely access nested properties with optional chaining to avoid runtime errors
    const browseContents = twoColumnBrowseResultsRenderer?.tabs[0]?.tabRenderer?.content;

    // Attempt to get the playlist content from the watch next results
    const watchNextContents = twoColumnWatchNextResults?.playlist?.playlist;

    // Try to extract the list of videos from one of the known content structures.
    const vids = browseContents?.sectionListRenderer?.contents[0]?.itemSectionRenderer?.contents[0]?.playlistVideoListRenderer?.contents
              || watchNextContents?.contents;

    // Calculate the total duration of all videos in seconds
    const seconds = vids.reduce(function (x, y) {
      const videoRenderer = y.playlistVideoRenderer || y.playlistPanelVideoRenderer;
      if (!videoRenderer) return x;

      // If 'lengthSeconds' is available and valid, use it directly
      if (!isNaN(videoRenderer.lengthSeconds)) {
        return x + parseInt(videoRenderer.lengthSeconds);
      }
      // If 'lengthText.simpleText' is available, convert it to seconds
      else if (videoRenderer.lengthText?.simpleText) {
        return x + timeToSeconds(videoRenderer.lengthText.simpleText);
      }
      // If neither is available, return the current total
      return x;
    }, 0);

    // divide by 60 and round to get the number of minutes
    const minutes = Math.round(seconds / 60);

    // if there is at least 1 hour, display hours and minutes, otherwise display minutes and seconds.
    const durationString =
      minutes >= 60 // if minutes is 60 or more
        ? Math.floor(minutes / 60) + "h " + (minutes % 60) + "m" // calculate hours and minutes
        : Math.floor(seconds / 60) + "m " + (seconds % 60) + "s"; // calculate minutes and seconds

    return durationString;
  }

  /**
   * Append the duration to the playlist metadata
   */
  function appendDurationToPlaylistMetadata() {
    const metadataRow = document.querySelectorAll('div.yt-content-metadata-view-model-wiz__metadata-row')[3]
    if (!metadataRow) return;

    const durationString = calculateDuration();

    // Create a new span for the duration
    const durationSpan = document.createElement('span');
    durationSpan.className = 'yt-core-attributed-string yt-content-metadata-view-model-wiz__metadata-text yt-core-attributed-string--white-space-pre-wrap yt-core-attributed-string--link-inherit-color';
    durationSpan.setAttribute('dir', 'auto');
    durationSpan.setAttribute('role', 'text');
    durationSpan.textContent = durationString;
    durationSpan.style.color = '#ff0'; // Highlight color

    // Add a delimiter before the duration
    const delimiterSpan = document.createElement('span');
    delimiterSpan.className = 'yt-content-metadata-view-model-wiz__delimiter';
    delimiterSpan.setAttribute('aria-hidden', 'true');
    delimiterSpan.textContent = '•';

    // Append the delimiter and duration to the metadata row
    metadataRow.appendChild(delimiterSpan);
    metadataRow.appendChild(durationSpan);

    console.debug('Duration of playlist:', durationString);
  }

  /**
   * Append the duration to the playlist header when watching a video in a playlist
   */
  function appendDurationToPlaylistHeader() {
    const headerDescription = document.querySelectorAll('div#publisher-container')[2];
    if (!headerDescription) return;

    waitForElement('.html5-video-container > video').then(() => {
      const videoElement = document.querySelector('.html5-video-container > video');
      if (!videoElement) return;

      const speed = videoElement.playbackRate;
      const durationString = calculateDuration();

      // Calculate the adjusted duration if playback speed is not 1x
      const totalSeconds = parseInt(durationString.split(' ').reduce((acc, val) => {
        if (val.includes('h')) acc += parseInt(val) * 3600;
        if (val.includes('m')) acc += parseInt(val) * 60;
        if (val.includes('s')) acc += parseInt(val);
        return acc;
      }, 0));

      const adjustedDuration = speed !== 1 ? totalSeconds / speed : totalSeconds;

      // Format adjusted duration into hours, minutes, and seconds
      const hours = Math.floor(adjustedDuration / 3600);
      const minutes = Math.floor((adjustedDuration % 3600) / 60);
      const seconds = Math.floor(adjustedDuration % 60);

      // Create a human-readable string
      const formattedDuration =
        hours > 0
          ? `${hours}h ${minutes.toString().padStart(2, '0')}m`
          : `${minutes}m ${seconds.toString().padStart(2, '0')}s`;

      // Remove the previous duration if it exists
      if (document.querySelector('#playlist-duration-calculated')) {
        // If the duration is the same, don't update
        if (document.querySelector('#playlist-duration-calculated').textContent === formattedDuration) return;

        document.querySelector('#playlist-duration-calculated').remove();
      }

      console.debug('Playback speed:', speed);
      console.debug('Duration of playlist:', durationString);
      console.debug('Adjusted duration:', formattedDuration);

      // Create a new span for the duration
      const durationSpan = document.createElement('div');
      durationSpan.className = 'index-message-wrapper style-scope ytd-playlist-panel-renderer';
      durationSpan.id = 'playlist-duration-calculated';
      durationSpan.setAttribute('dir', 'auto');
      durationSpan.setAttribute('role', 'text');
      durationSpan.textContent = `${formattedDuration}`;
      durationSpan.style.marginLeft = '5px'; // Add margin to the left
      durationSpan.style.color = '#ff0'; // Highlight color

      // Append the duration to the header description
      headerDescription.appendChild(durationSpan);
    });
  }

  /**
   * Detect video playback speed and display calculated duration
   */
  function detectPlaybackSpeed() {
    const videoElement = document.querySelector('.html5-video-container > video');
    if (!videoElement) return;

    const speed = videoElement.playbackRate;
    const currentTime = videoElement.currentTime;
    const duration = videoElement.duration;

    // Only display if playback speed is not 1x
    if (speed !== 1) {
      const calculatedDuration = (duration - currentTime) / speed;

      const timeWrapper = document.querySelector('.ytp-time-wrapper');
      if (!timeWrapper) return;

      let calculatedDurationElement = timeWrapper.querySelector('.ytp-time-duration-calculed');
      if (!calculatedDurationElement) {
        calculatedDurationElement = document.createElement('span');
        calculatedDurationElement.className = 'ytp-time-duration-calculed';
        calculatedDurationElement.style.color = '#ff0'; // Highlight color
        timeWrapper.appendChild(calculatedDurationElement);
      }

      const minutes = Math.floor(calculatedDuration / 60);
      const seconds = Math.floor(calculatedDuration % 60);
      calculatedDurationElement.textContent = ` (${minutes}:${seconds.toString().padStart(2, '0')})`;
      return calculatedDurationElement;
    } else {
      // Remove the calculated duration if speed is back to 1x
      const calculatedDurationElement = document.querySelector('.ytp-time-duration-calculed');
      if (calculatedDurationElement) {
        calculatedDurationElement.remove();
      }
      return null;
    }
  }

  /**
   * Wait for an element using an observer
   *
   * @param {string} selector Selector to wait for
   *
   * @see https://stackoverflow.com/a/61511955
   */
  function waitForElement(selector) {
    return new Promise((resolve) => {
      if (document.querySelector(selector)) {
        return resolve(document.querySelector(selector));
      }
      const observer = new MutationObserver((_) => {
        if (document.querySelector(selector)) {
          resolve(document.querySelector(selector));
          observer.disconnect();
        }
      });
      observer.observe(document.body, {
        childList: true,
        subtree: true,
      });
    });
  }

  /**
   * Check if the current page is a playlist or a single video
   */
  function isPlaylistOrVideoPage() {
    const url = window.location.href;
    return (
      url.includes('/playlist?list=') || // Playlist page
      url.includes('/watch?v=') // Single video page
    );
  }

  // Only run the script on playlist or single video pages
  if (isPlaylistOrVideoPage()) {
    setTimeout(() => {
      if (window.location.href.includes('/playlist?list=')) {
        // Append duration to playlist metadata
        waitForElement('.yt-content-metadata-view-model-wiz__metadata-row').then(() => {
          appendDurationToPlaylistMetadata()
        });
      } else if (window.location.href.includes('/watch?v=')) {
        // Append duration to playlist header when watching a video in a playlist
        if (window.location.href.includes('list=')) {
          waitForElement('div#publisher-container').then(() => {
            setInterval(appendDurationToPlaylistHeader, 1500);
          });
        }

        // Detect playback speed and update calculated duration periodically
        setInterval(detectPlaybackSpeed, 1000);
      }
    }, 1500);
  }
})();