Greasy Fork

来自缓存

Greasy Fork is available in English.

SOOP(숲) 녹화비서

뱅온 알림 + 녹화

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         SOOP(숲) 녹화비서
// @namespace    http://tampermonkey.net/
// @version      1.0.7
// @description  뱅온 알림 + 녹화
// @match        https://*.sooplive.com/*
// @match        https://sooplive.com/*
// @icon         https://res.sooplive.com/afreeca.ico
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @grant        GM_openInTab
// @grant        GM_registerMenuCommand
// @grant        GM_download
// @grant        GM_notification
// @grant        GM_addValueChangeListener
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // ==========================================
    // 1. 상태 및 설정 관리
    // ==========================================
    const currentUrlParts = window.location.pathname.split('/');
    const currentId = currentUrlParts.length > 1 && currentUrlParts[1] !== '' ? currentUrlParts[1] : null;

    const isBroadcastTab = !!currentId && window.location.hostname.includes('play.sooplive.com');

    let loadedStreamers = GM_getValue('streamers', [ { id: '', nick: '', notify: true, join: false, record: false } ]);
    loadedStreamers = loadedStreamers.map(s => {
        if (s.alert !== undefined) { s.join = s.alert; s.notify = s.alert; delete s.alert; }
        if (s.notify === undefined) s.notify = false;
        if (s.join === undefined) s.join = false;
        return s;
    });

    const STATE = {
        splitSize: GM_getValue('splitSize', 500),
        videoBitrate: GM_getValue('videoBitrate', 6),
        fastOpen: GM_getValue('fastOpen', false),
        silentMode: GM_getValue('silentMode', false),
        autoClose: GM_getValue('autoClose', false),
        ecoMode: GM_getValue('ecoMode', false),
        autoHighlight: GM_getValue('autoHighlight', false),
        chatFormat: GM_getValue('chatFormat', 'both'),
        streamers: loadedStreamers
    };

    // ==========================================
    // 2. CSS 스타일
    // ==========================================
    GM_addStyle(`
        #st-modal-overlay { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.6); z-index: 9999998; display: none; backdrop-filter: blur(2px); }
        #st-modal { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 520px; background: #ffffff; border-radius: 12px; z-index: 9999999; display: none; flex-direction: column; box-shadow: 0 10px 30px rgba(0,0,0,0.3); font-family: 'Malgun Gothic', sans-serif; }
        .st-header { background: #222; color: white; padding: 18px 20px; display: flex; justify-content: space-between; align-items: center; border-radius: 12px 12px 0 0; }
        .st-header h2 { margin: 0; font-size: 16px; font-weight: bold; display: flex; align-items: center; }
        .st-close-btn { cursor: pointer; font-size: 22px; background: none; border: none; color: white; transition: 0.2s; }
        .st-close-btn:hover { color: #FF4B8B; }
        .st-content { padding: 20px; display: flex; flex-direction: column; gap: 15px; max-height: 85vh; overflow-y: auto; }

        .st-toggle-row { display: flex; justify-content: space-between; align-items: center; font-size: 13px; font-weight: bold; color: #333; padding-bottom: 8px; border-bottom: 1px solid #eee; }
        .st-switch { position: relative; display: inline-block; width: 40px; height: 22px; flex-shrink: 0; }
        .st-switch input { opacity: 0; width: 0; height: 0; }
        .st-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: .3s; border-radius: 22px; }
        .st-slider:before { position: absolute; content: ""; height: 16px; width: 16px; left: 3px; bottom: 3px; background-color: white; transition: .3s; border-radius: 50%; }
        input:checked + .st-slider { background-color: #FF4B8B; }
        input:checked + .st-slider:before { transform: translateX(18px); }

        .st-mini-switch { position: relative; display: inline-block; width: 32px; height: 16px; vertical-align: middle; }
        .st-mini-switch input { opacity: 0; width: 0; height: 0; }
        .st-mini-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: .3s; border-radius: 18px; }
        .st-mini-slider:before { position: absolute; content: ""; height: 12px; width: 12px; left: 2px; bottom: 2px; background-color: white; transition: .3s; border-radius: 50%; }
        .st-mini-switch input:checked + .st-mini-slider { background-color: #00C853; }
        .st-mini-switch input:checked + .st-mini-slider:before { transform: translateX(16px); }

        .st-card { background: #f8f9fa; border-radius: 10px; padding: 15px; border: 1px solid #e9ecef; }
        .st-card-title { font-size: 13px; color: #555; margin-bottom: 10px; font-weight: bold; display: flex; justify-content: space-between; align-items: center; }

        .st-streamer-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; background: #fff; padding: 8px; border: 1px solid #ddd; border-radius: 6px; }
        .st-input-wrapper { position: relative; flex: 0 0 45%; height: 32px; background: #fff; border: 1px solid #ccc; border-radius: 4px; overflow: hidden; display: flex; }
        .st-input-sm { flex: 1; width: 100%; height: 100%; border: none; outline: none; padding: 0 8px; font-size: 13px; font-weight: bold; }
        .st-nick-overlay { position: absolute; right: 0; top: 0; height: 100%; background: linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,1) 15%, rgba(255,255,255,1) 100%); padding: 0 8px 0 15px; display: flex; align-items: center; font-size: 11px; color: #888; pointer-events: none; white-space: nowrap; font-weight: normal; }
        .st-controls { display: flex; flex: 1; align-items: center; justify-content: flex-end; gap: 8px; flex-shrink: 0; }

        .st-opt-label { font-size: 11px; color: #555; display: flex; flex-direction: column; align-items: center; gap: 3px; font-weight: bold; flex-shrink: 0; }
        .st-move-btn { background: #e9ecef; color: #495057; border: none; border-radius: 4px; width: 22px; height: 15px; cursor: pointer; font-size: 10px; display: flex; align-items: center; justify-content: center; transition: 0.2s; flex-shrink: 0; }
        .st-move-btn:hover { background: #ced4da; }
        .st-del-btn { background: #ff4d4f; color: white; border: none; border-radius: 4px; width: 26px; height: 26px; cursor: pointer; font-weight: bold; display: flex; align-items: center; justify-content: center; flex-shrink: 0; margin-left: 2px; }

        .st-add-btn { width: 100%; padding: 8px; background: #e9ecef; border: 1px dashed #adb5bd; border-radius: 6px; cursor: pointer; font-weight: bold; color: #495057; }
        .st-btn { padding: 8px 12px; border-radius: 6px; border: none; background: #e2e6ea; cursor: pointer; font-size: 13px; font-weight: bold; transition: 0.2s; color: #333; }
        .st-select { padding: 5px 10px; border-radius: 6px; border: 1px solid #ccc; font-size: 13px; font-weight: bold; outline: none; }

        .st-live-status { background: #222; color: #00ff00; padding: 10px; border-radius: 6px; font-family: monospace; font-size: 13px; margin-top: 10px; display: flex; justify-content: space-between; border-left: 4px solid #FF4B8B; }

        .st-tooltip { display: inline-flex; align-items: center; justify-content: center; width: 14px; height: 14px; border-radius: 50%; background: #aaa; color: white; font-size: 10px; font-weight: bold; margin-left: 6px; cursor: help; position: relative; vertical-align: middle; }
        .st-tooltip:hover { background: #FF4B8B; }
        .st-tooltip:hover .st-tooltip-text { visibility: visible; opacity: 1; }
        .st-tooltip-text { visibility: hidden; opacity: 0; width: 220px; background-color: #333; color: #fff; text-align: left; border-radius: 6px; padding: 10px 12px; position: absolute; z-index: 9999999; top: 150%; left: 50%; transform: translateX(-50%); font-size: 12px; font-weight: normal; line-height: 1.4; box-shadow: 0 4px 6px rgba(0,0,0,0.3); transition: opacity 0.2s; white-space: normal; pointer-events: none; word-break: keep-all; }
        .st-tooltip-text::after { content: ""; position: absolute; bottom: 100%; left: 50%; margin-left: -5px; border-width: 5px; border-style: solid; border-color: transparent transparent #333 transparent; }

        #st-rec-badge { position: absolute; top: 20px; left: 20px; background: rgba(0, 0, 0, 0.75); color: white; padding: 8px 15px; border-radius: 20px; font-size: 14px; font-weight: bold; font-family: 'Malgun Gothic', sans-serif; display: flex; align-items: center; gap: 8px; z-index: 2147483647; box-shadow: 0 4px 10px rgba(0,0,0,0.5); border: 1px solid rgba(255,255,255,0.1); transition: max-width 0.4s cubic-bezier(0.25, 1, 0.5, 1), padding 0.4s, opacity 0.4s; max-width: 400px; overflow: hidden; white-space: nowrap; cursor: pointer; }
        .st-rec-content { display: flex !important; flex-direction: row !important; align-items: center !important; gap: 8px !important; white-space: nowrap !important; flex-wrap: nowrap !important; width: max-content !important; transition: opacity 0.3s; opacity: 1; }
        #st-rec-badge.collapsed { max-width: 32px !important; padding: 4px 8px !important; background: rgba(0, 0, 0, 0.3) !important; border: 1px solid rgba(255, 75, 139, 0.4) !important; backdrop-filter: blur(2px) !important; overflow: hidden !important; white-space: nowrap !important; cursor: pointer; }
        #st-rec-badge.collapsed .st-rec-content { pointer-events: none !important; }
        .st-rec-dot { width: 12px; height: 12px; background-color: #ff3b3b; border-radius: 50%; animation: st-blink 1.5s infinite; flex-shrink: 0; margin-left: 2px;}
        @keyframes st-blink { 0% { opacity: 1; box-shadow: 0 0 5px #ff3b3b; } 50% { opacity: 0.3; box-shadow: none; } 100% { opacity: 1; box-shadow: 0 0 5px #ff3b3b; } }
        .st-rec-text { color: #ddd; font-size: 12px; }
        .st-rec-btn { border: none; border-radius: 4px; color: white; width: 24px; height: 24px; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 12px; transition: 0.2s; }
        .st-rec-stop-btn { background: #ff4d4f; margin-left: 2px; }
        .st-rec-stop-btn:hover { background: #ff7875; transform: scale(1.1); }
        .st-rec-vol-btn { background: #444; }
        .st-rec-vol-btn:hover { background: #666; transform: scale(1.1); }
        input[type="number"]::-webkit-outer-spin-button, input[type="number"]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
        input[type="number"] { -moz-appearance: textfield; }
    `);

    // ==========================================
    // 🔧 타이머 관리 객체 (메모리 누수 방지)
    // ==========================================
    const TimerManager = {
        timers: {},

        set(name, timerId) {
            this.clear(name);
            this.timers[name] = timerId;
        },

        clear(name) {
            if (this.timers[name]) {
                clearInterval(this.timers[name]);
                clearTimeout(this.timers[name]);
                delete this.timers[name];
            }
        },

        clearAll() {
            Object.keys(this.timers).forEach(name => this.clear(name));
        }
    };

    // ==========================================
    // 3. UI 로직
    // ==========================================
    function createUI() {
        const overlay = document.createElement('div');
        overlay.id = 'st-modal-overlay';
        const modal = document.createElement('div'); modal.id = 'st-modal';

        modal.innerHTML = `
            <div class="st-header">
                <h2>🎥 SOOP 녹화비서
                    <div class="st-tooltip" style="flex-shrink:0; margin-top:1px;">?<span class="st-tooltip-text">관제탑이 정상 작동하려면 브라우저에 SOOP 탭이 최소 하나 이상 켜져 있어야 합니다.<br><br>라이브 좌측 상단에 녹화 정보를 볼 수 있으며 추가하지 않은 스트리머도 설정 창을 열어 녹화가 가능합니다.<br><br>소리 끄고 녹화할 때 절대 방송 음소거 금지! 소리가 하나도 없는 벙어리 영상이 저장됩니다. 녹화본이 끊길 수도 있습니다.<br><br>사용자가 직접 닫은(보기 싫어서 끈) 방송은 스크립트가 '12시간 동안' 자동으로 팝업을 띄우지 않습니다.</span></div>
                </h2>
                <button class="st-close-btn">&times;</button>
            </div>
            <div class="st-content">

                <div id="st-quick-record-card" class="st-card" style="display:none; background: #fff0f5; border-color: #ffb3c6;">
                    <div class="st-card-title" style="color: #d81b60; font-size: 14px;">👀 현재 시청 중인 방송</div>
                    <div style="display:flex; justify-content:space-between; align-items:center;">
                        <span id="st-current-streamer" style="font-weight:bold; font-size:16px; color:#222;">-</span>
                        <button id="st-quick-record-btn" class="st-btn st-quick-btn">🔴 1회용 퀵 녹화 (Alt+R)</button>
                    </div>
                </div>

                <div class="st-toggle-row">
                    <span style="color:#FF4B8B; font-size:15px; display:flex; align-items:center; white-space:nowrap;">마스터 스위치 (전체 작동)</span>
                    <label class="st-switch"><input type="checkbox" id="st-master-toggle" ${GM_getValue('isMasterOn', false) ? 'checked' : ''}><span class="st-slider"></span></label>
                </div>

                <div style="display:flex; gap:12px;">
                    <div class="st-card" style="flex:1; display:flex; flex-direction:column; gap:14px;">
                        <div class="st-toggle-row" style="border:none; padding:0;">
                            <span style="display:flex; align-items:center; white-space:nowrap;">🤫 조용한 녹화
                                <div class="st-tooltip" style="flex-shrink:0; margin-right:10px;">?<span class="st-tooltip-text">무음 백그라운드로 몰래 녹화합니다. 좌측 상단 뱃지에서 언제든 🔊/🔇 조절 가능합니다.</span></div>
                            </span>
                            <label class="st-switch"><input type="checkbox" id="st-silent-toggle" ${STATE.silentMode ? 'checked' : ''}><span class="st-slider"></span></label>
                        </div>
                        <div class="st-toggle-row" style="border:none; padding:0;">
                            <span style="display:flex; align-items:center; white-space:nowrap;">♻️ 방종 시 탭 자원 회수
                                <div class="st-tooltip" style="flex-shrink:0; margin-right:10px;">?<span class="st-tooltip-text">방종이 감지되면 영상을 안전하게 저장하고, 3초 뒤 탭을 '빈 창(about:blank)'으로 이동시킵니다.</span></div>
                            </span>
                            <label class="st-switch"><input type="checkbox" id="st-autoclose-toggle" ${STATE.autoClose ? 'checked' : ''}><span class="st-slider"></span></label>
                        </div>
                        <div class="st-toggle-row" style="border:none; padding:0;">
                            <span style="white-space:nowrap;">💾 분할 저장</span>
                            <select class="st-select" id="st-split-select">
                                <option value="500" ${STATE.splitSize == 500 ? 'selected' : ''}>500 MB</option>
                                <option value="1024" ${STATE.splitSize == 1024 ? 'selected' : ''}>1 GB</option>
                                <option value="3072" ${STATE.splitSize == 3072 ? 'selected' : ''}>3 GB</option>
                                <option value="0" ${STATE.splitSize == 0 ? 'selected' : ''}>무제한</option>
                            </select>
                        </div>
                        <div class="st-toggle-row" style="border:none; padding:0;">
                            <span style="display:flex; align-items:center; color:#FF4B8B; white-space:nowrap;">💎 영상 품질
                                <div class="st-tooltip" style="background:#FF4B8B; flex-shrink:0; margin-right:10px;">?<span class="st-tooltip-text">녹화 화질(파일 용량)을 결정합니다. 보는 영상 화질기준으로 녹화됩니다.<br><br>• 3~4: 소통, 라디오 (저용량)<br>• 5~6: 일반 게임 (표준)<br>• 8 이상: FPS, 고화질 (고용량)</span></div>
                            </span>
                            <span style="font-size:13px; font-weight:bold; color:#FF4B8B; display:flex; align-items:center; gap:4px;">
                                <input type="number" id="st-bitrate-input" value="${STATE.videoBitrate}" style="width:36px; border:1px solid #ccc; border-radius:4px; outline:none; text-align:center; font-weight:bold; color:#FF4B8B; background:#fff; padding:2px;" min="1" max="30"> Mbps
                            </span>
                        </div>
                    </div>
                    <div class="st-card" style="flex:1; display:flex; flex-direction:column; gap:14px;">
                        <div class="st-toggle-row" style="border:none; padding:0;">
                            <span style="display:flex; align-items:center; white-space:nowrap;">🌱 에코 최적화
                                <div class="st-tooltip" style="flex-shrink:0; margin-right:10px;">?<span class="st-tooltip-text">녹화 프레임을 부드러운 30fps로 낮춰 컴퓨터 부담(CPU/RAM)을 줄입니다.</span></div>
                            </span>
                            <label class="st-switch"><input type="checkbox" id="st-eco-toggle" ${STATE.ecoMode ? 'checked' : ''}><span class="st-slider"></span></label>
                        </div>
                        <div class="st-toggle-row" style="border:none; padding:0;">
                            <span style="display:flex; align-items:center; white-space:nowrap;">🔥 자동 하이라이트
                                <div class="st-tooltip" style="flex-shrink:0; margin-right:10px;">?<span class="st-tooltip-text">'ㅋㅋ', '??' 채팅이 폭발하는 구간에 자동으로 타임라인 북마크가 새겨집니다.<br><br>[Alt+A]로 수동 북마크도 가능!</span></div>
                            </span>
                            <label class="st-switch"><input type="checkbox" id="st-autohl-toggle" ${STATE.autoHighlight ? 'checked' : ''}><span class="st-slider"></span></label>
                        </div>
                        <div class="st-toggle-row" style="border:none; padding:0;">
                            <span style="display:flex; align-items:center; color:#0d6efd; white-space:nowrap;">💬 채팅 저장
                                <div class="st-tooltip" style="background:#0d6efd; flex-shrink:0; margin-left:6px;">?<span class="st-tooltip-text" style="width:260px;">⚠️ 화면에 우측 채팅창이 열려 있을 때만 수집이 가능합니다.<br><br>채팅창을 접어두거나, 채팅창이 사라지는 '전체화면' 모드에서는 채팅 수집이 완전히 중단됩니다. (일반 창 모드나 극장 모드를 사용해 주세요.)</span></div>
                            </span>
                            <select class="st-select" id="st-chatformat-select" style="margin-left: 10px; width: 115px;">
                                <option value="none" ${STATE.chatFormat === 'none' ? 'selected' : ''}>저장 안 함</option>
                                <option value="txt" ${STATE.chatFormat === 'txt' ? 'selected' : ''}>TXT 텍스트</option>
                                <option value="srt" ${STATE.chatFormat === 'srt' ? 'selected' : ''}>SRT 자막 파일</option>
                                <option value="both" ${STATE.chatFormat === 'both' ? 'selected' : ''}>TXT + SRT</option>
                            </select>
                        </div>
                        <div class="st-toggle-row" style="border:none; padding:0;">
                            <span style="display:flex; align-items:center; color:#FF4B8B; white-space:nowrap;">🚀 동시 다발 입장
                                <div class="st-tooltip" style="background:#FF4B8B; flex-shrink:0; margin-right:10px;">?<span class="st-tooltip-text">안전 대기열(5초)을 무시하고 켜진 방송을 한꺼번에 즉시 엽니다.</span></div>
                            </span>
                            <label class="st-switch"><input type="checkbox" id="st-fastopen-toggle" ${STATE.fastOpen ? 'checked' : ''}><span class="st-slider"></span></label>
                        </div>
                    </div>
                </div>

                <div class="st-card">
                    <div class="st-card-title">
                        <span style="display:flex; align-items:center; gap:8px;">🎯 타겟 스트리머 우선순위
                                <div class="st-tooltip" style="background:#dc3545; flex-shrink:0;">?<span class="st-tooltip-text" style="width:250px;">참여/녹화 4개 초과는 비추천하며, 초과할 시 SOOP 기본 설정에 의해 가장 먼저 켜진(오래된) 방송이 종료됩니다. </span></div>                            <span style="font-size:11px; font-weight:normal; color:#666; background:#fff; padding:2px 6px; border-radius:4px; border:1px solid #ddd;">
                                <input type="number" id="st-interval-input" value="${GM_getValue('checkInterval', 15)}" style="width:26px; border:none; outline:none; text-align:center; font-weight:bold; color:#FF4B8B; background:transparent;" min="5"> s 마다 확인
                            </span>
                        </span>
                        <span id="st-streamer-count" style="color:#FF4B8B;">${STATE.streamers.length}/10</span>
                    </div>
                    <div id="st-streamer-list"></div>
                    <button id="st-add-streamer" class="st-add-btn">+ 스트리머 추가</button>
                    <div class="st-live-status" id="st-live-status"><span id="st-live-target">대기 중...</span><span id="st-live-info">-</span></div>
                </div>

                <div class="st-card" style="display:flex; justify-content:space-between; align-items:center; background:#f0f8ff; border-color:#b6d4fe; padding:12px 15px;">
                    <span style="font-weight:bold; color:#0d6efd; font-size:14px;">💤 수면 모드 (관제탑 일시정지)</span>
                    <div style="display:flex; align-items:center; gap:10px;">
                        <span id="st-sleep-status" style="font-size:13px; font-weight:bold; color:#dc3545;">꺼짐</span>
                        <button id="st-sleep-add-btn" class="st-btn" style="background:#0d6efd; color:white; padding:6px 10px; font-size:12px;">+ 1시간</button>
                        <button id="st-sleep-reset-btn" class="st-btn" style="background:#6c757d; color:white; padding:6px 10px; font-size:12px;">초기화</button>
                    </div>
                </div>
            </div>
        `;
        document.body.appendChild(overlay); document.body.appendChild(modal);

        const openModal = async () => {
            if (isBroadcastTab && currentId) {
                const qrCard = document.getElementById('st-quick-record-card');
                qrCard.style.display = 'block';
                const existing = STATE.streamers.find(s => s.id === currentId);
                const currentStreamerEl = document.getElementById('st-current-streamer');

                if (existing && existing.nick) {
                    currentStreamerEl.innerText = `${currentId} (${existing.nick})`;
                } else {
                    currentStreamerEl.innerText = `${currentId} (불러오는 중...)`;
                    try {
                        const res = await fetch('https://live.sooplive.com/afreeca/player_live_api.php', {
                            method: 'POST',
                            headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                            body: `bid=${encodeURIComponent(currentId)}`
                        });
                        const data = await res.json();
                        currentStreamerEl.innerText = (data?.CHANNEL?.BJNICK)
                            ? `${currentId} (${data.CHANNEL.BJNICK})`
                            : currentId;
                    } catch(e) {
                        currentStreamerEl.innerText = currentId;
                    }
                }
                document.getElementById('st-quick-record-btn').onclick = () => {
                    triggerQuickRecord();
                    closeModal();
                };
            }
            overlay.style.display = 'block'; modal.style.display = 'flex';
        };

        const closeModal = () => {
            overlay.style.display = 'none';
            modal.style.display = 'none';
        };

        modal.querySelector('.st-close-btn').onclick = closeModal;
        overlay.onclick = closeModal;
        GM_registerMenuCommand('⚙️ SOOP 녹화비서 열기 (Alt+S)', openModal);

        document.addEventListener('keydown', (e) => {
            if (!e.altKey) return;
            const key = e.key.toLowerCase();

            if (key === 's') {
                e.preventDefault();
                modal.style.display === 'flex' ? closeModal() : openModal();
            }
            if (key === 'a') {
                e.preventDefault();
                saveTimestamp();
            }
            if (key === 'r') {
                e.preventDefault();
                if (!isBroadcastTab || !currentId) return;
                if (isRecording) {
                    forceStopRecording(false);
                    showToast('⏹ 퀵녹화를 수동 종료합니다.');
                } else {
                    triggerQuickRecord();
                }
            }
        });

        document.getElementById('st-sleep-add-btn').onclick = () => {
            let current = GM_getValue('sleepUntil', 0);
            const now = Date.now();
            if (current < now) current = now;
            GM_setValue('sleepUntil', current + 3600000);
        };
        document.getElementById('st-sleep-reset-btn').onclick = () => { GM_setValue('sleepUntil', 0); };

        // ✅ 수면 모드 상태 업데이트 타이머 등록
        TimerManager.set('sleepModeUpdate', setInterval(() => {
            if (modal.style.display === 'flex') {
                const sleepUntil = GM_getValue('sleepUntil', 0);
                const now = Date.now();
                const sleepStatusEl = document.getElementById('st-sleep-status');

                if (sleepUntil > now) {
                    const diffMins = Math.ceil((sleepUntil - now) / 60000);
                    sleepStatusEl.innerText = `${diffMins}분 남음`;
                    sleepStatusEl.style.color = '#0d6efd';
                } else {
                    sleepStatusEl.innerText = '꺼짐';
                    sleepStatusEl.style.color = '#dc3545';
                }
            }
        }, 1000));

        function renderStreamers() {
            const listDiv = document.getElementById('st-streamer-list');
            listDiv.innerHTML = '';
            STATE.streamers.forEach((s, index) => {
                const row = document.createElement('div');
                row.className = 'st-streamer-row';
                const nickOverlay = s.nick ? `<div class="st-nick-overlay">${s.nick}</div>` : '';
                row.innerHTML = `
                    <div class="st-input-wrapper">
                        <input type="text" class="st-input-sm" placeholder="아이디(ID) 입력" value="${s.id}" data-idx="${index}">
                        ${nickOverlay}
                    </div>
                    <div class="st-controls">
                        <label class="st-opt-label">알림<label class="st-mini-switch"><input type="checkbox" class="st-notify-chk" data-idx="${index}" ${s.notify ? 'checked' : ''}><span class="st-mini-slider"></span></label></label>
                        <label class="st-opt-label">참여<label class="st-mini-switch"><input type="checkbox" class="st-join-chk" data-idx="${index}" ${s.join ? 'checked' : ''}><span class="st-mini-slider"></span></label></label>
                        <label class="st-opt-label">녹화<label class="st-mini-switch"><input type="checkbox" class="st-record-chk" data-idx="${index}" ${s.record ? 'checked' : ''}><span class="st-mini-slider"></span></label></label>
                        <div style="display:flex; flex-direction:column; gap:2px; margin-left:4px;">
                            <button class="st-move-btn st-move-up" data-idx="${index}">▲</button>
                            <button class="st-move-btn st-move-down" data-idx="${index}">▼</button>
                        </div>
                        <button class="st-del-btn" data-idx="${index}">×</button>
                    </div>
                `;
                listDiv.appendChild(row);
            });

            document.getElementById('st-streamer-count').innerText = `${STATE.streamers.length}/10`;
            document.getElementById('st-add-streamer').style.display = STATE.streamers.length >= 10 ? 'none' : 'block';

            listDiv.querySelectorAll('.st-input-sm').forEach(el => el.addEventListener('change', async (e) => {
                const idx = parseInt(e.target.dataset.idx);
                const newId = e.target.value.trim();
                STATE.streamers[idx].id = newId;
                GM_setValue('streamers', STATE.streamers);

                if (newId) {
                    try {
                        const res = await fetch('https://live.sooplive.com/afreeca/player_live_api.php', {
                            method: 'POST',
                            headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                            body: `bid=${encodeURIComponent(newId)}`
                        });
                        const data = await res.json();
                        STATE.streamers[idx].nick = data?.CHANNEL?.BJNICK || '';
                        GM_setValue('streamers', STATE.streamers);
                        renderStreamers();
                    } catch(err) {
                        console.error('닉네임 조회 실패:', err);
                    }
                }
            }));

            listDiv.querySelectorAll('.st-move-up').forEach(el => el.addEventListener('click', (e) => {
                const idx = parseInt(e.target.dataset.idx);
                if (idx > 0) {
                    [STATE.streamers[idx], STATE.streamers[idx - 1]] = [STATE.streamers[idx - 1], STATE.streamers[idx]];
                    GM_setValue('streamers', STATE.streamers);
                    renderStreamers();
                }
            }));

            listDiv.querySelectorAll('.st-move-down').forEach(el => el.addEventListener('click', (e) => {
                const idx = parseInt(e.target.dataset.idx);
                if (idx < STATE.streamers.length - 1) {
                    [STATE.streamers[idx], STATE.streamers[idx + 1]] = [STATE.streamers[idx + 1], STATE.streamers[idx]];
                    GM_setValue('streamers', STATE.streamers);
                    renderStreamers();
                }
            }));

            listDiv.querySelectorAll('.st-notify-chk').forEach(el => el.addEventListener('change', (e) => {
                STATE.streamers[parseInt(e.target.dataset.idx)].notify = e.target.checked;
                GM_setValue('streamers', STATE.streamers);
            }));

            listDiv.querySelectorAll('.st-join-chk').forEach(el => el.addEventListener('change', (e) => {
                STATE.streamers[parseInt(e.target.dataset.idx)].join = e.target.checked;
                GM_setValue('streamers', STATE.streamers);
            }));

            listDiv.querySelectorAll('.st-record-chk').forEach(el => el.addEventListener('change', (e) => {
                STATE.streamers[parseInt(e.target.dataset.idx)].record = e.target.checked;
                GM_setValue('streamers', STATE.streamers);
            }));

            listDiv.querySelectorAll('.st-del-btn').forEach(el => el.addEventListener('click', (e) => {
                const idx = parseInt(e.target.dataset.idx);
                const targetId = STATE.streamers[idx].id;
                STATE.streamers.splice(idx, 1);
                GM_setValue('streamers', STATE.streamers);

                if (targetId) {
                    // 삭제된 스트리머 관련 데이터 정리
                    ['lastOpened', 'lastToast', 'pop_lock', 'notify_lock'].forEach(prefix => {
                        GM_setValue(`${prefix}_${targetId}`, 0);
                    });
                }
                renderStreamers();
            }));
        }

        renderStreamers();

        document.getElementById('st-add-streamer').addEventListener('click', () => {
            if (STATE.streamers.length < 10) {
                STATE.streamers.push({ id: '', nick: '', notify: true, join: false, record: false });
                GM_setValue('streamers', STATE.streamers);
                renderStreamers();
            }
        });

        // 설정 변경 이벤트
        document.getElementById('st-master-toggle').onchange = (e) => { GM_setValue('isMasterOn', e.target.checked); };
        document.getElementById('st-silent-toggle').onchange = (e) => { STATE.silentMode = e.target.checked; GM_setValue('silentMode', STATE.silentMode); };
        document.getElementById('st-autoclose-toggle').onchange = (e) => { STATE.autoClose = e.target.checked; GM_setValue('autoClose', STATE.autoClose); };
        document.getElementById('st-split-select').onchange = (e) => { STATE.splitSize = parseInt(e.target.value); GM_setValue('splitSize', STATE.splitSize); };

        document.getElementById('st-bitrate-input').addEventListener('change', (e) => {
            let val = parseInt(e.target.value);
            val = Math.max(1, Math.min(30, val)); // 1~30 범위 제한
            e.target.value = val;
            STATE.videoBitrate = val;
            GM_setValue('videoBitrate', STATE.videoBitrate);
        });

        document.getElementById('st-fastopen-toggle').onchange = (e) => { STATE.fastOpen = e.target.checked; GM_setValue('fastOpen', STATE.fastOpen); };
        document.getElementById('st-eco-toggle').onchange = (e) => { STATE.ecoMode = e.target.checked; GM_setValue('ecoMode', STATE.ecoMode); };

        // ✅ 수정: GM_getValue → GM_setValue
        document.getElementById('st-autohl-toggle').onchange = (e) => {
            STATE.autoHighlight = e.target.checked;
            GM_setValue('autoHighlight', STATE.autoHighlight);
        };

        document.getElementById('st-chatformat-select').onchange = (e) => {
            STATE.chatFormat = e.target.value;
            GM_setValue('chatFormat', STATE.chatFormat);
        };

        document.getElementById('st-interval-input').addEventListener('change', (e) => {
            let val = parseInt(e.target.value);
            if (isNaN(val) || val < 5) val = 5;
            e.target.value = val;
            GM_setValue('checkInterval', val);
        });
    }

    // ==========================================
    // ★ 알림 시스템 & 소리 발생기
    // ==========================================
    function playDingSound() {
        try {
            const AudioContext = window.AudioContext || window.webkitAudioContext;
            if (!AudioContext) return;
            const ctx = new AudioContext();
            const osc = ctx.createOscillator();
            const gainNode = ctx.createGain();

            osc.type = 'sine';
            osc.frequency.setValueAtTime(1046.50, ctx.currentTime);
            osc.frequency.setValueAtTime(1318.51, ctx.currentTime + 0.1);

            gainNode.gain.setValueAtTime(0.1, ctx.currentTime);
            gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.5);

            osc.connect(gainNode);
            gainNode.connect(ctx.destination);

            osc.start();
            osc.stop(ctx.currentTime + 0.5);
        } catch(e) {
            console.error('알림음 재생 실패:', e);
        }
    }

    function showToast(message) {
        let toast = document.getElementById('st-toast');
        if (!toast) {
            toast = document.createElement('div');
            toast.id = 'st-toast';
            toast.style.cssText = 'position:fixed; bottom:30px; right:30px; background:rgba(0,0,0,0.85); color:white; padding:12px 20px; border-radius:8px; font-size:14px; font-weight:bold; z-index:2147483647; display:none; opacity:0; transition:opacity 0.3s; pointer-events:none; border-left:4px solid #FF4B8B; box-shadow:0 4px 10px rgba(0,0,0,0.5);';
        }

        const fsElement = document.fullscreenElement || document.webkitFullscreenElement;
        const container = fsElement || document.body;

        if (!toast.parentNode || toast.parentNode !== container) {
            container.appendChild(toast);
        }

        toast.innerText = message;
        toast.style.display = 'block';
        setTimeout(() => { toast.style.opacity = '1'; }, 10);

        TimerManager.clear('toastHide');
        TimerManager.set('toastHide', setTimeout(() => {
            toast.style.opacity = '0';
            setTimeout(() => {
                toast.style.display = 'none';
                if (toast.parentNode) toast.parentNode.removeChild(toast);
            }, 300);
        }, 3000));
    }

    // ==========================================
    // 4. 관제탑 레이더
    // ==========================================
    function startControlTower() {
        let isRadarRunning = false;

        async function radar() {
            if (isRadarRunning) return;

            const checkIntervalMs = GM_getValue('checkInterval', 15) * 1000;
            const masterLock = GM_getValue('st_master_radar_lock', 0);

            if (Date.now() < masterLock) {
                TimerManager.set('radarNext', setTimeout(radar, checkIntervalMs));
                return;
            }

            GM_setValue('st_master_radar_lock', Date.now() + checkIntervalMs - 1000);
            isRadarRunning = true;

            try {
                if (!GM_getValue('isMasterOn', false) || Date.now() < GM_getValue('sleepUntil', 0)) {
                    return;
                }

                const currentStreamers = GM_getValue('streamers', []);

                for (let i = 0; i < currentStreamers.length; i++) {
                    const s = currentStreamers[i];
                    if (!s.id || (!s.notify && !s.join && !s.record)) continue;

                    try {
                        const res = await fetch('https://live.sooplive.com/afreeca/player_live_api.php', {
                            method: 'POST',
                            headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                            body: `bid=${encodeURIComponent(s.id)}`
                        });
                        const data = await res.json();

                        // 닉네임 업데이트
                        if (data?.CHANNEL?.BJNICK && data.CHANNEL.BJNICK !== s.nick) {
                            currentStreamers[i].nick = data.CHANNEL.BJNICK;
                            GM_setValue('streamers', currentStreamers);
                        }

                        // 방송 중인지 확인
                        if (data?.CHANNEL?.RESULT === 1) {
                            await new Promise(resolve => setTimeout(resolve, Math.random() * 1500));

                            const currentTime = Date.now();
                            const lastOpened = GM_getValue(`lastOpened_${s.id}`, 0);
                            const lastToast = GM_getValue(`lastToast_${s.id}`, 0);

                            // 알림 처리
                            if (s.notify && currentTime - lastToast > 12 * 60 * 60 * 1000) {
                                const notifyLock = GM_getValue(`notify_lock_${s.id}`, 0);
                                if(currentTime > notifyLock) {
                                    GM_setValue(`notify_lock_${s.id}`, currentTime + 2000);
                                    playDingSound();

                                    const toastData = { id: s.id, nick: s.nick || s.id, time: currentTime };
                                    drawBroadcastToast(toastData);
                                    GM_setValue('st_global_toast_trigger', toastData);
                                    GM_setValue(`lastToast_${s.id}`, currentTime);
                                }
                            }

                            // 자동 참여 처리
                            if ((s.join || s.record) && currentTime - lastOpened > 12 * 60 * 60 * 1000) {
                                const popLock = GM_getValue(`pop_lock_${s.id}`, 0);
                                if (currentTime > popLock) {

                                    if (!STATE.fastOpen) {
                                        const globalLock = GM_getValue('global_tab_lock', 0);
                                        if (currentTime < globalLock) {
                                            console.log('⏳ [안전장치] 앞 탭이 열리는 중입니다. 대기합니다.');
                                            break;
                                        }
                                        GM_setValue('global_tab_lock', currentTime + 5000);
                                    }

                                    GM_setValue(`pop_lock_${s.id}`, currentTime + 6000);
                                    GM_setValue(`lastOpened_${s.id}`, currentTime);
                                    GM_openInTab(`https://play.sooplive.com/${s.id}`, { active: true, insert: true });

                                    if (!STATE.fastOpen) {
                                        break;
                                    }
                                }
                            }
                        }
                    } catch (e) {
                        console.error(`스트리머 ${s.id} 확인 중 오류:`, e);
                    }
                }
            } catch (error) {
                console.error('레이더 작동 중 에러 발생:', error);
            } finally {
                isRadarRunning = false;
            }

            TimerManager.set('radarNext', setTimeout(radar, checkIntervalMs));
        }

        TimerManager.set('radarNext', setTimeout(radar, 1000));
    }

    // ==========================================
    // ★ 투명 오디오 (수면 모드 방지 꼼수)
    // ==========================================
    let keepAliveAudioCtx = null;
    let keepAliveOsc = null;

    function startKeepAliveAudio() {
        try {
            if (!keepAliveAudioCtx) {
                keepAliveAudioCtx = new (window.AudioContext || window.webkitAudioContext)();
            }
            if (keepAliveAudioCtx.state === 'suspended') {
                keepAliveAudioCtx.resume();
            }
            if (!keepAliveOsc) {
                keepAliveOsc = keepAliveAudioCtx.createOscillator();
                keepAliveOsc.type = 'sine';
                keepAliveOsc.frequency.setValueAtTime(200, keepAliveAudioCtx.currentTime);

                const gainNode = keepAliveAudioCtx.createGain();
                gainNode.gain.setValueAtTime(0.001, keepAliveAudioCtx.currentTime);

                keepAliveOsc.connect(gainNode);
                gainNode.connect(keepAliveAudioCtx.destination);
                keepAliveOsc.start();
                console.log('🛡️ [수면 방지] 투명 오디오 가동!');
            }
        } catch (e) {
            console.error('투명 오디오 생성 실패:', e);
        }
    }

    function stopKeepAliveAudio() {
        if (keepAliveOsc) {
            try {
                keepAliveOsc.stop();
                keepAliveOsc.disconnect();
            } catch(e){}
            keepAliveOsc = null;
        }
        if (keepAliveAudioCtx && keepAliveAudioCtx.state !== 'closed') {
            try { keepAliveAudioCtx.close(); } catch(e){}
            keepAliveAudioCtx = null;
        }
    }

    // ==========================================
    // 5. 🥷 스마트 무인 캔버스 녹화 & 채팅 아카이브 엔진
    // ==========================================
    let recordingStartTime = null;
    let isRecording = false;
    let isEngineInit = false;
    let mediaRecorder = null;
    let recordedChunks = [];
    let recordingBytes = 0;

    let isSplitting = false;
    let isManualStop = false;
    let currentStreamerIdForRec = null;
    let recordingCanvas = null;
    let recordingCtx = null;
    let canvasStream = null;
    let audioCtx = null;
    let audioSource = null;
    let audioDest = null;
    let localGainNode = null;
    let isLocalMuted = STATE.silentMode;
    let chatLogs = [];
    let chatObserver = null;
    let autoHighlightQueue = [];
    let lastAutoHighlightTime = 0;

    let drawingLoopId = null;

    function getDualTimestamp() {
        const now = new Date();
        const realTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
        let relativeTime = "00:00:00";

        if (isRecording && recordingStartTime) {
            const totalSec = Math.floor((Date.now() - recordingStartTime) / 1000);
            const h = String(Math.floor(totalSec / 3600)).padStart(2, '0');
            const m = String(Math.floor((totalSec % 3600) / 60)).padStart(2, '0');
            const s = String(totalSec % 60).padStart(2, '0');
            relativeTime = `${h}:${m}:${s}`;
        }
        return { combined: `[${realTime} | ⏱️ ${relativeTime}]` };
    }

    function saveTimestamp() {
        if (!isRecording || !recordingStartTime) {
            showToast('⚠️ 녹화 중일 때만 북마크가 가능합니다.');
            return;
        }
        const timeMs = Date.now() - recordingStartTime;
        const t = getDualTimestamp();
        chatLogs.push({ timeMs: timeMs, text: `\n${t.combined} 🔖 --- 수동 타임스탬프 --- \n`, isSys: true });
        showToast(`🔖 북마크 완료!`);
    }

    function startChatArchiving() {
        chatLogs = [];
        autoHighlightQueue = [];
        const chatArea = document.getElementById('chat_area');
        if (!chatArea) return;

        chatObserver = new MutationObserver((mutations) => {
            mutations.forEach((mutation) => {
                mutation.addedNodes.forEach((node) => {
                    if (node.nodeType !== 1) return;

                    const nicknameEl = node.querySelector('dt');
                    const msgEl = node.querySelector('dd');
                    const t = getDualTimestamp();
                    let msgText = '', nick = '';

                    if (nicknameEl && msgEl) {
                        nick = nicknameEl.innerText.trim();
                        msgText = msgEl.innerText.trim();
                    } else {
                        const text = node.innerText;
                        if(text) msgText = text.replace(/\n/g, ' ');
                    }

                    if (msgText) {
                        const timeMs = (isRecording && recordingStartTime) ? Date.now() - recordingStartTime : 0;
                        chatLogs.push({
                            timeMs: timeMs,
                            nick: nick,
                            msg: msgText,
                            text: nick ? `${t.combined} ${nick}: ${msgText}` : `${t.combined} ${msgText}`
                        });

                        // 자동 하이라이트 감지
                        if (STATE.autoHighlight && (msgText.includes('ㅋㅋ') || msgText.includes('??'))) {
                            const now = Date.now();
                            autoHighlightQueue.push(now);
                            autoHighlightQueue = autoHighlightQueue.filter(time => now - time < 10000);

                            if (autoHighlightQueue.length >= 15 && now - lastAutoHighlightTime > 60000) {
                                lastAutoHighlightTime = now;
                                autoHighlightQueue = [];
                                chatLogs.push({
                                    timeMs: timeMs,
                                    text: `\n${t.combined} 🔥 --- 자동 하이라이트 감지 구간 --- \n`,
                                    isSys: true
                                });
                                showToast(`🔥 꿀잼 구간 자동 북마크 완료!`);
                            }
                        }
                    }
                });
            });
        });
        chatObserver.observe(chatArea, { childList: true, subtree: true });
    }

    function stopChatArchiving() {
        if (chatObserver) {
            chatObserver.disconnect();
            chatObserver = null;
        }
    }

    function formatSrtTime(ms) {
        const h = String(Math.floor(ms / 3600000)).padStart(2, '0');
        const m = String(Math.floor((ms % 3600000) / 60000)).padStart(2, '0');
        const s = String(Math.floor((ms % 60000) / 1000)).padStart(2, '0');
        const msStr = String(ms % 1000).padStart(3, '0');
        return `${h}:${m}:${s},${msStr}`;
    }

    function downloadChat(streamerId, isPart = false, isEmergency = false) {
        if (STATE.chatFormat === 'none') return;

        if (chatLogs.length === 0) {
            chatLogs.push({
                timeMs: 0,
                nick: '시스템',
                msg: '녹화 구간 내에 입력된 채팅이 없습니다.',
                text: '[00:00:00] 시스템: 녹화 구간 내에 입력된 채팅이 없습니다.'
            });
        }

        const now = new Date();
        const timeString = `${now.getFullYear()}${String(now.getMonth()+1).padStart(2,'0')}${String(now.getDate()).padStart(2,'0')}_${String(now.getHours()).padStart(2,'0')}${String(now.getMinutes()).padStart(2,'0')}`;
        let suffix = isPart ? '_part' : '';
        if (isEmergency) suffix += '_긴급저장';
        const baseFileName = `SOOP_${streamerId}_${timeString}${suffix}_chat`;

        const triggerDownload = (content, ext) => {
            const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.style.display = 'none';
            a.href = url;
            a.download = `${baseFileName}.${ext}`;
            document.body.appendChild(a);
            a.click();
            setTimeout(() => {
                if(document.body.contains(a)) document.body.removeChild(a);
                URL.revokeObjectURL(url);
            }, 100);
        };

        let delayMs = 0;

        // TXT 파일 다운로드
        if (STATE.chatFormat === 'txt' || STATE.chatFormat === 'both') {
            const txtContent = chatLogs.map(log => log.text || (log.nick ? `${log.nick}: ${log.msg}` : log.msg)).join('\n');
            triggerDownload(txtContent, 'txt');
            delayMs = 1500;
        }

        // SRT 자막 파일 다운로드
        if (STATE.chatFormat === 'srt' || STATE.chatFormat === 'both') {
            let srtContent = '';
            let srtIndex = 1;

            chatLogs.forEach(log => {
                if (log.isSys) return; // 시스템 메시지 제외
                const startTime = formatSrtTime(log.timeMs);
                const endTime = formatSrtTime(log.timeMs + 5000);
                const displayMsg = log.nick ? `${log.nick}: ${log.msg}` : log.msg;
                srtContent += `${srtIndex}\n${startTime} --> ${endTime}\n${displayMsg}\n\n`;
                srtIndex++;
            });

            if (isEmergency) {
                triggerDownload(srtContent, 'srt');
            } else {
                setTimeout(() => { triggerDownload(srtContent, 'srt'); }, delayMs);
            }
        }
        chatLogs = [];
    }

    function toggleSpeaker() {
        isLocalMuted = !isLocalMuted;
        const volBtn = document.querySelector('.st-rec-vol-btn');

        if (isLocalMuted) {
            if (localGainNode) localGainNode.gain.value = 0.001;
            if(volBtn) volBtn.innerText = '🔇';
            showToast('🔇 방송 음소거 (백그라운드 녹화 모드)');
        } else {
            if (localGainNode) localGainNode.gain.value = 1.0;
            if(volBtn) volBtn.innerText = '🔊';
            showToast('🔊 방송 소리 켬');
        }
    }

    function startCanvasRecording(video, streamerId) {
        if (isRecording) return;

        isManualStop = false;

        recordingCanvas = document.createElement('canvas');
        recordingCanvas.width = video.videoWidth || 1920;
        recordingCanvas.height = video.videoHeight || 1080;

        recordingCtx = recordingCanvas.getContext('2d', {
            alpha: false,
            desynchronized: true,
            willReadFrequently: false
        });

        // 오디오 세팅
        try {
            if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
            if (!audioSource) audioSource = audioCtx.createMediaElementSource(video);
            audioDest = audioCtx.createMediaStreamDestination();

            if (!localGainNode) {
                localGainNode = audioCtx.createGain();
                localGainNode.connect(audioCtx.destination);
            }

            try { audioSource.disconnect(); } catch(e) {}
            audioSource.connect(audioDest);
            audioSource.connect(localGainNode);
            localGainNode.gain.value = isLocalMuted ? 0.001 : 1.0;
        } catch (e) {
            console.error("오디오 세팅 에러:", e);
        }

        canvasStream = recordingCanvas.captureStream(0);
        if (audioDest) {
            const audioTrack = audioDest.stream.getAudioTracks()[0];
            if (audioTrack) canvasStream.addTrack(audioTrack);
        }

        // 코덱 선택
        const codecPriority = ['avc1.640028', 'avc1.4d0028', 'vp9', 'vp8'];
        let bestMimeType = 'video/webm';
        for (const codec of codecPriority) {
            const testType = `video/webm; codecs=${codec}`;
            if (MediaRecorder.isTypeSupported(testType)) {
                bestMimeType = testType;
                break;
            }
        }

        const targetBitrate = STATE.videoBitrate * 1000000;

        const recOptions = {
            mimeType: bestMimeType,
            videoBitsPerSecond: targetBitrate,
            audioBitsPerSecond: 128000
        };

        mediaRecorder = new MediaRecorder(canvasStream, recOptions);

        const MAX_CHUNK_SIZE = STATE.splitSize * 1024 * 1024;

        mediaRecorder.ondataavailable = (event) => {
            if (event.data && event.data.size > 0) {
                recordedChunks.push(event.data);
                recordingBytes += event.data.size;
                if (STATE.splitSize > 0 && recordingBytes >= MAX_CHUNK_SIZE) {
                    saveAndRestart(streamerId);
                }
            }
        };

        mediaRecorder.onstop = () => {
            if (drawingLoopId) {
                cancelAnimationFrame(drawingLoopId);
                drawingLoopId = null;
            }
            stopChatArchiving();
            stopKeepAliveAudio();

            if (!isSplitting) {
                // 오디오 원복
                if (audioSource && audioCtx) {
                    try {
                        audioSource.disconnect();
                        audioSource.connect(audioCtx.destination);
                        if (localGainNode) localGainNode.gain.value = 1.0;
                    } catch(e) {}
                }
                isLocalMuted = STATE.silentMode;

                downloadVideo(streamerId);

                setTimeout(() => {
                    if (STATE.chatFormat !== 'none') downloadChat(streamerId);
                }, 1500);

                // 자동 닫기
                if(STATE.autoClose && !isManualStop) {
                    showToast('♻️ 안전한 파일 저장을 위해 6초 후 탭을 회수합니다.');
                    setTimeout(() => { window.location.href = 'about:blank'; }, 6000);
                }

                // ✅ visibility 리스너 정리
                if (window._st_visibilityHandler) {
                    document.removeEventListener('visibilitychange', window._st_visibilityHandler);
                    window._st_visibilityHandler = null;
                }

                isEngineInit = false;
                mediaRecorder = null;
            }
        };

        mediaRecorder.start(2000);
        isRecording = true;
        recordingStartTime = Date.now();
        recordingBytes = 0;

        startKeepAliveAudio();
        createRecBadgeUI();
        startDrawingLoop(video);
        monitorBroadcastEnd(streamerId);

        if (STATE.chatFormat !== 'none') startChatArchiving();
    }

    function monitorBroadcastEnd(streamerId) {
        TimerManager.clear('broadcastEndMonitor');

        TimerManager.set('broadcastEndMonitor', setInterval(async () => {
            if(!isRecording) {
                TimerManager.clear('broadcastEndMonitor');
                return;
            }
            try {
                const res = await fetch('https://live.sooplive.com/afreeca/player_live_api.php', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                    body: `bid=${encodeURIComponent(streamerId)}`
                });
                const data = await res.json();
                if (data?.CHANNEL?.RESULT === 0) {
                    TimerManager.clear('broadcastEndMonitor');
                    GM_setValue(`lastOpened_${streamerId}`, 0);
                    forceStopRecording(true);
                }
            } catch(e) {
                console.error('방송 종료 확인 중 오류:', e);
            }
        }, 15000));
    }

    // ✅ 개선: visibility 이벤트 리스너 중복 방지
    function startDrawingLoop(video) {
        const targetFps = STATE.ecoMode ? 30 : 60;
        const frameInterval = 1000 / targetFps;
        let lastTime = 0;
        let isBackgroundTab = document.hidden;
        let bgIntervalId = null;

        function performDraw(timestamp) {
            if (!isRecording) return;
            if (timestamp - lastTime < frameInterval) return;
            lastTime = timestamp;

            if (video.videoWidth > 0 && video.videoHeight > 0) {
                if (recordingCanvas.width !== video.videoWidth) {
                    recordingCanvas.width = video.videoWidth;
                    recordingCanvas.height = video.videoHeight;
                }
                recordingCtx.drawImage(video, 0, 0, recordingCanvas.width, recordingCanvas.height);
                const videoTrack = canvasStream.getVideoTracks()[0];
                if (videoTrack?.requestFrame) videoTrack.requestFrame();
            }
        }

        function rafLoop(timestamp) {
            if (!isRecording || isBackgroundTab) return;
            performDraw(timestamp);
            drawingLoopId = requestAnimationFrame(rafLoop);
        }

        function startBgLoop() {
            if (bgIntervalId) return;
            startKeepAliveAudio();
            const bgInterval = Math.max(frameInterval, 33);
            bgIntervalId = setInterval(() => {
                if (!isRecording) {
                    clearInterval(bgIntervalId);
                    bgIntervalId = null;
                    return;
                }
                performDraw(performance.now());
            }, bgInterval);
            console.log('📺 [녹화] 백그라운드 모드 전환 (setInterval + Keep-Alive)');
        }

        function stopBgLoop() {
            if (bgIntervalId) {
                clearInterval(bgIntervalId);
                bgIntervalId = null;
            }
            stopKeepAliveAudio();
        }

        function handleVisibilityChange() {
            if (!isRecording) return;
            isBackgroundTab = document.hidden;

            if (isBackgroundTab) {
                if (drawingLoopId) {
                    cancelAnimationFrame(drawingLoopId);
                    drawingLoopId = null;
                }
                startBgLoop();
            } else {
                stopBgLoop();
                console.log('📺 [녹화] 포그라운드 복귀 (requestAnimationFrame)');
                drawingLoopId = requestAnimationFrame(rafLoop);
            }
        }

        // ✅ 기존 리스너 제거 후 새로 등록
        if (window._st_visibilityHandler) {
            document.removeEventListener('visibilitychange', window._st_visibilityHandler);
        }

        document.addEventListener('visibilitychange', handleVisibilityChange);
        window._st_visibilityHandler = handleVisibilityChange;

        if (isBackgroundTab) {
            startBgLoop();
        } else {
            drawingLoopId = requestAnimationFrame(rafLoop);
        }
    }

    function saveAndRestart(streamerId) {
        if (isSplitting) return;
        isSplitting = true;

        mediaRecorder.onstop = () => {
            if (drawingLoopId) {
                cancelAnimationFrame(drawingLoopId);
                drawingLoopId = null;
            }
            stopChatArchiving();
            stopKeepAliveAudio();

            downloadVideo(streamerId, true);

            setTimeout(() => {
                if (STATE.chatFormat !== 'none') downloadChat(streamerId, true);
            }, 1500);

            // ✅ visibility 리스너 정리
            if (window._st_visibilityHandler) {
                document.removeEventListener('visibilitychange', window._st_visibilityHandler);
                window._st_visibilityHandler = null;
            }

            setTimeout(() => {
                const video = document.querySelector('video');
                if (video) {
                    isSplitting = false;
                    isRecording = false;
                    startCanvasRecording(video, streamerId);
                }
            }, 3500);
        };
        mediaRecorder.stop();
    }

    function forceStopRecording(isAutoEnd = false) {
        if (!isRecording) return;

        if (!isAutoEnd) isManualStop = true;

        if (mediaRecorder && mediaRecorder.state !== 'inactive') {
            mediaRecorder.stop();
        }
        isRecording = false;

        const recBadge = document.getElementById('st-rec-badge');
        if (recBadge) recBadge.remove();

        TimerManager.clear('badgeTimer');
    }

    function downloadVideo(streamerId, isPart = false, isEmergency = false) {
        if (recordedChunks.length === 0) return;

        const blob = new Blob(recordedChunks, { type: 'video/webm' });
        const url = URL.createObjectURL(blob);
        const now = new Date();
        const timeString = `${now.getFullYear()}${String(now.getMonth()+1).padStart(2,'0')}${String(now.getDate()).padStart(2,'0')}_${String(now.getHours()).padStart(2,'0')}${String(now.getMinutes()).padStart(2,'0')}`;
        let suffix = isPart ? '_part' : '';
        if (isEmergency) suffix += '_긴급저장';

        const a = document.createElement('a');
        a.style.display = 'none';
        a.href = url;
        a.download = `SOOP_${streamerId}_${timeString}${suffix}.webm`;
        document.body.appendChild(a);
        a.click();

        setTimeout(() => {
            if(document.body.contains(a)) document.body.removeChild(a);
            URL.revokeObjectURL(url);
        }, 100);

        recordedChunks = [];
        recordingBytes = 0;
    }

    function createRecBadgeUI() {
        let badge = document.getElementById('st-rec-badge');
        if (!badge) {
            badge = document.createElement('div');
            badge.id = 'st-rec-badge';
            const soundIcon = isLocalMuted ? '🔇' : '🔊';
            badge.innerHTML = `<div class="st-rec-dot"></div><div class="st-rec-content">REC <span class="st-rec-text" id="st-rec-timer">00:00:00</span> <button class="st-rec-btn st-rec-vol-btn">${soundIcon}</button> <button class="st-rec-btn st-rec-stop-btn">⏹</button></div>`;

            const playerContainer = document.querySelector('#player_area') || document.body;
            playerContainer.appendChild(badge);

            badge.querySelector('.st-rec-vol-btn').addEventListener('click', (e) => {
                e.stopPropagation();
                toggleSpeaker();
            });

            badge.querySelector('.st-rec-stop-btn').addEventListener('click', (e) => {
                e.stopPropagation();
                forceStopRecording();
            });

            let collapseTimer = null;

            function scheduleCollapse(delay) {
                clearTimeout(collapseTimer);
                collapseTimer = setTimeout(() => {
                    if (isRecording && badge) badge.classList.add('collapsed');
                }, delay);
            }

            scheduleCollapse(4000);

            badge.addEventListener('mouseenter', () => {
                clearTimeout(collapseTimer);
                collapseTimer = null;
                badge.classList.remove('collapsed');
            });

            badge.addEventListener('mouseleave', () => {
                scheduleCollapse(2000);
            });
        }

        badge.style.display = 'flex';

        TimerManager.clear('badgeTimer');
        TimerManager.set('badgeTimer', setInterval(() => {
            if (!recordingStartTime || !isRecording) return;
            const diff = Date.now() - recordingStartTime;
            const h = String(Math.floor(diff / 3600000)).padStart(2, '0');
            const m = String(Math.floor((diff % 3600000) / 60000)).padStart(2, '0');
            const s = String(Math.floor((diff % 60000) / 1000)).padStart(2, '0');
            const timerEl = document.getElementById('st-rec-timer');
            if (timerEl) timerEl.innerText = `${h}:${m}:${s}`;
        }, 1000));
    }

    // ==========================================
    // ★ 퀵녹화 트리거 함수
    // ==========================================
    function triggerQuickRecord() {
        if (!isBroadcastTab || !currentId) {
            showToast('⚠️ 라이브 탭에서만 퀵녹화가 가능합니다.');
            return;
        }
        if (isRecording) {
            showToast('⚠️ 이미 녹화가 진행 중입니다.');
            return;
        }

        showToast(`🔴 ${currentId} 1회용 퀵 녹화를 시작합니다!`);
        const videoElement = document.querySelector('video');

        if (videoElement) {
            isEngineInit = false;
            initRecordingEngine(true);
        } else {
            showToast('⚠️ 영상 요소를 찾을 수 없습니다. 방송이 시작됐는지 확인하세요.');
        }
    }

    // ==========================================
    // 긴급 저장 (브라우저 종료 전)
    // ==========================================
    window.addEventListener('beforeunload', () => {
        if (isRecording) {
            if (STATE.chatFormat !== 'none' && chatLogs.length > 0) {
                downloadChat(currentStreamerIdForRec, false, true);
            }
            if (recordedChunks.length > 0) {
                downloadVideo(currentStreamerIdForRec, false, true);
            }
        }
    });

    function initRecordingEngine(forceStart = false) {
        if (isRecording || isEngineInit) return;
        if (!currentId) return;

        if (!forceStart) {
            const streamerConfig = STATE.streamers.find(s => s.id === currentId);
            if (!GM_getValue('isMasterOn', false) || !streamerConfig || !streamerConfig.record) return;
        }

        isEngineInit = true;
        currentStreamerIdForRec = currentId;

        const v = document.querySelector('video');
        if (v && !mediaRecorder) {
            setTimeout(() => startCanvasRecording(v, currentId), 1000);
        } else {
            const observer = new MutationObserver(() => {
                const vi = document.querySelector('video');
                if (vi && !mediaRecorder) {
                    observer.disconnect();
                    setTimeout(() => startCanvasRecording(vi, currentId), 3000);
                }
            });
            observer.observe(document.body, { childList: true, subtree: true });
        }
    }

    // ==========================================
    // 초기화
    // ==========================================
    createUI();
    initRecordingEngine();
    startControlTower();

    // ==========================================
    // 성인 인증 자동 처리
    // ==========================================
    TimerManager.set('adultCheck', setInterval(() => {
        const adultBtns = document.querySelectorAll('.btn_ok, .btn_confirm, button');
        adultBtns.forEach(btn => {
            const btnText = btn.innerText;
            const bodyText = document.body.innerText;

            if ((btnText.includes('확인') || btnText.includes('시청하기')) &&
                (bodyText.includes('19세') || bodyText.includes('성인 인증'))) {
                const parentModal = btn.closest('div');
                if(parentModal && parentModal.style.display !== 'none') btn.click();
            }
        });
    }, 2000));

    // ==========================================
    // ★ 무결점 투트랙 방종 감지 시스템
    // ==========================================
    TimerManager.set('broadcastEndCheck', setInterval(() => {
        let isStreamEnded = false;

        // 플레이어 영역 체크
        const playerArea = document.getElementById('player_area');
        if (playerArea) {
            const cleanPlayerText = playerArea.innerText.replace(/\s/g, '');
            if (cleanPlayerText.includes('방송이종료') || cleanPlayerText.includes('방송을종료')) {
                isStreamEnded = true;
            }
        }

        // 채팅 영역 체크
        if (!isStreamEnded) {
            const chatArea = document.getElementById('chat_area');
            if (chatArea) {
                const cleanChatText = chatArea.innerText.replace(/\s/g, '');
                if (cleanChatText.includes('방송이종료된후에는채팅에참여하실수없습니다') ||
                    cleanChatText.includes('방송이종료되었습니다.')) {
                    isStreamEnded = true;
                }
            }
        }

        if (isStreamEnded) {
            if (currentId) GM_setValue(`lastOpened_${currentId}`, 0);

            if (isRecording) {
                forceStopRecording(true);
            }
            else {
                if (STATE.autoClose) {
                    showToast('♻️ 방송이 종료되어 3초 후 탭을 회수합니다.');
                    setTimeout(() => { window.location.href = 'about:blank'; }, 3000);
                }
            }
        }
    }, 3000));

    // ==========================================
    // ★ 전역 알림 수신기
    // ==========================================
    function drawBroadcastToast(data) {
        if (!data || !data.id) return;
        const toastId = `st_toast_${data.id}_${data.time}`;
        if (document.getElementById(toastId)) return;

        const existingToasts = document.querySelectorAll('.st-broadcast-toast');
        const toastCount = existingToasts.length;
        const topPosition = 80 + (toastCount * 80);

        const toastHtml = `
            <div id="${toastId}" class="st-broadcast-toast" style="position: fixed; top: ${topPosition}px; right: 30px; z-index: 2147483647; background: rgba(0, 0, 0, 0.85); color: #fff; padding: 16px 24px; border-radius: 8px; font-size: 15px; font-weight: bold; box-shadow: 0 4px 12px rgba(0,0,0,0.5); transition: opacity 0.5s ease; cursor: pointer; border-left: 4px solid #0058e9;">
                <span style="font-size: 18px; margin-right: 8px; vertical-align: middle;">📺</span>
                <span style="vertical-align: middle;">[${data.nick}] 님이 방송을 시작했습니다!</span>
            </div>
        `;

        const container = document.fullscreenElement || document.body;
        if (container) {
            container.insertAdjacentHTML('beforeend', toastHtml);
            const toastEl = document.getElementById(toastId);

            toastEl.onclick = function() {
                GM_openInTab(`https://play.sooplive.co.kr/${data.id}`, { active: true });
                toastEl.remove();
            };

            setTimeout(() => {
                const el = document.getElementById(toastId);
                if (el) {
                    el.style.opacity = '0';
                    setTimeout(() => el.remove(), 500);
                }
            }, 5000);
        }
    }

    GM_addValueChangeListener('st_global_toast_trigger', function(name, old_value, new_value, remote) {
        if (remote && new_value) drawBroadcastToast(new_value);
    });

    // ==========================================
    // 페이지 언로드 시 타이머 정리
    // ==========================================
    window.addEventListener('unload', () => {
        TimerManager.clearAll();
    });
})();