YouTube Timestamp Navigator & Unarchived Video Replacer

유튜브 타임스탬프 생성, 탐색 및 언아카이브 영상 대체 기능 제공

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         YouTube Timestamp Navigator & Unarchived Video Replacer
// @namespace    YouTube Timestamp Navigator & Unarchived Video Replacer
// @version      1.0
// @description  유튜브 타임스탬프 생성, 탐색 및 언아카이브 영상 대체 기능 제공
// @author       Hess
// @match        https://www.youtube.com/*
// @run-at       document-start
// @icon         https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @grant        GM_getValue
// @grant        GM_setValue
// @license      MIT
// ==/UserScript==
// https://greasyfork.org/ko/scripts/529709-youtube-timestamp-navigator-unarchived-video-replacer
(function() {
    'use strict';

    if (!GM_getValue("unarchived_videos", null)) {
        GM_setValue("unarchived_videos", {});
    }

    let currentTime;
    let video = null;
    const initializeVideoElement = () => {
        video = document.querySelector("video");
        currentTime = video.currentTime;
    };
    window.addEventListener("load", initializeVideoElement);

    const formatTime = (s) => {
        const h = Math.floor(s / 3600);
        return `${h ? `${h}:` : ''}${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
    };

    // 키 동작 매핑 객체 생성
    const keyHandlers = {
        keydown: {
            "p": () => toggleTimestampWindow(),
            "[": () => {
                const lowerTime = findRange(parseTimestamps(timestampText), currentTime).lower;
                if (lowerTime !== null) video.currentTime = lowerTime;
            },
            "]": () => {
                const upperTime = findRange(parseTimestamps(timestampText), currentTime).upper;
                if (upperTime !== null) video.currentTime = upperTime;
            },
            "'": () => {
                addTimestampButton.click();
                timestampText = timestampInput.value;
            },
            ";": () => {
                if (!isTimestampWindowOn) return;
                event.preventDefault();
                event.stopPropagation();
                (document.activeElement !== timestampInput ? timestampInput.focus() : timestampInput.blur());
            },
        },
    };

    const shouldIgnoreKeyEvent = (event) => {
        if (event.repeat || !event.isTrusted) return true;
        const activeElement = document.activeElement;
        const isTextInput = (
            activeElement.tagName.toLowerCase() === "textarea" ||
            (activeElement.tagName.toLowerCase() === "input" &&
             ["text", "password", "email", "search", "tel", "url", "number", "date", "time"].includes(activeElement.type)) ||
            activeElement.isContentEditable
        );
        return isTextInput && ![";", "'"].includes(event.key); // ;와 '는 타임스탬프 창에서 허용
    };

    const handleKeyEvent = (event) => {
        initializeVideoElement();
        if (shouldIgnoreKeyEvent(event)) return;
        // 입력창에 포커스가 있을 때도 ;랑 ' 키는 허용
        if (isTimestampWindowOn && (document.activeElement === timestampInput || document.activeElement === timestampInput2)) {
            if (event.key === ";") {
                event.preventDefault();
                if (document.activeElement !== timestampInput) {timestampInput.focus(); return;}
                else timestampInput.blur();
                return;
            }
            if (event.key === "'") {event.preventDefault(); addTimestampButton.click(); timestampText = timestampInput.value; return;}
        }
        // 이것 이외에 텍스트 입력창에 포커스가 있으면 키 입력 무시
        if (document.activeElement === timestampInput || document.activeElement === timestampInput2) return;
        keyHandlers.keydown?.[event.key]?.(event);
    };
    window.addEventListener("keydown", handleKeyEvent);

    // 포커싱 확인을 위해 빼둠
    let timestampWindow, timestampInput, timestampInput2, addTimestampButton;
    let timestampText = "";
    let isTimestampWindowOn= false;

    function toggleTimestampWindow() {
        if (!isTimestampWindowOn) {
            timestampWindow = document.createElement("div");
            timestampWindow.id = "timestampWindow";
            Object.assign(timestampWindow.style, {
                position: "fixed",
                top: "50%",
                left: "90%",
                transform: "translate(-70%, -50%)",
                width: "340px",
                height: "360px",
                backgroundColor: "rgba(50, 50, 50, 0.6)",
                border: "2px solid rgba(200, 200, 200, 0.6)",
                borderRadius: "8px",
                padding: "10px",
                boxShadow: "0 4px 8px rgba(0, 0, 0, 0.3)",
                zIndex: "1000",
                display: "flex",
                flexDirection: "column",
            });

            // 상단 버튼 바(topBar) 생성
            const topBar = document.createElement("div");
            Object.assign(topBar.style, {
                display: "flex",
                alignItems: "center",
                justifyContent: "space-between",
                marginBottom: "8px"
            });

            // 변경: 아이콘들을 담을 iconGroup 컨테이너
            const iconGroup = document.createElement("div");
            Object.assign(iconGroup.style, {
                display: "flex",
                gap: "6px",
                alignItems: "center"
            });

            // 일반 주소 타임스탬프 버튼
            const currentTimestampButton = document.createElement("button");
            currentTimestampButton.textContent = "🔗";
            currentTimestampButton.style.cssText = "background-color: #ADD8E6; color: white; border: none; border-radius: 50%; width: 24px; height: 24px; cursor: pointer;";
            currentTimestampButton.style.fontSize = `12px`;
            currentTimestampButton.style.display = "flex";
            currentTimestampButton.style.justifyContent = "center";
            currentTimestampButton.style.alignItems = "center";
            currentTimestampButton.onclick = () => copyTimestamp();

            // 입력한 시간 타임스탬프 버튼
            const customTimestampButton = document.createElement("button");
            customTimestampButton.textContent = "🕒";
            customTimestampButton.style.cssText = "background-color: #ADD8E6; color: white; border: none; border-radius: 50%; width: 24px; height: 24px; cursor: pointer;";
            customTimestampButton.style.fontSize = `12px`;
            customTimestampButton.style.display = "flex";
            customTimestampButton.style.justifyContent = "center";
            customTimestampButton.style.alignItems = "center";
            customTimestampButton.onclick = () => {
                let time = parseTimeToSeconds(timestampInput2.value);
                if (time === null || time < 0 || time > video.duration) {
                    time = Math.floor(video.currentTime);
                }
                copyTimestamp(time);
            };

            // 타임스탬프 입력 필드
            timestampInput2 = document.createElement("input"); // 한 줄 입력창
            timestampInput2.id = "timestampInput2";
            timestampInput2.type = "text";
            timestampInput2.placeholder = "";
            timestampInput2.style.cssText = "width: 50px; text-align: center; background: rgba(255, 255, 255, 0.7); border: none; border-radius: 5px; font-size: 14px;";
            timestampInput2.onmousedown = (e) => e.stopPropagation(); // 드래그 방지
            timestampInput2.tabIndex = 0; // 문서의 자연스러운 순서에 따라 포커스를 받습니다.
            Object.assign(timestampInput2.style, {
                border: "1px solid lightgray", // 테두리 스타일 추가
            });
            // 포커스 시 스타일 적용
            timestampInput2.addEventListener("focus", () => {
                Object.assign(timestampInput2.style, {
                    outline: "1px auto -webkit-focus-ring-color", // 기본 포커싱 테두리 설정
                });
            });
            // 포커스 해제 시 기본 스타일로 복원 (outline 제거)
            timestampInput2.addEventListener("blur", () => {
                timestampInput2.style.outline = "";
            });

            // 맨 아래에 현재 시간 추가 버튼
            addTimestampButton = document.createElement("button");
            addTimestampButton.textContent = "📝";
            addTimestampButton.style.cssText = "background-color: pink; color: white; border: none; border-radius: 0%; width: 24px; height: 24px; cursor: pointer;";
            addTimestampButton.style.fontSize = `12px`;
            addTimestampButton.style.display = "flex";
            addTimestampButton.style.justifyContent = "center";
            addTimestampButton.style.alignItems = "center";
            addTimestampButton.onclick = () => {
                const currentTimeText = formatTime(video.currentTime);
                const fullText = timestampInput.value; // 입력창 전체 텍스트
                const cursorPosition = timestampInput.selectionStart; // 커서 위치 가져오기
                const textBeforeCursor = fullText.slice(0, cursorPosition);
                const textAfterCursor = fullText.slice(cursorPosition);

                const linesBeforeCursor = textBeforeCursor.split("\n"); // 커서 이전의 줄
                const allLines = fullText.split("\n"); // 전체 줄

                const isCursorAtLastLine = linesBeforeCursor.length === allLines.length; // 커서가 마지막 줄에 있는지 확인
                const isLastLineEmptyOrWhitespace = allLines[allLines.length - 1].trim() === ""; // 마지막 줄이 비어있거나 여백만 있는지 확인
                const isFullTextEmptyOrWhitespace = fullText.trim() === ""; // 전체 텍스트가 비어있거나 여백만 있는지 확인

                if ((isCursorAtLastLine && isLastLineEmptyOrWhitespace) || isFullTextEmptyOrWhitespace) {
                    // 전체 텍스트가 비어있거나 마지막 줄이 여백인 경우, 엔터 없이 타임스탬프 삽입
                    timestampInput.value = allLines.slice(0, -1).join("\n") + `${isFullTextEmptyOrWhitespace ? '' : '\n'}${currentTimeText}`;
                } else {
                    // 그렇지 않으면 엔터 포함하여 타임스탬프 추가
                    timestampInput.value += `\n${currentTimeText}`;
                }
                timestampInput.scrollTop = timestampInput.scrollHeight; // 스크롤을 맨 아래로 이동
                timestampText = timestampInput.value; // 입력된 텍스트 저장
            };

            // 홀로덱스로 이동 버튼
            const goToHolodexPageButton = document.createElement("button");
            goToHolodexPageButton.style.cssText = `
    background-color: #A2CC66;
    color: white;
    border: none;
    width: 24px;
    height: 24px;
    cursor: pointer;
    display: flex;
    justify-content: center;
    align-items: center;
    background-image: url("https://i.namu.wiki/i/3yNJVcRTjZqJ7B-FuiE4alfJ3ELyYe3fzQ0oKwUuuPeAQxyyX4e2lKEhV9_lU1PyhZ48FKwQzN_OWSw39rNVMxZ9UtdhKXAP16SZFomjEjVVu5hKvahFD8cSUWQA9KrbjU-QFHIgXQkI6Z_VH5oKhw.svg");
    background-size: 70%;
    background-repeat: no-repeat;
    background-position: center;
`;

            if (window.location.href.includes('youtube.com') && !window.location.href.includes('/embed/')) {
                // 유튜브 페이지: 버튼은 기본(holodex) 아이콘 사용
                goToHolodexPageButton.style.backgroundImage = 'url("https://i.namu.wiki/i/3yNJVcRTjZqJ7B-FuiE4alfJ3ELyYe3fzQ0oKwUuuPeAQxyyX4e2lKEhV9_lU1PyhZ48FKwQzN_OWSw39rNVMxZ9UtdhKXAP16SZFomjEjVVu5hKvahFD8cSUWQA9KrbjU-QFHIgXQkI6Z_VH5oKhw.svg")';
                goToHolodexPageButton.onclick = () => {
                    const videoId = extractYouTubeVideoId(window.location.href);
                    const holodexUrl = videoId ? 'https://holodex.net/watch/' + videoId : 'https://holodex.net';
                    window.open(holodexUrl, '_blank');
                };
            } else if (window.location.href.includes('holodex.net')) {
                // holodex 페이지: 버튼을 보이게 하고, 아이콘은 유튜브 아이콘으로 변경
                goToHolodexPageButton.style.backgroundImage = 'url("https://www.google.com/s2/favicons?sz=64&domain=youtube.com")';
                goToHolodexPageButton.onclick = () => {
                    const urlInputElement = document.getElementById("urlInput");
                    const urlInputValue = urlInputElement ? normalizeYouTubeURL(urlInputElement.value) : "";
                    const videoId = extractYouTubeVideoId(urlInputValue);
                    if (videoId) {
                        const youtubeEmbedUrl = 'https://www.youtube.com/embed/' + videoId;
                        const currentHolodexUrl = 'https://holodex.net/watch/' + extractYouTubeVideoId(location.href);
                        GM_setValue(currentHolodexUrl, youtubeEmbedUrl);
                        location.href = youtubeEmbedUrl;
                    }
                };
            } else {
                // 삭제: 원래 youtube.com이 아닌 경우 버튼 숨김 처리
                goToHolodexPageButton.style.display = 'none';
            }

            // 버튼 컨테이너에 요소 추가
            iconGroup.appendChild(currentTimestampButton);
            iconGroup.appendChild(customTimestampButton);
            iconGroup.appendChild(timestampInput2);
            iconGroup.appendChild(addTimestampButton);
            iconGroup.appendChild(goToHolodexPageButton);

            // topBar 왼쪽에 iconGroup 추가
            topBar.appendChild(iconGroup);

            // 닫기 버튼
            const closeButton = document.createElement("button");
            closeButton.textContent = "X";
            Object.assign(closeButton.style, {
                position: "absolute",
                top: "11px",
                right: "10px",
                color: "white",
                backgroundColor: "red",
                border: "none",
                fontSize: "17px",
                padding: "2px 4px",
                cursor: "pointer"
            });
            closeButton.onclick = () => timestampWindow.remove();

            // topBar 오른쪽에 closeButton 추가
            topBar.appendChild(closeButton);

            // timestampWindow에 topBar 추가
            timestampWindow.appendChild(topBar);

            // 변경: 텍스트 영역을 감싸는 컨테이너 (flex)
            const textContainer = document.createElement("div");
            Object.assign(textContainer.style, {
                flex: "1",
                display: "flex",
                flexDirection: "column",
                gap: "8px"
            });

            // 추가: textarea 요소 생성 (timestampInput)
            timestampInput = document.createElement("textarea");

            // textarea 생성 (기존 timestampInput)
            timestampInput.id = "timestampInput";
            timestampInput.onmousedown = (e) => e.stopPropagation(); // 드래그 방지
            Object.assign(timestampInput.style, {
                flex: "1",
                backgroundColor: "rgba(255, 255, 255, 0.7)",
                color: "#000",
                fontWeight: "bold",
                border: "none",
                resize: "none",
                fontSize: "14px",
                padding: "12px", // 좌우 패딩을 넉넉하게
                borderRadius: "4px"
            });
            timestampInput.placeholder = "여기에 텍스트를 입력하세요.";
            timestampInput.value = timestampText;
            timestampInput.addEventListener("input", () => {
                timestampText = timestampInput.value;
            });

            // 하단에 URL 입력창 추가 (한 줄짜리 input)
            const urlInput = document.createElement("input");
            urlInput.id = "urlInput";
            urlInput.onmousedown = (e) => e.stopPropagation(); // 드래그 방지
            urlInput.type = "text";
            const savedYoutubeEmbedUrl = GM_getValue('https://holodex.net/watch/' + extractYouTubeVideoId(location.href));
            // 변경: 값이 있으면 그 값을, 없으면 비움
            urlInput.value = savedYoutubeEmbedUrl ? savedYoutubeEmbedUrl : "";
            urlInput.placeholder = "URL을 입력하세요...";
            urlInput.style.cssText = "padding: 8px; border: 1px solid lightgray; border-radius: 4px; font-size: 14px;";

            // textContainer에 텍스트 영역과 URL 입력창 추가
            textContainer.appendChild(timestampInput);
            textContainer.appendChild(urlInput);

            // 타임스탬프 창에 textContainer 추가
            timestampWindow.appendChild(textContainer);

            // 드래그 기능 추가
            let isDragging = false, startX, startY, startLeft, startTop;
            timestampWindow.onmousedown = (e) => {
                isDragging = true;
                ({ clientX: startX, clientY: startY } = e);
                ({ left: startLeft, top: startTop } = window.getComputedStyle(timestampWindow));
                document.onmousemove = ({ clientX, clientY }) => {
                    if (isDragging) {
                        timestampWindow.style.left = `${parseInt(startLeft) + clientX - startX}px`;
                        timestampWindow.style.top = `${parseInt(startTop) + clientY - startY}px`;
                    }
                };
                document.onmouseup = () => {
                    isDragging = false;
                    document.onmousemove = null;
                    document.onmouseup = null;
                };
            };
            document.body.appendChild(timestampWindow);
        } else {
            timestampWindow.remove();
        }
        isTimestampWindowOn = !isTimestampWindowOn;
    }

    // 타임스탬프 추출 함수, 초 단위로 저장
    function parseTimestamps(inputText) {
        const regex = /\b(?:\d{1,2}:)?\d{1,2}:\d{2}\b/g;
        const matches = inputText.match(regex) || [];

        return matches.map(time => {
            const parts = time.split(':').map(Number).reverse();
            let seconds = parts[0] || 0;
            let minutes = parts[1] || 0;
            let hours = parts[2] || 0;
            return seconds + minutes * 60 + hours * 3600;
        });
    }

    // 현재 시간의 lower, upper 타임스탬프로 이동
    function findRange(timestamps, inputValue) {
        const sortedTimestamps = timestamps.filter((value, index, self) => self.indexOf(value) === index).sort((a, b) => a - b);
        inputValue = Math.floor(inputValue);
        const index = sortedTimestamps.indexOf(inputValue);
        if (index !== -1) sortedTimestamps.splice(index, 1);

        for (let i = 0; i < sortedTimestamps.length; i++) {
            if (inputValue < sortedTimestamps[i]) {
                return {lower: i > 0 ? sortedTimestamps[i - 1] : null, upper: sortedTimestamps[i]};
            }
            if (inputValue === sortedTimestamps[i] && i < sortedTimestamps.length - 1) {
                return {lower: sortedTimestamps[i], upper: sortedTimestamps[i + 1]};
            }
        }
        return {lower: sortedTimestamps[sortedTimestamps.length - 1], upper: null};
    }

    // 유튜브 주소 정규화
    function normalizeYouTubeURL(url, timestamp = null) {
        try {
            const urlObj = new URL(url);
            let videoId = "";
            let timeParam = "";

            // 1. 단축 URL (youtu.be)
            if (urlObj.hostname === "youtu.be") videoId = urlObj.pathname.substring(1);
            // 2. Shorts, Embed, Live, 기본 watch URL 처리
            else if (urlObj.hostname.includes("youtube.com")) {
                const pathParts = urlObj.pathname.split("/");
                if (pathParts.includes("shorts") || pathParts.includes("embed") || pathParts.includes("live")) videoId = pathParts[pathParts.length - 1];
                else if (urlObj.pathname === "/watch") videoId = urlObj.searchParams.get("v");
            }

            // 유효한 videoId가 없으면 반환 불가
            if (!videoId) return null;

            // 3. 특정 시간 시작 옵션 유지
            if (urlObj.searchParams.has("t")) timeParam = `&t=${urlObj.searchParams.get("t")}`;
            else if (urlObj.searchParams.has("start")) timeParam = `&t=${urlObj.searchParams.get("start")}`;

            // 4. 타임스탬프가 있으면 그걸로 시작 시간 설정
            if (timestamp !== null) timeParam = `&t=${timestamp}`;

            // 5. 최종 변환된 URL 반환 (재생목록 정보 제거)
            return `https://www.youtube.com/watch?v=${videoId}${timeParam}`;
        } catch (e) {
            return null; // 잘못된 URL 입력 시
        }
    }

    function extractYouTubeVideoId(url) {
        try {
            const urlObj = new URL(url);
            let videoId = "";

            // 1. 단축 URL (youtu.be)
            if (urlObj.hostname === "youtu.be") {
                videoId = urlObj.pathname.substring(1);
            }
            // 2. Shorts, Embed, Live, 기본 watch URL 처리
            else if (urlObj.hostname.includes("youtube.com")) {
                const pathParts = urlObj.pathname.split("/");
                if (pathParts.includes("shorts") || pathParts.includes("embed") || pathParts.includes("live")) {
                    videoId = pathParts[pathParts.length - 1];
                } else if (urlObj.pathname === "/watch") {
                    videoId = urlObj.searchParams.get("v");
                }
            }
            // 유효한 videoId가 없으면 null 반환
            return videoId ? videoId : null;
        } catch (e) {
            return null; // 잘못된 URL 입력 시
        }
    }

    function copyTimestamp(time = null) {
        const url = normalizeYouTubeURL(location.href, time);
        if (url) {
            navigator.clipboard.writeText(url).then(() => {
                console.log(`Copied: ${url}`);
            }).catch(err => console.error("Failed to copy", err));
        }
    }

    // 입력된 다양한 타임스탬프를 초로 변환
    function parseTimeToSeconds(input) {
        if (!input) return null;

        // 숫자만 입력된 경우 (정수 또는 소수)
        if (/^\d+(\.\d+)?$/.test(input)) return Math.floor(parseFloat(input));

        // h, m, s 형식 (예: "1h2m3s", "2h", "45m30s")
        const hmsRegex = /^(\d+)h(?:\s*(\d+)m)?(?:\s*(\d+)s)?$|^(\d+)m(?:\s*(\d+)s)?$|^(\d+)s$/;
        const hmsMatch = input.match(hmsRegex);
        if (hmsMatch) {
            return (parseInt(hmsMatch[1] || 0, 10) * 3600) +
                (parseInt(hmsMatch[2] || hmsMatch[4] || 0, 10) * 60) +
                (parseInt(hmsMatch[3] || hmsMatch[5] || hmsMatch[6] || 0, 10));
        }

        // hh:mm:ss 또는 mm:ss 형식 (예: "1:02:03", "02:03")
        const timeRegex = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/;
        const timeMatch = input.match(timeRegex);
        if (timeMatch) {
            const hours = timeMatch[3] ? parseInt(timeMatch[1], 10) : 0;
            const minutes = timeMatch[3] ? parseInt(timeMatch[2], 10) : parseInt(timeMatch[1], 10);
            const seconds = parseInt(timeMatch[3] || timeMatch[2], 10);
            return hours * 3600 + minutes * 60 + seconds;
        }
        return null;
    }
})();