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