치지직 다시보기 실제 시각 토글

치지직 다시보기의 표시 시각 클릭 시 실제 라이브 당시 시각으로 토글

// ==UserScript==
// @name         치지직 다시보기 실제 시각 토글
// @namespace    https://chzzk.naver.com/
// @version      0.2.3
// @description  치지직 다시보기의 표시 시각 클릭 시 실제 라이브 당시 시각으로 토글
// @author       noipung
// @match        https://chzzk.naver.com/*
// @match        https://*.chzzk.naver.com/*
// @grant        GM_addStyle
// @license      MIT
// ==/UserScript==

(function () {
  "use strict";

  // 히든 클래스명 및 스타일 설정
  const HIDDEN_CLASS_NAME = "hidden-by-chzzk-vod-realtime-toggle";

  GM_addStyle(`
.${HIDDEN_CLASS_NAME} {
  display: none !important;
}

.pzp-vod-time:not(.pip_mode *) {
  cursor: pointer;
}

.pzp-seeking-preview--no-sprite .pzp-seeking-preview__time:not(.real-time).${HIDDEN_CLASS_NAME} {
  display: inherit !important;
  visibility: hidden;
}`);

  // 변수 설정
  let lastLink = window.location.href;
  let videoCount = 0;
  let liveOpenMs = null;
  let isLast = false;
  let isLiveVod = false;
  let previewObserver = null;

  const done = { prev: false, next: false };

  const MAX_DURATION = 61200;
  const videoNoMatcher = /(?<=https:\/\/chzzk.naver.com\/video\/)\d+/;
  const getApiLink = (videoNo) =>
    `https://api.chzzk.naver.com/service/v2/videos/${videoNo}`;

  // 기존 요소들, dom은 동적으로 추가될 때마다 함수로 할당
  const elements = {
    vodTimeContainer: { selector: ".pzp-vod-time", dom: null }, // vod 시간 표시 관련 요소들
    vodCurrentTime: { selector: ".pzp-vod-time__current-time", dom: null },
    vodBar: { selector: ".pzp-vod-time__bar", dom: null },
    vodDuration: { selector: ".pzp-vod-time__duration", dom: null },
    previewTimeContainer: {
      selector: ".pzp-seeking-preview__description",
      dom: null,
    }, // vod 프리뷰 시간 표시 관련 요소들
    previewTime: { selector: ".pzp-seeking-preview__time", dom: null },
    player: { selector: ".webplayer-internal-video", dom: null }, // 비디오 플레이어
  };

  // 추가할 실제시각 요소
  const elementsToAdd = {
    vodRealTime: {
      tag: "span",
      classList: ["pzp-ui-text", "pzp-vod-time__current-time", "real-time"],
      dom: null,
      parent: elements.vodTimeContainer,
    },
    previewRealTime: {
      tag: "div",
      classList: ["pzp-seeking-preview__time", "real-time"],
      dom: null,
      parent: elements.previewTimeContainer,
    },
  };

  // (hh:)mm:ss => ms
  const timeStringToMs = (str) =>
    str
      .split(":")
      .reduce(
        (sum, n, i, arr) => sum + n * 60 ** (arr.length - i - 1) * 1000,
        0
      );

  // Date => m월 n일 오전/오후 hh:mm:ss
  const dateToStrings = (date) => [
    date.toLocaleString("ko", {
      month: "short",
      day: "numeric",
    }),
    date.toLocaleString("ko", {
      hour: "numeric",
      minute: "numeric",
      second: "numeric",
      hour12: true,
    }),
  ];

  let lastTimeString = null;

  // (hh:)mm:ss => m월 n일 오전/오후 hh:mm:ss
  const getRealTimeStrings = (timeString) => {
    const realTimeDate = new Date(
      liveOpenMs + videoCount * MAX_DURATION * 1000 + timeStringToMs(timeString)
    );
    return dateToStrings(realTimeDate);
  };

  // 재생으로 인해 영상 시간이 바뀔 때
  const onTimeUpdate = () => {
    const currentTimeString = elements.vodCurrentTime.dom.textContent;

    if (lastTimeString === currentTimeString) return;

    lastTimeString = currentTimeString;

    elementsToAdd.vodRealTime.dom.textContent =
      getRealTimeStrings(currentTimeString).join(" ");
  };

  let realTimeMode = false;

  // 영상 시각 <=> 실제 시각 전환
  const toggleMode = (bool) => {
    realTimeMode = typeof bool === "boolean" ? bool : !realTimeMode;

    const originalvodTimeDoms = [
      "vodCurrentTime",
      "vodBar",
      "vodDuration",
      "previewTime",
    ].map((key) => elements[key].dom);

    const realTimeDoms = ["vodRealTime", "previewRealTime"].map(
      (key) => elementsToAdd[key].dom
    );

    originalvodTimeDoms.forEach((dom) => {
      dom.classList.toggle(HIDDEN_CLASS_NAME, realTimeMode);
    });

    realTimeDoms.forEach((dom) => {
      dom.classList.toggle(HIDDEN_CLASS_NAME, !realTimeMode);
    });
  };

  // 재생바 이동할 때 나오는 시간이 바뀌면
  const onPreviewChange = () => {
    const handleMutations = ([mutation]) => {
      const { nodeValue } = mutation.target;
      const previewTimeString = elements.previewTime.dom.textContent;
      elementsToAdd.previewRealTime.dom.textContent =
        getRealTimeStrings(previewTimeString).join(" ");
    };

    previewObserver = new MutationObserver(handleMutations);

    previewObserver.observe(elements.previewTime.dom, {
      characterData: true,
      subtree: true,
    });
  };

  const onPlayerCanPlay = () => {
    Object.keys(elements).forEach((key) => {
      const currentDom = document.querySelector(elements[key].selector);
      if (elements[key].dom === currentDom) return;
      elements[key].dom = currentDom;
    });

    Object.keys(elementsToAdd).forEach((key) => {
      const element = elementsToAdd[key];
      element.dom = document.createElement(element.tag);
      element.dom.classList.add(...element.classList);
      element.parent.dom.append(element.dom);
    });

    toggleMode(false);

    elements.vodTimeContainer.dom.addEventListener("pointerdown", toggleMode);
    elements.player.dom.addEventListener("timeupdate", onTimeUpdate);

    onPreviewChange();
  };

  // 모든 dom이 할당 되었을 때 실행
  const onSetDoms = () => {
    const playerDom = elements.player.dom;

    if (playerDom.readyState >= 4) onPlayerCanPlay();
    else playerDom.addEventListener("canplay", onPlayerCanPlay, { once: true });
  };

  // 모든 dom들 null로 초기화
  const resetDoms = () => {
    if (previewObserver) {
      previewObserver.disconnect();
      previewObserver = null;
    }

    elements.vodTimeContainer.dom?.removeEventListener(
      "pointerdown",
      toggleMode
    );
    elements.player.dom?.removeEventListener("timeupdate", onTimeUpdate);

    [elements, elementsToAdd].forEach((obj) =>
      Object.keys(obj).forEach((key) => {
        obj[key].dom = null;
      })
    );
  };

  const resetVariables = () => {
    liveOpenMs = null;
    isLast = false;
    videoCount = 0;
    done.prev = false;
    done.next = false;
  };

  // 요소가 동적으로 추가되면 elements 객체의 각 dom에 할당
  const setDoms = () => {
    const observer = new MutationObserver((mutations) => {
      // 남은 요소 추적
      const remainingKeys = Object.keys(elements).filter(
        (key) => !elements[key].dom
      );

      // 모든 요소를 찾은 경우 관찰 중지
      if (!remainingKeys.length) {
        onSetDoms();
        observer.disconnect();
        return;
      }

      // 각 선택자에 대해 문서 전체 검색
      let allFound = true;
      for (const key of remainingKeys) {
        const { selector } = elements[key];
        const element = document.querySelector(selector);

        if (element) elements[key].dom = element;
        else allFound = false;
      }

      // 모든 요소 발견 시 즉시 종료
      if (allFound) {
        onSetDoms();
        observer.disconnect();
      }
    });

    // 초기 검색 수행
    const initialKeys = Object.keys(elements).filter(
      (key) => !elements[key].dom
    );
    initialKeys.forEach((key) => {
      elements[key].dom = document.querySelector(elements[key].selector);
    });

    // 변경 관찰 시작
    observer.observe(document.body, {
      childList: true,
      subtree: true,
    });
  };

  // 라이브 다시보기 VOD 페이지가 아닌 페이지로 들어갈 때
  const onEnterNotLiveVodPage = () => {
    if (!isLiveVod) return;

    isLiveVod = false;

    toggleMode(false);
    resetDoms();
  };

  // 라이브 다시보기 VOD 페이지로 들어갈 때
  const onEnterLiveVodPage = () => {
    isLiveVod = true;

    resetDoms();
    setDoms();
  };

  const getData = async (apiLink) => {
    try {
      const response = await fetch(apiLink);

      if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);

      return await response.json();
    } catch (error) {
      console.error("Fetch error:", error);
      throw error;
    }
  };

  const getApiData = async (apiLink, arrow) => {
    const data = await getData(apiLink);
    const { livePv, duration, liveOpenDate, prevVideo, nextVideo } =
      data.content;

    if (!liveOpenDate) {
      onEnterNotLiveVodPage();
      return;
    }

    if (!liveOpenMs) {
      liveOpenMs = new Date(liveOpenDate).getTime();
      isLast = duration === MAX_DURATION;
    }

    if (arrow !== 1) {
      if (!isLast && prevVideo && livePv === prevVideo.livePv) {
        videoCount++;
        const apiLink = getApiLink(prevVideo.videoNo);
        getApiData(apiLink, -1);
      } else {
        done.prev = true;
        if (done.next) onEnterLiveVodPage();
      }
    }

    if (arrow !== -1) {
      if (nextVideo && livePv === nextVideo.livePv) {
        if (nextVideo.duration === MAX_DURATION) videoCount++;
        const apiLink = getApiLink(nextVideo.videoNo);
        getApiData(apiLink, 1);
      } else {
        done.next = true;
        if (done.prev) onEnterLiveVodPage();
      }
    }
  };

  // 페이지로 들어갈 때
  const onEnterPage = (lastLink) => {
    const [videoNo] = lastLink.match(videoNoMatcher) || [];

    resetVariables();

    if (!videoNo) {
      // VOD 페이지가 아닐 때.
      onEnterNotLiveVodPage();
      return;
    }

    const apiLink = getApiLink(videoNo);

    getApiData(apiLink);
  };

  window.navigation.addEventListener("navigate", (e) => {
    lastLink = e.destination.url;
    onEnterPage(lastLink);
  });

  onEnterPage(lastLink);
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址