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

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

目前為 2025-03-17 提交的版本,檢視 最新版本

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

(function() {
    'use strict';

    // 히든 클래스명 및 스타일 설정
    const classNameForHiddenElement = 'hidden-by-chzzk-vod-realtime-indicator';

    const styleToAdd = document.createElement('style');
    styleToAdd.textContent = `
  .${classNameForHiddenElement}:not(.pzp-seeking-preview__time:not(.real-time)) {
    display: none !important;
  }

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

  .${classNameForHiddenElement}.pzp-seeking-preview__time:not(.real-time) {
    visibility: hidden;
  }
`;
    document.head.appendChild(styleToAdd);

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

    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-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 + 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);

        if (realTimeMode) {
            originalvodTimeDoms.forEach(dom => {
                dom?.classList.add(classNameForHiddenElement);
            });

            realTimeDoms.forEach(dom => {
                dom?.classList.remove(classNameForHiddenElement);
            });
        } else {
            originalvodTimeDoms.forEach(dom => {
                dom?.classList.remove(classNameForHiddenElement);
            });

            realTimeDoms.forEach(dom => {
                dom?.classList.add(classNameForHiddenElement);
            });
        }
    }

    // 재생바 이동할 때 나오는 시간이 바뀌면
    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;
        }));
    }

    // 요소가 동적으로 추가되면 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;
        liveOpenMs = null;

        toggleMode(false);
        resetDoms();
    }

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

        resetDoms();
        setDoms();
    }

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

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

        const apiLink = getApiLink(videoNo);

        fetch(apiLink)
            .then((response) => {
            if (!response.ok) {
                throw new Error(`HTTP error! status: ${response.status}`);
            }
            return response.json();
        })
            .then((responseData) => {
            const { liveOpenDate } = responseData.content;

            if (!liveOpenDate) { // 업로드한 영상의 VOD 페이지일 때.
                onEnterNotLiveVodPage();
                return;
            }

            liveOpenMs = new Date(liveOpenDate).getTime();

            onEnterLiveVodPage();
        })
            .catch((error) => {
            console.error('Fetch error:', error);
        });
    }

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

    onEnterPage(lastLink);
})();

QingJ © 2025

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