Greasy Fork 还支持 简体中文。

YouTube Live Time Display

在 YouTube 直播與直播存檔中顯示已播放時間 / 對應的現實世界時間戳

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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
    }
  });
})();