Twitch VOD Clock

Show the original live-stream clock time on Twitch VODs

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey, Greasemonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да инсталирате разширение, като например Tampermonkey .

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Userscripts.

За да инсталирате скрипта, трябва да инсталирате разширение като Tampermonkey.

За да инсталирате този скрипт, трябва да имате инсталиран скриптов мениджър.

(Вече имам скриптов мениджър, искам да го инсталирам!)

Advertisement:

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

(Вече имам инсталиран мениджър на стиловете, искам да го инсталирам!)

Advertisement:

// ==UserScript==
// @name         Twitch VOD Clock
// @namespace    https://www.twitch.tv/
// @version      1.0.3
// @description  Show the original live-stream clock time on Twitch VODs
// @license      MIT
// @match        https://www.twitch.tv/*
// @run-at       document-idle
// @grant        none
// ==/UserScript==

(() => {
  "use strict";
    
  // Third-party endpoint to resolve Twitch VOD start times. Source:
  // https://chromewebstore.google.com/detail/twitch-live-clock/ieglaglfpddaaibpbafbbbikjpdkhgfi
  const API_URL = "https://usa6k2nlql.execute-api.us-east-1.amazonaws.com/start-time";
  const VIDEO_PATH_PATTERN = /^\/videos\/([0-9]+)$/;
  const URL_CHECK_INTERVAL_MS = 500;
  const PLAYER_CHECK_INTERVAL_MS = 500;

  // Format used for the live clock next to the player time.
  // Set to null to disable the live clock.
  // Tokens: YYYY, YY, MM, DD, HH, mm, ss
  const LIVE_TIMESTAMP_DATE_FORMAT = "YYYY-MM-DD HH:mm:ss";

  // Format used for the metadata label timestamp.
  // Set to null to leave Twitch's original metadata label unchanged.
  // Use {TWITCH} to include Twitch's original label, e.g. "5 days ago".
  // Tokens: {TWITCH}, YYYY, YY, MM, DD, HH, mm, ss
  const LABEL_TIMESTAMP_DATE_FORMAT = "{TWITCH}, YYYY-MM-DD HH:mm";

  let currentClock = null;
  let currentTimestampLabel = null;
  let previousURL = document.location.href;

  const getVideoIdFromUrl = (url) => {
    const match = url.pathname.match(VIDEO_PATH_PATTERN);
    return match ? match[1] : null;
  };

  const getStartTime = async (videoId) => {
    const url = new URL(API_URL);
    url.searchParams.append("videoId", videoId);

    const response = await fetch(url.toString());
    if (!response.ok) {
      return null;
    }

    const body = await response.text();
    return body ? new Date(body) : null;
  };

  const parseWatchTime = (text) => {
    const parts = text.split(":").map((part) => Number.parseInt(part, 10));

    if (parts.some(Number.isNaN)) {
      return null;
    }

    if (parts.length === 2) {
      return { hours: 0, minutes: parts[0], seconds: parts[1] };
    }

    if (parts.length === 3) {
      return { hours: parts[0], minutes: parts[1], seconds: parts[2] };
    }

    return null;
  };

  const addWatchTime = (startTime, watchTime) =>
    new Date(
      startTime.getTime() +
        watchTime.hours * 60 * 60 * 1000 +
        watchTime.minutes * 60 * 1000 +
        watchTime.seconds * 1000
    );

  const pad = (value) => String(value).padStart(2, "0");

  const formatDateTime = (date, format) =>
    format.replace(/YYYY|YY|MM|DD|HH|mm|ss/g, (token) => {
      const tokens = {
        YYYY: String(date.getFullYear()),
        YY: pad(date.getFullYear() % 100),
        MM: pad(date.getMonth() + 1),
        DD: pad(date.getDate()),
        HH: pad(date.getHours()),
        mm: pad(date.getMinutes()),
        ss: pad(date.getSeconds()),
      };

      return tokens[token];
    });

  const formatLabelDateTime = (date, twitchLabel) =>
    formatDateTime(date, LABEL_TIMESTAMP_DATE_FORMAT).replaceAll(
      "{TWITCH}",
      twitchLabel
    );

  const copyClasses = (source, target) => {
    source.classList.forEach((className) => {
      target.classList.add(className);
    });
  };

  const getTimestampLabelElement = () => {
    const timestampBars = document.getElementsByClassName("timestamp-metadata__bar");
    if (!timestampBars[0]?.parentElement) {
      return null;
    }

    return (
      Array.from(timestampBars[0].parentElement.children).find(
        (element) => element.nodeName === "P"
      ) ?? null
    );
  };

  class TimestampLabel {
    constructor(startTime) {
      this.startTime = startTime;
      this.observer = null;
      this.isUnmounted = false;
    }

    mount() {
      if (this.update()) {
        return;
      }

      this.observer = new MutationObserver(() => {
        if (this.update()) {
          this.observer?.disconnect();
        }
      });

      this.observer.observe(document.body, {
        childList: true,
        subtree: true,
      });
    }

    unmount() {
      this.isUnmounted = true;
      this.observer?.disconnect();
    }

    update() {
      if (this.isUnmounted) {
        return false;
      }

      const element = getTimestampLabelElement();
      if (!element) {
        return false;
      }

      const currentText = element.textContent.trim();
      const previousText = element.dataset.twitchLiveClockText;
      const previousTwitchText = element.dataset.twitchLiveClockTwitchText;
      const twitchText =
        previousText && currentText === previousText && previousTwitchText
          ? previousTwitchText
          : currentText;
      const nextText = formatLabelDateTime(this.startTime, twitchText);

      if (currentText === nextText) {
        return true;
      }

      element.dataset.twitchLiveClockTwitchText = twitchText;
      element.dataset.twitchLiveClockText = nextText;
      element.textContent = nextText;
      return true;
    }
  }

  class Clock {
    constructor(startTime) {
      this.startTime = startTime;
      this.clockElements = [];
      this.watchTimeElements = [];
      this.observer = null;
      this.isUnmounted = false;
    }

    async mountWhenReady() {
      await this.waitForPlayerReady();

      if (this.isUnmounted) {
        return;
      }

      this.insertClock();
      this.observer = new MutationObserver(() => {
        this.updateClock();
      });

      if (this.watchTimeElements.length > 0) {
        this.observer.observe(this.watchTimeElements[0], {
          subtree: true,
          characterData: true,
        });
      }
    }

    unmount() {
      this.isUnmounted = true;
      this.clockElements.forEach((element) => {
        element.remove();
      });
      this.observer?.disconnect();
    }

    waitForPlayerReady() {
      return new Promise((resolve) => {
        const check = () => {
          if (this.isUnmounted) {
            resolve();
            return;
          }

          this.watchTimeElements = Array.from(
            document.querySelectorAll('[data-a-target="player-seekbar-current-time"]')
          );

          if (this.watchTimeElements.length > 0) {
            resolve();
            return;
          }

          window.setTimeout(check, PLAYER_CHECK_INTERVAL_MS);
        };

        check();
      });
    }

    insertClock() {
      this.watchTimeElements.forEach((watchTimeElement) => {
        const clockElement = document.createElement("p");
        const parent = watchTimeElement.parentElement;
        const nextElement = watchTimeElement.nextElementSibling;

        this.clockElements.push(clockElement);
        copyClasses(watchTimeElement, clockElement);

        if (
          parent &&
          nextElement &&
          nextElement.getAttribute("data-a-target") === "player-seekbar-duration"
        ) {
          parent.insertBefore(clockElement, nextElement);
        }
      });

      this.updateClock();
    }

    updateClock() {
      if (this.watchTimeElements.length === 0) {
        return;
      }

      const watchTime = parseWatchTime(this.watchTimeElements[0].innerText);
      if (!watchTime) {
        return;
      }

      const streamedTime = addWatchTime(this.startTime, watchTime);
      this.clockElements.forEach((element) => {
        element.textContent = formatDateTime(streamedTime, LIVE_TIMESTAMP_DATE_FORMAT);
      });
    }
  }

  const mount = async () => {
    currentClock?.unmount();
    currentClock = null;
    currentTimestampLabel?.unmount();
    currentTimestampLabel = null;

    const videoId = getVideoIdFromUrl(new URL(window.location.href));
    if (!videoId) {
      return;
    }

    const startTime = await getStartTime(videoId);
    if (!startTime || Number.isNaN(startTime.getTime())) {
      return;
    }

    if (LABEL_TIMESTAMP_DATE_FORMAT) {
      currentTimestampLabel = new TimestampLabel(startTime);
      currentTimestampLabel.mount();
    }

    if (LIVE_TIMESTAMP_DATE_FORMAT) {
      currentClock = new Clock(startTime);
      await currentClock.mountWhenReady();
    }
  };

  const observeURLChanges = () => {
    const check = () => {
      const currentURL = document.location.href;
      if (currentURL !== previousURL) {
        previousURL = currentURL;
        mount();
      }

      window.setTimeout(check, URL_CHECK_INTERVAL_MS);
    };

    check();
  };

  observeURLChanges();
  mount();
})();