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