YouTube Timestamp Navigator & Unarchived Video Replacer

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

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