Twitch VOD Clock

Show the original live-stream clock time on Twitch VODs

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(Tôi đã có Trình quản lý tập lệnh người dùng, hãy cài đặt nó!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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