Twitch VOD Clock

Show the original live-stream clock time on Twitch VODs

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

Advertisement:

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

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