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