Twitch VOD Clock

Show the original live-stream clock time on Twitch VODs

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Advertisement:

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

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