// ==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 // @downloadURL none // ==/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); })();