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.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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);
  }
})();