Greasy Fork is available in English.
뱅온 알림 + 녹화
// ==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">×</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();
});
})();