SOOP (숲) - m3u8 링크 복사 버튼 추가

SOOP 방송 페이지 플레이어 하단 정보 바의 가장 왼쪽에 m3u8 링크 복사 버튼을 추가하고, 페이지 테마에 따라 디자인을 조정합니다.

目前為 2025-05-25 提交的版本,檢視 最新版本

// ==UserScript==
// @name         SOOP (숲) - m3u8 링크 복사 버튼 추가
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  SOOP 방송 페이지 플레이어 하단 정보 바의 가장 왼쪽에 m3u8 링크 복사 버튼을 추가하고, 페이지 테마에 따라 디자인을 조정합니다.
// @author       Your Name
// @icon         https://www.google.com/s2/favicons?sz=256&domain=sooplive.co.kr
// @match        https://play.sooplive.co.kr/*/*
// @exclude      https://play.sooplive.co.kr/*/embed*
// @grant        GM_setClipboard
// @grant        GM_addStyle
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // --- 유틸리티 함수들 ---
    const getBroadAid2 = async (id, broadNumber) => {
        const data = {
            bid: id, bno: broadNumber, from_api: '0', mode: 'landing',
            player_type: 'html5', pwd: '', stream_type: 'common',
            quality: 'original', type: 'aid'
        };
        const requestOptions = {
            method: 'POST', body: new URLSearchParams(data), credentials: 'include'
        };
        try {
            const response = await fetch('https://live.sooplive.co.kr/afreeca/player_live_api.php', requestOptions);
            const result = await response.json();
            // console.log("[getBroadAid2] AID 응답:", result);
            return result.CHANNEL.AID || null;
        } catch (error) {
            console.error('[getBroadAid2] AID 가져오기 오류:', error);
            return null;
        }
    };

    function getCurrentBroadcastInfoFromUrl() {
        const pathParts = window.location.pathname.split('/');
        if (pathParts.length >= 3 && pathParts[1] && pathParts[2] && !isNaN(parseInt(pathParts[2]))) {
            const userId = pathParts[1];
            const broadNo = parseInt(pathParts[2]);
            return { userId, broadNo };
        }
        console.error("[getCurrentBroadcastInfoFromUrl] URL에서 방송 정보를 가져올 수 없습니다.");
        return null;
    }

    function showToastMessage(message, autoHide = true) {
        let toast = document.querySelector('.m3u8-toast-message-userscript');
        if (toast) { toast.remove(); }
        toast = document.createElement('div');
        toast.className = 'm3u8-toast-message-userscript';
        toast.textContent = message;
        document.body.appendChild(toast);
        setTimeout(() => { toast.classList.add('show'); }, 10);
        if (autoHide) {
            setTimeout(() => {
                toast.classList.remove('show');
                setTimeout(() => { if (toast.parentNode) { toast.parentNode.removeChild(toast); } }, 500);
            }, 3000);
        }
    }

    // --- 전역 변수 ---
    let currentBroadcastInfo = null;
    let copyButtonLiElement = null;
    let copyButtonElement = null;

    // --- SVG 아이콘 ---
    const linkIconSvg = `
        <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
            <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
            <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
        </svg>
    `;

    // --- 테마 및 스타일 관련 함수 ---
    function getCurrentTheme() {
        return document.documentElement.getAttribute('dark') === 'true' ? 'dark' : 'light';
    }

    function updateButtonAppearance() {
        if (!copyButtonLiElement || !copyButtonElement) return;
        const theme = getCurrentTheme();
        const svgIcon = copyButtonElement.querySelector('svg');
        if (!svgIcon) return;

        if (theme === 'dark') {
            copyButtonLiElement.style.backgroundColor = 'rgba(70, 70, 77, 0.8)'; // 다크 모드 버튼 배경 (약간 투명도 조절 가능)
            svgIcon.style.stroke = '#e0e0e0'; // 다크 모드 아이콘 색상
            copyButtonElement.onmouseover = () => {
                copyButtonLiElement.style.backgroundColor = 'rgba(85, 85, 92, 0.9)';
                svgIcon.style.stroke = '#ffffff';
            };
            copyButtonElement.onmouseout = () => {
                copyButtonLiElement.style.backgroundColor = 'rgba(70, 70, 77, 0.8)';
                svgIcon.style.stroke = '#e0e0e0';
            };
        } else { // light theme
            copyButtonLiElement.style.backgroundColor = 'rgba(225, 226, 230, 0.9)'; // 라이트 모드 버튼 배경
            svgIcon.style.stroke = '#4a4a52'; // 라이트 모드 아이콘 색상
            copyButtonElement.onmouseover = () => {
                copyButtonLiElement.style.backgroundColor = 'rgba(205, 206, 212, 1)';
                svgIcon.style.stroke = '#1a1a1c';
            };
            copyButtonElement.onmouseout = () => {
                copyButtonLiElement.style.backgroundColor = 'rgba(225, 226, 230, 0.9)';
                svgIcon.style.stroke = '#4a4a52';
            };
        }
    }

    function applyGlobalStyles() {
        if (document.getElementById('m3u8UserScriptGlobalStyle')) return;
        const styleElement = document.createElement('style');
        styleElement.id = 'm3u8UserScriptGlobalStyle';
        styleElement.textContent = `
            #m3u8CopyButtonUserScriptItem {
                display: inline-flex; /* 내부 버튼 정렬을 위해 flex로 변경 */
                align-items: center;
                justify-content: center;
                vertical-align: middle;
                margin-right: 8px; /* 오른쪽 아이콘과의 간격 (가장 왼쪽이므로 margin-left는 불필요) */
                border-radius: 50%;
                width: 30px;
                height: 30px;
                transition: background-color 0.2s ease;
                /* SOOP의 다른 li.donation, li.adballoon 등과 유사한 스타일을 위해 클래스 추가 고려 */
                /* 예: buttonItem.classList.add("donation"); copyButtonLiElement.classList.add("m3u8-custom-icon"); */
            }
            #m3u8CopyButtonUserScriptButton {
                background-color: transparent !important;
                border: none !important;
                cursor: pointer !important;
                padding: 0 !important;
                display: flex !important;
                align-items: center !important;
                justify-content: center !important;
                width: 100%;
                height: 100%;
                border-radius: 50%;
            }
            #m3u8CopyButtonUserScriptButton svg {
                width: 18px !important;
                height: 18px !important;
                vertical-align: middle;
                transition: stroke 0.2s ease;
            }
            .m3u8-toast-message-userscript {
                position: fixed; bottom: 70px; left: 50%; transform: translateX(-50%);
                background-color: rgba(0, 0, 0, 0.85); color: white; padding: 12px 22px;
                border-radius: 6px; z-index: 9999999; font-size: 14px; opacity: 0;
                transition: opacity 0.4s ease-in-out; box-shadow: 0 3px 8px rgba(0,0,0,0.3);
                text-align: center;
            }
            .m3u8-toast-message-userscript.show { opacity: 1; }
        `;
        document.head.appendChild(styleElement);
    }

    // --- 핵심 로직 ---
    async function handleCopyButtonClick() {
        if (!currentBroadcastInfo) { showToastMessage("방송 정보를 가져올 수 없습니다."); return; }
        showToastMessage("m3u8 링크 추출 중...", false);
        const aid = await getBroadAid2(currentBroadcastInfo.userId, currentBroadcastInfo.broadNo);
        if (aid) {
            const m3u8Link = `https://live-global-cdn-v02.sooplive.co.kr/live-stm-12/auth_playlist.m3u8?aid=${aid}`;
            GM_setClipboard(m3u8Link, 'text');
            showToastMessage("m3u8 링크가 클립보드에 복사되었습니다.");
        } else {
            showToastMessage("m3u8 링크 추출에 실패했습니다. (AID 오류)");
        }
    }

    function ensureCopyLinkButton() {
        if (!currentBroadcastInfo) return;

        // 대상 위치 탐색 (우선순위: column[number="2"] -> column[number="1"] player_item_list -> column[number="1"] depend_item)
        let itemListContainer = document.querySelector('.broadcast_information .column[number="2"] .player_item_list ul');
        if (!itemListContainer) {
            itemListContainer = document.querySelector('.broadcast_information .column[number="1"] .player_item_list ul');
        }
        if (!itemListContainer) {
            itemListContainer = document.querySelector('.broadcast_information .column[number="1"] ul.depend_item');
        }

        if (!itemListContainer) {
            // console.warn("[ensureCopyLinkButton] 대상 아이콘 목록 ul을 찾을 수 없어 실행 중단.");
            return;
        }

        copyButtonLiElement = document.getElementById('m3u8CopyButtonUserScriptItem');

        if (!copyButtonLiElement || !itemListContainer.contains(copyButtonLiElement)) {
            if (copyButtonLiElement && !itemListContainer.contains(copyButtonLiElement)) {
                copyButtonLiElement.remove();
            }

            copyButtonLiElement = document.createElement('li');
            copyButtonLiElement.id = 'm3u8CopyButtonUserScriptItem';
            // SOOP의 다른 아이콘 li 요소들(예: li.donation)의 클래스를 참고하여 추가하면 레이아웃에 도움이 될 수 있습니다.
            // 예: copyButtonLiElement.classList.add("donation"); // 이렇게 하면 기존 donation 스타일이 적용될 수 있음 (주의)
            //     copyButtonLiElement.classList.add("m3u8-custom-item"); // 커스텀 클래스 추가

            copyButtonElement = document.createElement('button');
            copyButtonElement.id = 'm3u8CopyButtonUserScriptButton';
            copyButtonElement.innerHTML = linkIconSvg;
            copyButtonElement.title = 'm3u8 링크 복사';

            copyButtonElement.removeEventListener('click', handleCopyButtonClick);
            copyButtonElement.addEventListener('click', handleCopyButtonClick);

            copyButtonLiElement.appendChild(copyButtonElement);

            // 가장 왼쪽에 삽입 (첫 번째 자식으로)
            if (itemListContainer.firstChild) {
                itemListContainer.insertBefore(copyButtonLiElement, itemListContainer.firstChild);
            } else {
                itemListContainer.appendChild(copyButtonLiElement);
            }
            console.log("[ensureCopyLinkButton] 버튼 아이템 가장 왼쪽에 추가/재배치 완료.");
        }
        updateButtonAppearance();
    }

    function initializeScript() {
        console.log(`SOOP m3u8 복사 스크립트 초기화 (v${GM_info.script.version})`);
        currentBroadcastInfo = getCurrentBroadcastInfoFromUrl();

        if (!currentBroadcastInfo) { showToastMessage("방송 정보를 URL에서 인식할 수 없어 스크립트를 시작할 수 없습니다."); return; }
        console.log("[initializeScript] 초기 방송 정보:", currentBroadcastInfo);

        applyGlobalStyles();

        const themeObserver = new MutationObserver(() => {
            updateButtonAppearance();
        });
        themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['dark'] });

        const broadcastInfoArea = document.querySelector('.broadcast_information');
        if (!broadcastInfoArea) {
            console.error("[initializeScript] .broadcast_information 영역을 찾을 수 없어 Observer 시작 불가. 폴백 시도.");
            let fallbackAttempts = 0;
            const fallbackInterval = setInterval(() => {
                fallbackAttempts++;
                const foundArea = document.querySelector('.broadcast_information');
                if (foundArea || fallbackAttempts > 20) {
                    clearInterval(fallbackInterval);
                    if(foundArea) initializeScript(); // 재시도 (무한루프 방지 필요)
                    return;
                }
                ensureCopyLinkButton(); // 영역 못찾아도 버튼 추가 시도
            }, 500);
            return;
        }

        const mainObserver = new MutationObserver(() => {
            ensureCopyLinkButton();
        });
        mainObserver.observe(broadcastInfoArea, { childList: true, subtree: true });

        setTimeout(() => {
            ensureCopyLinkButton();
            updateButtonAppearance();
        }, 1000);

        console.log("[initializeScript] 초기화 완료. Observer 감시 시작됨.");
    }

    // --- 스크립트 실행 조건 ---
    const currentPath = window.location.pathname;
    const pathParts = currentPath.split('/');
    const isTargetPage = window.location.href.startsWith("https://play.sooplive.co.kr/") &&
                         !currentPath.includes("/embed") &&
                         pathParts.length >= 3 && pathParts[1] &&
                         !isNaN(parseInt(pathParts[2]));

    if (isTargetPage) {
        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', initializeScript);
        } else {
            initializeScript();
        }
    }
})();