// ==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 = `
👀 현재 시청 중인 방송
-
마스터 스위치 (전체 작동)
💾 분할 저장
💬 채팅 저장
?⚠️ 화면에 우측 채팅창이 열려 있을 때만 수집이 가능합니다.
채팅창을 접어두거나, 채팅창이 사라지는 '전체화면' 모드에서는 채팅 수집이 완전히 중단됩니다. (일반 창 모드나 극장 모드를 사용해 주세요.)
💤 수면 모드 (관제탑 일시정지)
꺼짐
`;
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();
});
})();