// ==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 // @downloadURL https://update.greasyfork.icu/scripts/570488/SOOP%28%EC%88%B2%29%20%EB%85%B9%ED%99%94%EB%B9%84%EC%84%9C.user.js // @updateURL https://update.greasyfork.icu/scripts/570488/SOOP%28%EC%88%B2%29%20%EB%85%B9%ED%99%94%EB%B9%84%EC%84%9C.meta.js // ==/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 = `

🎥 SOOP 녹화비서
?관제탑이 정상 작동하려면 브라우저에 SOOP 탭이 최소 하나 이상 켜져 있어야 합니다.

라이브 좌측 상단에 녹화 정보를 볼 수 있으며 추가하지 않은 스트리머도 설정 창을 열어 녹화가 가능합니다.

소리 끄고 녹화할 때 절대 방송 음소거 금지! 소리가 하나도 없는 벙어리 영상이 저장됩니다. 녹화본이 끊길 수도 있습니다.

사용자가 직접 닫은(보기 싫어서 끈) 방송은 스크립트가 '12시간 동안' 자동으로 팝업을 띄우지 않습니다.

마스터 스위치 (전체 작동)
🤫 조용한 녹화
?무음 백그라운드로 몰래 녹화합니다. 좌측 상단 뱃지에서 언제든 🔊/🔇 조절 가능합니다.
♻️ 방종 시 탭 자원 회수
?방종이 감지되면 영상을 안전하게 저장하고, 3초 뒤 탭을 '빈 창(about:blank)'으로 이동시킵니다.
💾 분할 저장
💎 영상 품질
?녹화 화질(파일 용량)을 결정합니다. 보는 영상 화질기준으로 녹화됩니다.

• 3~4: 소통, 라디오 (저용량)
• 5~6: 일반 게임 (표준)
• 8 이상: FPS, 고화질 (고용량)
Mbps
🌱 에코 최적화
?녹화 프레임을 부드러운 30fps로 낮춰 컴퓨터 부담(CPU/RAM)을 줄입니다.
🔥 자동 하이라이트
?'ㅋㅋ', '??' 채팅이 폭발하는 구간에 자동으로 타임라인 북마크가 새겨집니다.

[Alt+A]로 수동 북마크도 가능!
💬 채팅 저장
?⚠️ 화면에 우측 채팅창이 열려 있을 때만 수집이 가능합니다.

채팅창을 접어두거나, 채팅창이 사라지는 '전체화면' 모드에서는 채팅 수집이 완전히 중단됩니다. (일반 창 모드나 극장 모드를 사용해 주세요.)
🚀 동시 다발 입장
?안전 대기열(5초)을 무시하고 켜진 방송을 한꺼번에 즉시 엽니다.
🎯 타겟 스트리머 우선순위
?참여/녹화 4개 초과는 비추천하며, 초과할 시 SOOP 기본 설정에 의해 가장 먼저 켜진(오래된) 방송이 종료됩니다.
s 마다 확인
${STATE.streamers.length}/10
대기 중...-
💤 수면 모드 (관제탑 일시정지)
꺼짐
`; 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 ? `
${s.nick}
` : ''; row.innerHTML = `
${nickOverlay}
`; 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 = `
REC 00:00:00
`; 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 = `
📺 [${data.nick}] 님이 방송을 시작했습니다!
`; 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(); }); })();