YouTube Live Time Display

Display elapsed time / real-world timestamp for live streams and live archives on YouTube

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

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

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

// ==UserScript==
// @name              YouTube Live Time Display
// @name:zh-TW        YouTube Live Time Display
// @name:zh-TW        YouTube Live Time Display
// @name:zh-TW        YouTube Live Time Display
// @namespace         https://docs.scriptcat.org/
// @version           0.0.3
// @description       Display elapsed time / real-world timestamp for live streams and live archives on YouTube
// @description:zh-TW 在 YouTube 直播與直播存檔中顯示已播放時間 / 對應的現實世界時間戳
// @description:zh-CN 在 YouTube 直播和直播存档中显示已播放时间 / 对应的现实世界时间戳
// @description:ja    YouTube のライブ配信およびライブアーカイブで、経過時間/実世界のタイムスタンプを表示
// @author            CY Fung
// @match             *://www.youtube.com/*
// @run-at            document-start
// @inject-into       page
// @allFrames         true
// @license           MIT
// ==/UserScript==

(() => {
  "use strict";

  // ===== Reference =====
  // https://greasyfork.org/en/scripts/453367-youtube-live-clock
  // credit to its author DerekHuang (https://greasyfork.org/en/users/972801-derekhuang)

  // =========================
  // Config
  // =========================
  // Choose your ideal date format by changing FORMAT value below:
  // 1: 2024/10/17 08:53:14 (default)
  // 2: 10/17/2024 08:53:14
  // 3: 17/10/2024 08:53:14
  // 4: Thu 17/10/2024 08:53:14
  // 5: Thursday 17/10/2024 08:53:14
  // 6: Thursday 17 October 2024 08:53:14
  const FORMAT = 1;

  const CSS = `
    .ytp-chrome-bottom .ytp-time-display,
    .ytp-chrome-bottom .ytp-right-controls {
      display: flex !important;
    }
    #present-time {
      margin: 0 10px 0 5px !important;
      background: var(--yt-spec-overlay-background-medium-light, rgba(0, 0, 0, .3));
      border-radius: 30px;
      padding: 0px 16px;
    }
  `;

  const rootContainerSelector = "#ytd-player, #player, #container, #movie_player";
  const timeDisplaySelector = ".ytp-chrome-bottom .ytp-time-display";
  const progressBarSelector = ".ytp-chrome-bottom .ytp-progress-bar";

  const liveBadgeSelector = ".ytp-chrome-bottom .ytp-live-badge";
  const liveBadgeNowSelector = ".ytp-chrome-bottom .ytp-live-badge.ytp-live-badge-is-livehead";

  // =========================
  // Utilities
  // =========================
  const qs = (sel, root = document) => root.querySelector(sel);
  const qsa = (sel, root = document) => [...root.querySelectorAll(sel)];

  function addStyleSheet(cssText) {
    // adoptedStyleSheets requires re-assignment since it's a FrozenArray.
    const sheet = new CSSStyleSheet();
    sheet.replaceSync(cssText);
    document.adoptedStyleSheets = document.adoptedStyleSheets.concat(sheet);
    return sheet;
  }

  const pad2 = (n) => `00${+n}`.slice(-2);

  function secondsToClock(seconds) {
    const s = seconds % 60;
    const m = Math.floor((seconds / 60) % 60);
    const h = Math.floor(seconds / 3600);
    return h > 0 ? `${h}:${pad2(m)}:${pad2(s)}` : `${m}:${pad2(s)}`;
  }

  function clockToSeconds(clock) {
    if (typeof clock !== "string") return NaN;
    const parts = clock.trim().split(":").map(Number);
    if (parts.some(Number.isNaN)) return NaN;
    if (parts.length === 2) {
      // M:SS
      const [m, s] = parts;
      return m * 60 + s;
    }
    if (parts.length === 3) {
      // H:MM:SS
      const [h, m, s] = parts;
      return h * 3600 + m * 60 + s;
    }
    return NaN;
  }

  const ABBR = {
    week: { Sun: "Sunday", Mon: "Monday", Tue: "Tuesday", Wed: "Wednesday", Thu: "Thursday", Fri: "Friday", Sat: "Saturday" },
    monthFull: { Jan: "January", Feb: "February", Mar: "March", Apr: "April", May: "May", Jun: "June", Jul: "July", Aug: "August", Sep: "September", Oct: "October", Nov: "November", Dec: "December" },
    month2: { Jan: "01", Feb: "02", Mar: "03", Apr: "04", May: "05", Jun: "06", Jul: "07", Aug: "08", Sep: "09", Oct: "10", Nov: "11", Dec: "12" }
  };

  function formatDateForDisplay(date, format = FORMAT) {
    // Keep original behavior (day not zero-padded)
    const weekShort = date.toLocaleString("en-US", { weekday: "short" }); // Sun, Mon...
    const monthShort = date.toLocaleString("en-US", { month: "short" }); // Jan, Feb...
    const day = date.getDate();
    const year = date.getFullYear();
    const time = date.toLocaleTimeString("en-US", { hour12: false });

    const mm = ABBR.month2[monthShort];
    const weekFull = ABBR.week[weekShort];
    const monthFull = ABBR.monthFull[monthShort];

    switch (format) {
      case 1: return `${year}/${mm}/${day} ${time}`;
      case 2: return `${mm}/${day}/${year} ${time}`;
      case 3: return `${day}/${mm}/${year} ${time}`;
      case 4: return `${weekShort} ${day}/${mm}/${year} ${time}`;
      case 5: return `${weekFull} ${day}/${mm}/${year} ${time}`;
      case 6: return `${weekFull} ${day} ${monthFull} ${year} ${time}`;
      default: return `${year}/${mm}/${day} ${time}`;
    }
  }

  function safeUrlToVideoId(url) {
    if (!url || typeof url !== "string") return "";
    try {
      const u = new URL(url, "https://www.youtube.com");
      const x = `${u.pathname}${u.search}`;
      if (x.startsWith("/watch?v=") || x.startsWith("/live/")) {
        return (x.match(/[\w-]{11}/) || [])[0] || "";
      }
    } catch (_e) {
      // ignore
    }
    return "";
  }

  function removeAll(selector, root = document) {
    for (const el of qsa(selector, root)) el.remove();
  }

  function isVisibleLiveBadge(rootContainer, selector) {
    const el = qs(selector, rootContainer);
    return !!(el && !el.closest("[hidden]"));
  }

  // =========================
  // Core logic
  // =========================
  addStyleSheet(CSS);

  let navObserver = null;
  let progressObserver = null;

  function disconnectObservers() {
    if (navObserver) navObserver.disconnect();
    navObserver = null;
    if (progressObserver) progressObserver.disconnect();
    progressObserver = null;
  }

  let searchKeyEventHandler;
  let searchKeyEventHandlerWrapper = (evt) => {
    if (searchKeyEventHandler) searchKeyEventHandler(evt);
  }

  function ensureClockEl({ timeDisplay, rootContainer }) {
    let el = qs("#present-time", rootContainer);
    if (!el) {
      el = timeDisplay.ownerDocument.createElement("span");
      el.id = "present-time";
      // Insert after the first child
      timeDisplay.insertBefore(el, timeDisplay.childNodes[1] || null);
      const setTime = (res, t0) => {
        if (!res) return;
        let sec = -1;
        if (res.indexOf("/") > 0) {
          const m = res.match(/\d+\/\d+\/\d+\s+\d+:[\d:.]+/);
          if (m) {
            const dt = new Date(m[0]);
            if (dt >= t0) {
              sec = (dt - t0) / 1000;
            }
          }
        } else {
          const m = res.match(/\d+:[\d:.]+/);
          if (m) {
            sec = clockToSeconds(m[0]);
          }
        }
        if (Number.isFinite(sec) && sec > -1e-8) {
          for (const s of rootContainer.querySelectorAll("video, audio")) {
            if (s.readyState > 1 && s.duration > 8 && Number.isFinite(s.currentTime)) {
              s.currentTime = sec;
              break;
            }
          }
        }
      }
      el.addEventListener("click", (evt) => {
        evt.preventDefault();
        evt.stopImmediatePropagation();
        evt.stopPropagation();
        const target = evt.target;
        const timestamp = +target.getAttribute("clock-timestamp");
        const t0 = +target.getAttribute("clock-t0");
        if (timestamp > 1000 && t0 > 1000) {
          const input = document.querySelector('yt-searchbox input[type="text"][placeholder]');
          if (!input) return;
          if (!input.hasAttribute("clock-hook")) {
            input.setAttribute("clock-hook", "");
            input.addEventListener("keydown", searchKeyEventHandlerWrapper, true);
            input.addEventListener("keyup", searchKeyEventHandlerWrapper, true);
            input.addEventListener("keypress", searchKeyEventHandlerWrapper, true);
          }
          searchKeyEventHandler = (evt) => {
            if (!evt || evt.key !== 'Enter' || evt.code !== 'Enter') return;
            const input = evt.target;
            if (!input) return;
            const val = input.value;
            if (!val || typeof val !== "string") return;
            if (!val.startsWith("!Set_Time:")) return;
            evt.preventDefault();
            evt.stopImmediatePropagation();
            evt.stopPropagation();
            setTime(val, t0);
          };
          input.value = "!Set_Time: " + target.getAttribute("clock-duration") + " OR " + formatDateForDisplay(new Date(timestamp), 1);
          setTimeout(() => input.focus(), 1);
          return;
        }
      })
    }
    return el;
  }

  function updateClock({ rootContainer, publication }) {
    const timeDisplay = qs(timeDisplaySelector, rootContainer);
    const progressBar = qs(progressBarSelector, rootContainer);
    if (!timeDisplay || !progressBar) return;

    const clockEl = ensureClockEl({ timeDisplay, rootContainer });

    const t = +progressBar.getAttribute("aria-valuenow");
    if (!Number.isFinite(t) || !publication || !publication.startDate) return;

    const t0 = new Date(publication.startDate).getTime();
    if (!(t0 > 1000) || !(t>-1e-8)) return;

    const t1 = t0 + t * 1000;
    const dateText = formatDateForDisplay(new Date(t1));

    // Determine "live head" / "live" states
    let isLiveHead = isVisibleLiveBadge(rootContainer, liveBadgeNowSelector);
    let isLive = isLiveHead || isVisibleLiveBadge(rootContainer, liveBadgeSelector);

    // If the computed present-time is close to now, treat as live
    if (!isLiveHead && Math.abs(t1 - Date.now()) < 8000) {
      isLiveHead = true;
      isLive = true;
    }
    const durationText = `${secondsToClock(t)}`;
    clockEl.setAttribute("clock-duration", durationText.trim());
    clockEl.setAttribute("clock-timestamp", `${t1}`);
    clockEl.setAttribute("clock-t0", `${t0}`);

    if (isLiveHead) {
      clockEl.textContent = `${durationText}`;
    } else if (isLive) {
      clockEl.textContent = `${durationText} (${dateText})`;
    } else {
      clockEl.textContent = `${dateText}`;
    }
  }

  function getMatchedMicroformatJsonForVid(vid) {
    // Original logic: iterate scripts under #microformat and parse JSON.
    const scripts = qsa("#microformat script");
    for (const script of scripts) {
      const text = script.textContent;
      if (!text || text.length < 9) continue;

      let obj;
      try {
        obj = JSON.parse(text);
      } catch {
        continue;
      }
      if (!obj || typeof obj !== "object") continue;

      for (const value of Object.values(obj)) {
        const jsonVid = safeUrlToVideoId(value);
        if (jsonVid && jsonVid === vid) return obj;
      }
    }
    return null;
  }

  function waitForPlayerAndMicroformat(vid) {
    return new Promise((resolve) => {
      navObserver = new MutationObserver((mutations, observer) => {
        const timeDisplay = qs(timeDisplaySelector);
        const progressBar = qs(progressBarSelector);
        if (!timeDisplay || !progressBar) return;

        const jsonObject = getMatchedMicroformatJsonForVid(vid);
        if (!jsonObject) return;

        // done
        disconnectObservers();
        observer.disconnect();

        resolve({ jsonObject, progressBar });
      });

      navObserver.observe(document.body, { attributes: true, childList: true, subtree: true });
    });
  }

  function pickPublication(jsonObject) {
    const pubs = (jsonObject || 0).publication;
    if (!pubs) return null;
    const arr = Array.isArray(pubs) ? pubs : [...pubs];
    return arr.filter((p) => (p || 0).startDate || (p || 0).endDate)[0] || null;
  }

  async function main(vid) {
    const { jsonObject, progressBar } = await waitForPlayerAndMicroformat(vid);

    const publication = pickPublication(jsonObject);
    if (!publication) {
      removeAll("#present-time");
      return;
    }

    progressObserver = new MutationObserver((mutations) => {
      for (const m of mutations) {
        const target = (m || 0).target;
        if (!target || target.isConnected !== true || typeof target.closest !== "function") continue;
        if (target.closest("[hidden]")) continue;

        const rootContainer = target.closest(rootContainerSelector);
        if (!rootContainer) continue;

        updateClock({ rootContainer, publication });
        break;
      }
    });

    progressObserver.observe(progressBar, {
      characterData: true,
      attributeFilter: ["aria-valuenow", "mut-dummy"]
    });

    // Force at least one mutation so the observer runs once
    const rid = Math.floor(Math.random() * 2251799813685248) + 2251799813685248;
    progressBar.setAttribute("mut-dummy", `${Date.now()}_${rid}`);
  }

  // =========================
  // Navigation hook
  // =========================
  document.addEventListener("yt-navigate-finish", (event) => {
    try {
      const url = event.detail.endpoint.commandMetadata.webCommandMetadata.url;
      const vid = safeUrlToVideoId(url);
      if (vid) main(vid);
    } catch (_e) {
      // ignored
    }
  });
})();