Show the original live-stream clock time on Twitch VODs
// ==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();
})();