SOOP Time Recorder

SOOP 라이브 및 VOD에서 타임스탬프 기록 및 관리

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         SOOP Time Recorder
// @namespace    http://tampermonkey.net/
// @version      1.1
// @description  SOOP 라이브 및 VOD에서 타임스탬프 기록 및 관리
// @author       result41
// @match        https://play.sooplive.co.kr/*
// @match        https://stbbs.sooplive.co.kr/vodclip/index.php/*
// @match        https://vod.sooplive.co.kr/*
// @grant        GM_setValue
// @grant        GM_getValue
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    let historyWindow = null;
    const HOST = window.location.host;
    const IS_CLIP_POPUP = HOST === 'stbbs.sooplive.co.kr';
    const IS_VOD = HOST === 'vod.sooplive.co.kr';

    function showToast(message) {
        const existingToast = document.getElementById('soop_custom_toast');
        if (existingToast) { existingToast.remove(); }
        if (IS_CLIP_POPUP) return;
        const toast = document.createElement('div');
        toast.id = 'soop_custom_toast';
        toast.style.position = 'fixed';
        toast.style.bottom = '100px';
        toast.style.left = '50%';
        toast.style.transform = 'translateX(-50%)';
        toast.style.backgroundColor = 'rgba(0, 0, 0, 0.7)';
        toast.style.color = '#fff';
        toast.style.padding = '10px 20px';
        toast.style.borderRadius = '5px';
        toast.style.zIndex = '999999';
        toast.style.opacity = '0';
        toast.style.transition = 'opacity 0.3s ease-in-out';
        toast.style.fontSize = '14px';
        toast.innerText = message;
        document.documentElement.appendChild(toast);
        setTimeout(() => { toast.style.opacity = '1'; }, 10);
        setTimeout(() => {
            toast.style.opacity = '0';
            toast.addEventListener('transitionend', () => {
                if (toast.parentNode) { toast.parentNode.removeChild(toast); }
            });
        }, 3000);
    }

    // --- 통합 BJ ID 추출 함수 ---
    function getCurrentBjId() {
        // 1. VOD 페이지 로직 (썸네일 박스 href 파싱)
        if (IS_VOD) {
            const thumbLink = document.querySelector('.thumbnail_box .thumb');
            if (thumbLink && thumbLink.href) {
                // 예: https://www.sooplive.co.kr/station/songhy -> songhy 추출
                const parts = thumbLink.href.split('/').filter(part => part.length > 0);
                return parts[parts.length - 1];
            }
            return null;
        }

        // 2. 클립 팝업 (URL 파라미터)
        const urlMatch = window.location.href.match(/bj_id=([^&]+)/);
        if (urlMatch && urlMatch[1]) {
            return urlMatch[1];
        }

        // 3. 라이브 플레이어 (URL 경로)
        const pathMatch = window.location.pathname.match(/^\/([^\/]+)\/\d+/);
        if (pathMatch && pathMatch[1]) {
            return pathMatch[1];
        }

        return null;
    }

    function getStorageKey(bjId) {
        return `soop_record_${bjId || 'default'}`;
    }

    function getCleanedHistory(bjId) {
        const key = getStorageKey(bjId);
        let storedData = JSON.parse(GM_getValue(key, '[]'));
        if (!Array.isArray(storedData)) { storedData = []; }
        const now = Date.now();
        const validHistory = storedData.filter(item => {
            return !item.expiry || item.expiry > now;
        });
        if (validHistory.length !== storedData.length) {
            GM_setValue(key, JSON.stringify(validHistory));
        }
        return validHistory;
    }

    function setHistory(bjId, historyArray) {
        const key = getStorageKey(bjId);
        GM_setValue(key, JSON.stringify(historyArray));
    }

    function deleteHistory() {
        if (!confirm("정말 모든 기록을 삭제하시겠습니까?")) {
            return;
        }

        const bjId = getCurrentBjId();
        const key = getStorageKey(bjId);

        GM_setValue(key, null);

        showToast(`✔ 기록이 모두 삭제되었습니다.`);

        if (historyWindow && !historyWindow.closed) {
            historyWindow.close();
            historyWindow = null;
        }
    }
    // ---------------------------------------------


    if (IS_CLIP_POPUP) {
        function waitForPopupElements() {
            const bjIdQuery = getCurrentBjId();
            if (!bjIdQuery) return;
            let attempts = 0;
            const maxAttempts = 20;
            const interval = setInterval(() => {
                const titleElement = document.querySelector('.u_clip_title ');
                attempts++;
                if (titleElement) {
                    clearInterval(interval);
                    displayClipTimer(bjIdQuery, titleElement);
                } else if (attempts >= maxAttempts) {
                    clearInterval(interval);
                }
            }, 500);
        }

        function displayClipTimer(bjId, titleElement) {
            const history = getCleanedHistory(bjId);
            if (history.length === 0) return;
            const latestRecord = history[history.length - 1];
            if (!latestRecord.expiry) return;
            const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
            const savedTimeMs = latestRecord.expiry - THIRTY_DAYS_MS;
            const now = Date.now();
            const TEN_MINUTES_MS = 10 * 60 * 1000;
            const timeElapsed = now - savedTimeMs;

            if (timeElapsed < TEN_MINUTES_MS) {
                const timeLeftMs = TEN_MINUTES_MS - timeElapsed;
                const minutes = Math.floor(timeLeftMs / 60000);
                const seconds = Math.floor((timeLeftMs % 60000) / 1000);
                const timeString = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;

                const timerSpan = document.createElement('span');
                timerSpan.style.marginRight = '10px';
                timerSpan.style.fontWeight = 'bold';
                timerSpan.style.color = '#8e958c';
                timerSpan.innerText = `⏳ 마지막 Record 시간: ${timeString}`;

                titleElement.parentNode.insertBefore(timerSpan, titleElement);
            }
        }
        waitForPopupElements();
        return;
    }


    // --- 메인 및 VOD 페이지 로직 ---
    const MAX_ATTEMPTS = 40;
    let attempts = 0;

    const loadInterval = setInterval(() => {
        if (attempts >= MAX_ATTEMPTS) {
            clearInterval(loadInterval);
            return;
        }

        // 1. VOD 페이지 로직
        if (IS_VOD) {
            // player_item_list 클래스를 가진 모든 요소를 찾음 (보통 2개)
            const playerLists = document.querySelectorAll('.player_item_list');
            if (playerLists.length > 0) {
                let injectedCount = 0;
                playerLists.forEach(list => {
                    // 각 리스트에 버튼 컨테이너가 이미 있는지 확인 (클래스로 확인)
                    if (!list.querySelector('.soop_custom_buttons_vod')) {
                        createButtons(list, 'vod_page');
                    }
                    if (list.querySelector('.soop_custom_buttons_vod')) {
                        injectedCount++;
                    }
                });

                // 찾은 리스트 모두에 삽입되었으면 인터벌 종료
                if (injectedCount === playerLists.length) {
                    clearInterval(loadInterval);
                    return;
                }
            }
        }
        // 2. 라이브 플레이어 로직
        else {
            const broadState = document.querySelector('#broadInfo #broadState');
            if (broadState && !document.getElementById('soop_custom_buttons')) {
                clearInterval(loadInterval);
                createButtons(broadState, 'broadState');
                return;
            }

            const broadcastArea = document.querySelector('#broadcastArea');
            if (broadcastArea && !document.getElementById('soop_custom_buttons')) {
                if (!broadState) {
                    clearInterval(loadInterval);
                    createButtons(broadcastArea, 'broadcastArea');
                    return;
                }
            }
        }

        attempts++;
    }, 500);
    // ----------------------------------------------------


    window.addEventListener('message', (event) => {
        if (!event.data || event.data.source !== 'soop_time_recorder_popup') {
            return;
        }

        const { action, value, historyUpdates, id, newTime } = event.data;
        const bjId = getCurrentBjId();

        switch (action) {
            case 'SAVE_ALL_MEMOS':
                if (historyUpdates && Array.isArray(historyUpdates)) {
                    const currentHistory = getCleanedHistory(bjId);
                    historyUpdates.forEach(update => {
                        const index = currentHistory.findIndex(item => item.id === update.id);
                        if (index > -1) {
                            currentHistory[index].memo = update.memo;
                        }
                    });
                    setHistory(bjId, currentHistory);

                    if (value === 'COPYING') {
                        if (historyWindow && !historyWindow.closed && historyWindow.startCopy) {
                            historyWindow.startCopy();
                        }
                    }
                }
                break;

            case 'DELETE_SINGLE':
                if (id) {
                    let currentHistory = getCleanedHistory(bjId);
                    const originalLength = currentHistory.length;
                    currentHistory = currentHistory.filter(item => item.id !== id);
                    if (currentHistory.length !== originalLength) {
                        setHistory(bjId, currentHistory);
                        showToast("🗑️ 항목이 삭제되었습니다.");
                    }
                }
                break;

            case 'UPDATE_TIME':
                if (id && newTime) {
                    const currentHistory = getCleanedHistory(bjId);
                    const index = currentHistory.findIndex(item => item.id === id);
                    if (index > -1) {
                        currentHistory[index].videoTime = newTime;
                        setHistory(bjId, currentHistory);
                        showToast(`✏️ 시간이 수정되었습니다: ${newTime}`);
                    }
                }
                break;
        }
    });
    // ----------------------------------------------------


    // --- 버튼 및 팝업 관련 함수 정의 ---
    function createButtons(targetElement, insertType) {
        const container = document.createElement('span');

        const btnRecord = document.createElement('button');
        btnRecord.innerText = 'Record';
        styleButton(btnRecord, '#4CAF50');
        btnRecord.onclick = recordTime;

        const btnHistory = document.createElement('button');
        btnHistory.innerText = 'History';
        styleButton(btnHistory, '#2196F3');
        btnHistory.onclick = openHistoryWindow;

        const btnDelete = document.createElement('button');
        btnDelete.innerText = 'Delete All';
        styleButton(btnDelete, '#f44336');
        btnDelete.onclick = deleteHistory;

        if (insertType === 'broadState') {
            container.id = 'soop_custom_buttons'; // 라이브는 ID 사용
            container.style.marginLeft = '10px';
            container.style.display = 'inline-flex';
            container.style.alignItems = 'center';
            container.style.gap = '5px';
            container.style.position = 'relative';

            container.appendChild(btnRecord);
            container.appendChild(btnHistory);
            container.appendChild(btnDelete);

            targetElement.parentNode.insertBefore(container, targetElement.nextSibling);

        } else if (insertType === 'broadcastArea') {
            container.id = 'soop_custom_buttons'; // 라이브는 ID 사용
            container.style.display = 'block';
            container.style.position = 'absolute';
            container.style.top = '10px';
            container.style.right = '10px';
            container.style.zIndex = '10';

            const innerFlex = document.createElement('div');
            innerFlex.style.display = 'flex';
            innerFlex.style.gap = '5px';
            container.appendChild(innerFlex);

            innerFlex.appendChild(btnRecord);
            innerFlex.appendChild(btnHistory);
            innerFlex.appendChild(btnDelete);

            targetElement.appendChild(container);

        } else if (insertType === 'vod_page') {
            // VOD 페이지: 다중 삽입을 위해 ID 대신 클래스 사용
            container.className = 'soop_custom_buttons_vod';
            container.style.display = 'inline-block';
            container.style.marginRight = '10px';
            container.style.verticalAlign = 'middle';

            // Record 버튼 제외, History와 Delete만 추가
            container.appendChild(btnHistory);
            container.appendChild(document.createTextNode(' '));
            container.appendChild(btnDelete);

            btnHistory.style.marginRight = '4px';

            // targetElement(.player_item_list)의 첫 번째 자식으로 삽입
            targetElement.insertBefore(container, targetElement.firstChild);
        }
    }

    function recordTime() {
        const timeElement = document.querySelector('#broadInfo #time');

        let currentTime = "00:00:00";
        if (timeElement) {
             currentTime = timeElement.innerText.trim();
        } else {
            showToast("시간 정보를 찾을 수 없습니다.");
            return;
        }

        if (currentTime === "00:00:00") {
            showToast("시간 정보를 찾을 수 없습니다.");
            return;
        }

        const now = new Date();
        const timestamp = now.toLocaleTimeString();

        const bjId = getCurrentBjId();
        if (!bjId) {
            showToast("방송인 ID를 찾을 수 없어 기록할 수 없습니다.");
            return;
        }

        const key = getStorageKey(bjId);
        const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
        const expiryTime = now.getTime() + THIRTY_DAYS_MS;
        const history = getCleanedHistory(bjId);

        history.push({
            id: Date.now().toString() + Math.random().toString(36).substring(2, 9),
            videoTime: currentTime,
            memo: "",
            savedAt: timestamp,
            expiry: expiryTime
        });

        setHistory(bjId, history);
        showToast(`✔ 타임스탬프 저장됨: ${currentTime} `);

        if (historyWindow && !historyWindow.closed) {
             historyWindow.close();
             historyWindow = null;
             openHistoryWindow();
        }
    }

    function openHistoryWindow() {
        if (historyWindow && !historyWindow.closed) {
            historyWindow.focus();
            return;
        }

        const bjId = getCurrentBjId();
        if (!bjId) {
            showToast("BJ ID를 찾을 수 없어 History를 열 수 없습니다.");
            return;
        }

        const currentHistory = getCleanedHistory(bjId);
        const escapedHistoryJson = JSON.stringify(currentHistory).replace(/</g, '\\u003c');


        historyWindow = window.open('', 'SOOP_Time_History', 'width=450,height=550,scrollbars=no,resizable=yes');

        if (!historyWindow) {
            alert('팝업 창이 차단되었습니다. 팝업 차단을 해제해 주세요.');
            return;
        }

        const doc = historyWindow.document;
        doc.title = 'SOOP 타임스탬프 기록';

        doc.write(`
            <html>
            <head>
                <style>
                    html, body {
                        height: 100%; margin: 0; padding: 0; overflow: hidden;
                    }
                    body {
                        font-family: sans-serif; background-color: #2c2c2c; color: #fff; padding: 15px;
                        display: flex; flex-direction: column; box-sizing: border-box;
                    }
                    h3 {
                        color: #FFD700; border-bottom: 2px solid #555; padding-bottom: 5px; margin-top: 0;
                        flex-shrink: 0;
                    }
                    #content_area { flex-grow: 1; display: flex; flex-direction: column; overflow: hidden; }
                    #list_container { flex-grow: 1; overflow-y: auto; padding-right: 10px; margin-bottom: 15px; }
                    ul { list-style: none; padding: 0; margin: 0; }
                    li { border-bottom: 1px solid #444; padding: 8px 0; margin-bottom: 10px; display: flex; flex-direction: column; }
                    .header-line { display: flex; justify-content: space-between; align-items: center; margin-bottom: 5px; }
                    .time-info { display: flex; align-items: center; gap: 10px; }

                    .video-time {
                        font-weight: bold; color: #4CAF50; font-size: 1.2em;
                        cursor: pointer;
                        transition: color 0.2s;
                    }
                    .video-time:hover { color: #81C784; text-decoration: underline; }

                    .time-edit-input {
                        background-color: #444; color: #fff; border: 1px solid #4CAF50;
                        font-size: 1.1em; font-weight: bold; width: 100px; padding: 2px;
                        border-radius: 3px;
                    }

                    .saved-at { font-size: 0.8em; color: #aaa; }
                    .memo-input {
                        width: 100%; height: 50px; padding: 5px; box-sizing: border-box; border: 1px solid #555;
                        background-color: #3a3a3a; color: white; margin-top: 5px; resize: vertical; font-family: sans-serif;
                    }
                    .history-btn {
                        border: none; padding: 8px; border-radius: 4px; cursor: pointer;
                        font-weight: bold; width: 100%; box-sizing: border-box; flex-shrink: 0;
                        margin-bottom: 5px;
                    }
                    .delete-one-btn {
                        background-color: transparent;
                        border: 1px solid #f44336;
                        color: #f44336;
                        border-radius: 3px;
                        cursor: pointer;
                        padding: 2px 6px;
                        font-size: 0.8em;
                        transition: all 0.2s;
                    }
                    .delete-one-btn:hover {
                        background-color: #f44336;
                        color: white;
                    }

                    #copy_btn { background-color: #2196F3; color: white; }
                    #copy_btn:hover { background-color: #1976D2; }
                    .empty-message { color: #ccc; text-align: center; padding: 20px; }
                </style>
            </head>
            <body>
                <h3>SOOP 타임스탬프 기록 (${bjId})</h3>
                <div id="content_area"></div>

                <script>
                    let historyData = JSON.parse('${escapedHistoryJson}');

                    window.deleteEntry = function(id) {
                        if (!confirm('이 기록을 삭제하시겠습니까?')) {
                            return;
                        }
                        const itemElement = document.getElementById('item-' + id);
                        if (itemElement) itemElement.remove();

                        historyData = historyData.filter(item => item.id !== id);

                        if (historyData.length === 0) {
                            const listContainer = document.querySelector('#list_container');
                            if (listContainer) listContainer.innerHTML = '<p class="empty-message">저장된 기록이 없습니다.</p>';
                        }

                        window.opener.postMessage({
                            source: 'soop_time_recorder_popup',
                            action: 'DELETE_SINGLE',
                            id: id
                        }, '*');
                    };

                    window.editTime = function(id, spanElement) {
                        const originalText = spanElement.innerText.replace('⏰ ', '');
                        const input = document.createElement('input');
                        input.type = 'text';
                        input.value = originalText;
                        input.className = 'time-edit-input';

                        input.onblur = function() { finishEditTime(id, input, spanElement, originalText); };
                        input.onkeydown = function(e) {
                            if (e.key === 'Enter') {
                                input.blur();
                            }
                        };

                        spanElement.style.display = 'none';
                        spanElement.parentNode.insertBefore(input, spanElement);
                        input.focus();
                    };

                    function finishEditTime(id, inputElement, spanElement, originalText) {
                        const newTime = inputElement.value.trim();

                        if (newTime === originalText || newTime === '') {
                            inputElement.remove();
                            spanElement.style.display = 'inline';
                            return;
                        }

                        spanElement.innerText = '⏰ ' + newTime;
                        inputElement.remove();
                        spanElement.style.display = 'inline';

                        const index = historyData.findIndex(item => item.id === id);
                        if (index > -1) {
                            historyData[index].videoTime = newTime;
                        }

                        window.opener.postMessage({
                            source: 'soop_time_recorder_popup',
                            action: 'UPDATE_TIME',
                            id: id,
                            newTime: newTime
                        }, '*');
                    }

                    function sendAllMemos(actionType) {
                        const memoUpdates = [];
                        const textareas = document.querySelectorAll('.memo-input');

                        textareas.forEach(textarea => {
                            const recordId = textarea.id.split('-')[2];
                            memoUpdates.push({
                                id: recordId,
                                memo: textarea.value
                            });
                            const index = historyData.findIndex(item => item.id === recordId);
                            if (index > -1) {
                                historyData[index].memo = textarea.value;
                            }
                        });

                        window.opener.postMessage({
                            source: 'soop_time_recorder_popup',
                            action: 'SAVE_ALL_MEMOS',
                            historyUpdates: memoUpdates,
                            value: actionType
                        }, '*');
                    }

                    window.onbeforeunload = function() {
                        sendAllMemos('CLOSING');
                    };

                    window.startCopy = function() {
                        copyFullHistory(true);
                    };

                    function copyFullHistory(isSaved) {
                        if (!isSaved) {
                            sendAllMemos('COPYING');
                            return;
                        }

                        const textToCopy = historyData.slice().map(item => {
                            const baseTime = '[ ' + item.videoTime + ' ]';
                            const memoText = item.memo ? item.memo.trim() : "";
                            return memoText ? baseTime + ' - ' + memoText : baseTime;
                        }).join('\\n');

                        if (!textToCopy) {
                            alert('복사할 기록이 없습니다.');
                            return;
                        }

                        const tempTextArea = document.createElement('textarea');
                        tempTextArea.value = textToCopy;
                        tempTextArea.style.position = 'fixed';
                        tempTextArea.style.left = '-9999px';
                        document.body.appendChild(tempTextArea);

                        try {
                            tempTextArea.select();
                            if (document.execCommand('copy')) {
                                alert('✔ 전체 기록이 클립보드에 복사되었습니다.\\n팝업 창을 닫습니다.');
                                window.close();
                            } else {
                                alert('클립보드 복사에 실패했습니다.');
                            }
                        } catch (err) {
                            console.error('클립보드 복사 실패:', err);
                        } finally {
                            document.body.removeChild(tempTextArea);
                        }
                    }

                    document.body.addEventListener('click', function(event) {
                        const target = event.target;
                        if (target.id === 'copy_btn') {
                            copyFullHistory(false);
                        }
                    });

                    function renderHistoryContent(contentArea, historyArray) {
                        if (!contentArea) return;
                        const history = historyArray;

                        contentArea.innerHTML = '';

                        let listHTML = '<div id="list_container"><ul>';

                        if (history.length === 0) {
                            listHTML += '<p class="empty-message">저장된 기록이 없습니다.</p>';
                        } else {
                            [...history].reverse().forEach((item) => {
                                listHTML += \`
                                    <li id="item-\${item.id}">
                                        <div class="header-line">
                                            <div class="time-info">
                                                <span class="video-time" title="더블클릭하여 시간 수정" ondblclick="editTime('\${item.id}', this)">⏰ \${item.videoTime}</span>
                                                <span class="saved-at">\${item.savedAt}</span>
                                            </div>
                                            <button class="delete-one-btn" onclick="deleteEntry('\${item.id}')">삭제</button>
                                        </div>
                                        <textarea
                                            id="memo-input-\${item.id}"
                                            class="memo-input"
                                            rows="2"
                                            placeholder="메모를 입력하세요..."
                                        >\${item.memo}</textarea>
                                    </li>
                                \`;
                            });
                        }
                        listHTML += '</ul></div>';

                        const copyButtonHTML = \`<button id="copy_btn" class="history-btn">📄 전체 목록 복사하기</button>\`;

                        contentArea.innerHTML = listHTML + copyButtonHTML;
                    }

                    renderHistoryContent(document.getElementById('content_area'), historyData);

                </script>
            </body>
            </html>
        `);
        doc.close();
    }

    function styleButton(btn, bgColor) {
         btn.style.backgroundColor = bgColor;
         btn.style.color = 'white';
         btn.style.border = 'none';
         btn.style.padding = '4px 8px';
         btn.style.borderRadius = '3px';
         btn.style.cursor = 'pointer';
         btn.style.fontSize = '12px';
         btn.style.fontWeight = 'bold';
         btn.style.fontFamily = 'dotum, sans-serif';
    }

})();