YouTube Live Time Display

在 YouTube 直播和直播存档中显示已播放时间 / 对应的现实世界时间戳

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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.2
// @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;
    }
  `;

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