// ==UserScript==
// @name soop 방송 딜레이 자동 조정
// @namespace https://greatest.deepsurf.us/ko/scripts/539405
// @version 2.0
// @description soop 방송 딜레이를 목표 시간 이내로 자동 보정
// @icon https://www.google.com/s2/favicons?sz=64&domain=www.sooplive.co.kr
// @author 다크초코
// @match https://play.sooplive.co.kr/*
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const CONFIG = {
CHECK_INTERVAL_MS: 100, // 딜레이 체크 주기
HISTORY_DURATION_MS: 2000, // 최근 평균 딜레이 계산 구간
DEFAULT_TARGET_DELAY_MS: 1500, // 기본 목표 딜레이
START_THRESHOLD_MS: 50, // 목표 초과시 조정 시작 임계값 (첫 시작)
RESTART_THRESHOLD_MS: 200, // 목표 초과시 조정 재시작 임계값 (해제 후)
STOP_THRESHOLD_MS: 25, // 목표 이하시 조정 해제 임계값
CONSECUTIVE_REQUIRED: 3, // 연속 조건 충족 횟수
KP_PER_SECOND: 0.125, // P-제어 게인 (초 단위 오차 대비 배속 가중치)
MAX_RATE: 1.5, // 최대 배속
MIN_RATE: 0.8 // 최소 배속
};
const STORAGE_KEYS = {
ENABLED: 'soop_delay_enabled',
TARGET_DELAY: 'soop_delay_target_ms',
PANEL_POS: 'soop_delay_panel_pos'
};
let video = null;
let intervalId = null;
let delayHistory = [];
let isEnabled = loadEnabled();
let targetDelayMs = loadTargetDelay();
let isAdjusting = false;
let currentPlaybackRate = 1.0;
let lastDisplayUpdate = 0;
let fullscreenHidden = false;
let urlObserver = null;
let consecutiveOverCount = 0;
let consecutiveUnderCount = 0;
let hasBeenAdjusted = false;
function findVideo() {
return document.querySelector('video');
}
function calculateDelayMs(videoElement) {
if (!videoElement) return null;
try {
const buffered = videoElement.buffered;
if (buffered.length > 0) {
const end = buffered.end(buffered.length - 1);
const now = videoElement.currentTime;
const delaySec = end - now;
return delaySec >= 0 ? delaySec * 1000 : null;
}
} catch (e) {
console.warn('딜레이 계산 오류:', e);
}
return null;
}
function pushDelayHistory(delayMs) {
const now = Date.now();
delayHistory.push({ delayMs, t: now });
const cutoff = now - CONFIG.HISTORY_DURATION_MS;
delayHistory = delayHistory.filter(d => d.t >= cutoff);
}
function getAverageDelayMs() {
if (delayHistory.length === 0) return 0;
const sum = delayHistory.reduce((acc, d) => acc + d.delayMs, 0);
return sum / delayHistory.length;
}
function computeAutoRate(averageDelayMs) {
const errorMs = averageDelayMs - targetDelayMs;
const errorSec = errorMs / 1000;
let rate;
if (errorMs > 0) {
// 목표보다 지연 많음 -> 빠르게
rate = 1.0 + CONFIG.KP_PER_SECOND * errorSec;
} else {
// 목표보다 지연 적음 -> 느리게
rate = 1.0 + CONFIG.KP_PER_SECOND * errorSec;
}
return clamp(rate, CONFIG.MIN_RATE, CONFIG.MAX_RATE);
}
function clamp(v, lo, hi) {
if (v < lo) return lo;
if (v > hi) return hi;
return v;
}
function setPlaybackRateSafely(rate) {
if (!video) return;
try {
if (Math.abs((video.playbackRate || 1.0) - rate) > 0.01) {
video.playbackRate = rate;
}
currentPlaybackRate = rate;
} catch (e) {
console.warn('재생속도 설정 오류:', e);
}
}
function protectRateChange() {
if (!video) return;
const onRateChange = (e) => {
if (!video) return;
// 자동 조정 중에 외부가 속도를 바꿨다면 되돌림
if (isAdjusting && Math.abs(video.playbackRate - currentPlaybackRate) > 0.01) {
e.stopPropagation();
setPlaybackRateSafely(currentPlaybackRate);
}
};
video.removeEventListener('ratechange', onRateChange, true);
video.addEventListener('ratechange', onRateChange, true);
}
function tick() {
if (!video) {
video = findVideo();
if (!video) return;
protectRateChange();
}
const delayMs = calculateDelayMs(video);
if (delayMs == null) return;
pushDelayHistory(delayMs);
const avgMs = getAverageDelayMs();
renderInfo(avgMs);
if (!isEnabled) {
if (isAdjusting) {
isAdjusting = false;
setPlaybackRateSafely(1.0);
}
consecutiveOverCount = 0;
consecutiveUnderCount = 0;
hasBeenAdjusted = false; // 토글 off/on 시 조건 리셋
return;
}
// 즉시값 기반 판단 (평균 아닌 현재값)
// 첫 시작은 50ms 초과, 재시작은 200ms 초과 필요
const thresholdToUse = hasBeenAdjusted ? CONFIG.RESTART_THRESHOLD_MS : CONFIG.START_THRESHOLD_MS;
const currentOverTarget = delayMs > (targetDelayMs + thresholdToUse);
const currentUnderTarget = delayMs < (targetDelayMs - CONFIG.STOP_THRESHOLD_MS);
if (!isAdjusting) {
if (currentOverTarget) {
consecutiveOverCount++;
consecutiveUnderCount = 0;
if (consecutiveOverCount >= CONFIG.CONSECUTIVE_REQUIRED) {
isAdjusting = true;
hasBeenAdjusted = true;
}
} else {
consecutiveOverCount = 0;
}
} else {
if (currentUnderTarget) {
consecutiveUnderCount++;
consecutiveOverCount = 0;
if (consecutiveUnderCount >= CONFIG.CONSECUTIVE_REQUIRED) {
isAdjusting = false;
setPlaybackRateSafely(1.0);
return;
}
} else {
consecutiveUnderCount = 0;
}
// 조정 중일 때는 평균값으로 배속 계산
const rate = computeAutoRate(avgMs);
setPlaybackRateSafely(rate);
}
// 조정 중이 아니면 정상속도로 유지
if (!isAdjusting && Math.abs(currentPlaybackRate - 1.0) > 0.01) {
setPlaybackRateSafely(1.0);
}
}
function start() {
stop();
intervalId = setInterval(tick, CONFIG.CHECK_INTERVAL_MS);
}
function stop() {
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
}
function cleanup() {
stop();
delayHistory = [];
isAdjusting = false;
currentPlaybackRate = 1.0;
consecutiveOverCount = 0;
consecutiveUnderCount = 0;
hasBeenAdjusted = false;
if (video) {
try { video.playbackRate = 1.0; } catch (e) {}
}
video = null;
}
function handleFullscreenChange() {
const fs = !!(document.fullscreenElement || document.webkitFullscreenElement || document.mozFullScreenElement || document.msFullscreenElement);
fullscreenHidden = fs;
const panel = document.getElementById('soop-delay-panel');
if (panel) {
panel.style.display = fs ? 'none' : 'flex';
if (!fs) ensurePanelInViewport(panel);
}
}
function renderInfo(avgMs) {
const now = Date.now();
if (now - lastDisplayUpdate < 100) return;
lastDisplayUpdate = now;
const avgNode = document.getElementById('soop-delay-avg');
const rateNode = document.getElementById('soop-delay-rate');
if (avgNode) avgNode.textContent = `${avgMs.toFixed(0)}ms`;
if (rateNode) rateNode.textContent = `${(currentPlaybackRate).toFixed(2)}X`;
}
function loadEnabled() {
try {
const v = localStorage.getItem(STORAGE_KEYS.ENABLED);
return v == null ? true : v === '1';
} catch { return true; }
}
function saveEnabled(v) {
try { localStorage.setItem(STORAGE_KEYS.ENABLED, v ? '1' : '0'); } catch {}
}
function loadTargetDelay() {
try {
const v = parseInt(localStorage.getItem(STORAGE_KEYS.TARGET_DELAY) || '', 10);
if (isFinite(v) && v >= 200 && v <= 8000) return v;
} catch {}
return CONFIG.DEFAULT_TARGET_DELAY_MS;
}
function saveTargetDelay(ms) {
try { localStorage.setItem(STORAGE_KEYS.TARGET_DELAY, String(ms)); } catch {}
}
function getScreenKey() {
// 화면 해상도와 화면 배치를 기반으로 고유 키 생성
const screenKey = `${screen.width}x${screen.height}_${screen.availWidth}x${screen.availHeight}_${window.screen.colorDepth}`;
return screenKey;
}
function loadPanelPos() {
try {
const screenKey = getScreenKey();
const allPositions = JSON.parse(localStorage.getItem(STORAGE_KEYS.PANEL_POS) || '{}');
// 현재 화면에 대한 저장된 위치가 있는지 확인
if (allPositions[screenKey]) {
const pos = allPositions[screenKey];
if (typeof pos.x === 'number' && typeof pos.y === 'number') {
// 저장된 위치가 현재 화면 범위 내에 있는지 확인
if (pos.x >= 0 && pos.x < window.screen.availWidth &&
pos.y >= 0 && pos.y < window.screen.availHeight) {
return { x: pos.x, y: pos.y };
}
}
}
// 현재 화면에 저장된 위치가 없으면 기본 위치 반환
return null;
} catch {}
return null;
}
function savePanelPos(x, y) {
try {
const screenKey = getScreenKey();
const allPositions = JSON.parse(localStorage.getItem(STORAGE_KEYS.PANEL_POS) || '{}');
// 현재 화면의 위치 정보 저장
allPositions[screenKey] = {
x: x,
y: y,
timestamp: Date.now(),
screenWidth: screen.width,
screenHeight: screen.height
};
// 오래된 위치 정보 정리 (30일 이상)
const cutoff = Date.now() - (30 * 24 * 60 * 60 * 1000);
Object.keys(allPositions).forEach(key => {
if (allPositions[key].timestamp && allPositions[key].timestamp < cutoff) {
delete allPositions[key];
}
});
localStorage.setItem(STORAGE_KEYS.PANEL_POS, JSON.stringify(allPositions));
} catch {}
}
function createPanel() {
if (document.getElementById('soop-delay-panel')) return;
const panel = document.createElement('div');
panel.id = 'soop-delay-panel';
panel.style.cssText = [
'position: fixed',
'right: 10px',
'bottom: 10px',
'display: flex',
'align-items: center',
'gap: 2px',
'padding: 3px 4px',
'border-radius: 4px',
'background: rgba(0,0,0,0.75)',
'color: #fff',
'font: 10px/1.2 monospace',
'font-variant-numeric: tabular-nums',
'z-index: 10000',
'user-select: none',
'cursor: default',
'white-space: nowrap'
].join(';');
// 드래그 이동 (Ctrl + 드래그만 허용)
let isDragging = false; let dragOffsetX = 0; let dragOffsetY = 0;
panel.addEventListener('mousedown', (e) => {
try {
if ((e.target instanceof HTMLInputElement) || (e.target instanceof HTMLButtonElement) || (e.target.closest && e.target.closest('button'))) return;
if (!e.ctrlKey) return; // Ctrl 키가 눌려있지 않으면 드래그 불가
} catch {}
isDragging = true;
const rect = panel.getBoundingClientRect();
dragOffsetX = e.clientX - rect.left; dragOffsetY = e.clientY - rect.top;
e.preventDefault();
});
window.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const x = Math.max(0, Math.min(window.innerWidth - panel.offsetWidth, e.clientX - dragOffsetX));
const y = Math.max(0, Math.min(window.innerHeight - panel.offsetHeight, e.clientY - dragOffsetY));
panel.style.left = x + 'px';
panel.style.top = y + 'px';
panel.style.right = 'auto';
panel.style.bottom = 'auto';
});
window.addEventListener('mouseup', (e) => {
if (!isDragging) return;
isDragging = false;
const rect = panel.getBoundingClientRect();
savePanelPos(rect.left, rect.top);
ensurePanelInViewport(panel);
});
// 토글 스위치 (좌우 슬라이드)
let switchState = isEnabled;
const switchBtn = document.createElement('button');
switchBtn.type = 'button';
switchBtn.style.cssText = [
'position: relative',
'width: 32px',
'height: 18px',
'border-radius: 9px',
'border: 1px solid rgba(255,255,255,0.25)',
'padding: 0',
'background: transparent',
'cursor: pointer'
].join(';');
const knob = document.createElement('span');
knob.style.cssText = [
'position: absolute',
'top: 1px',
'left: 1px',
'width: 14px',
'height: 14px',
'border-radius: 50%',
'background: #fff',
'transition: left 120ms ease'
].join(';');
switchBtn.appendChild(knob);
function updateSwitch() {
switchBtn.style.background = switchState ? 'rgba(46, 204, 113, 0.85)' : 'rgba(255,255,255,0.15)';
knob.style.left = switchState ? '16px' : '1px';
}
updateSwitch();
switchBtn.addEventListener('click', (e) => {
switchState = !switchState;
isEnabled = switchState;
saveEnabled(isEnabled);
updateSwitch();
if (!isEnabled) setPlaybackRateSafely(1.0);
// 토글 시 조건 리셋
hasBeenAdjusted = false;
consecutiveOverCount = 0;
consecutiveUnderCount = 0;
e.preventDefault();
e.stopPropagation();
const panel = document.getElementById('soop-delay-panel');
if (panel) ensurePanelInViewport(panel);
});
// 목표값 입력
const targetInput = document.createElement('input');
targetInput.type = 'number';
targetInput.min = '200';
targetInput.max = '8000';
targetInput.step = '50';
targetInput.value = String(targetDelayMs);
targetInput.style.width = '55px';
targetInput.style.color = '#fff';
targetInput.style.background = 'rgba(255,255,255,0.08)';
targetInput.style.border = '1px solid rgba(255,255,255,0.25)';
targetInput.style.borderRadius = '3px';
targetInput.style.padding = '1px 3px';
targetInput.style.height = '18px';
targetInput.style.fontSize = '10px';
targetInput.style.boxSizing = 'border-box';
targetInput.style.outline = 'none';
targetInput.style.caretColor = '#fff';
targetInput.addEventListener('input', () => {
let v = parseInt(targetInput.value || '0', 10);
if (!isFinite(v)) return;
v = clamp(v, 200, 8000);
targetDelayMs = v;
saveTargetDelay(v);
// 목표값 변경 시 조건 리셋
hasBeenAdjusted = false;
consecutiveOverCount = 0;
consecutiveUnderCount = 0;
});
// IME 상태 보존
let preservedCompositionState = null;
targetInput.addEventListener('focus', (e) => {
// 포커스 시 기존 IME 상태 복원 시도
if (preservedCompositionState) {
try {
const selection = window.getSelection();
if (selection && preservedCompositionState.range) {
selection.removeAllRanges();
selection.addRange(preservedCompositionState.range);
}
} catch (err) {}
}
});
targetInput.addEventListener('blur', (e) => {
// 블러 시 현재 IME 상태 저장
try {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
preservedCompositionState = {
range: selection.getRangeAt(0).cloneRange(),
composition: document.querySelector('input:focus') === targetInput
};
}
} catch (err) {}
});
// 컴포지션 이벤트 처리
targetInput.addEventListener('compositionstart', (e) => {
preservedCompositionState = { composing: true };
});
targetInput.addEventListener('compositionend', (e) => {
if (preservedCompositionState) {
preservedCompositionState.composing = false;
}
});
const msText = document.createElement('span');
msText.textContent = 'ms';
// 표시값
const avgVal = document.createElement('span');
avgVal.id = 'soop-delay-avg';
avgVal.textContent = '-ms';
avgVal.style.display = 'inline-block';
avgVal.style.minWidth = '24px';
avgVal.style.textAlign = 'right';
const rateVal = document.createElement('span');
rateVal.id = 'soop-delay-rate';
rateVal.textContent = '1.00X';
rateVal.style.display = 'inline-block';
rateVal.style.minWidth = '22px';
rateVal.style.textAlign = 'right';
panel.appendChild(switchBtn);
panel.appendChild(document.createTextNode(' 목표:'));
panel.appendChild(targetInput);
panel.appendChild(msText);
panel.appendChild(document.createTextNode(' 딜레이:'));
panel.appendChild(avgVal);
panel.appendChild(document.createTextNode(' 배속:'));
panel.appendChild(rateVal);
document.body.appendChild(panel);
ensurePanelInViewport(panel);
const saved = loadPanelPos();
if (saved) {
panel.style.left = saved.x + 'px';
panel.style.top = saved.y + 'px';
panel.style.right = 'auto';
panel.style.bottom = 'auto';
ensurePanelInViewport(panel);
}
handleFullscreenChange();
}
function observeUrlChange() {
let last = location.href;
if (urlObserver) urlObserver.disconnect();
urlObserver = new MutationObserver(() => {
if (location.href !== last) {
last = location.href;
// SPA 내 전환 처리
cleanup();
createPanel();
start();
}
});
urlObserver.observe(document, { subtree: true, childList: true });
}
function preventBackgroundThrottling() {
try {
// Page Visibility API 우회 - 항상 활성 상태로 인식
Object.defineProperty(document, 'hidden', {
get: () => false,
configurable: true
});
Object.defineProperty(document, 'visibilityState', {
get: () => 'visible',
configurable: true
});
// visibilitychange 이벤트 차단
const originalAddEventListener = document.addEventListener;
document.addEventListener = function(type, listener, options) {
if (type === 'visibilitychange') {
console.log('[soop-delay] visibilitychange 이벤트 차단');
return;
}
return originalAddEventListener.call(this, type, listener, options);
};
// requestAnimationFrame 강제 활성화
const originalRAF = window.requestAnimationFrame;
window.requestAnimationFrame = function(callback) {
return originalRAF.call(window, function() {
try {
callback.apply(this, arguments);
} catch (e) {
console.warn('[soop-delay] RAF 콜백 오류:', e);
}
});
};
// 백그라운드 탭에서도 타이머가 계속 실행되도록 강제
const keepAlive = () => {
if (!document.hidden) return;
// 백그라운드에서 더미 작업 수행
const start = performance.now();
while (performance.now() - start < 1) {
// 짧은 CPU 작업으로 브라우저가 탭을 비활성화하지 않도록 함
}
};
setInterval(keepAlive, 1000);
console.log('[soop-delay] 백그라운드 스로틀링 방지 활성화');
} catch (e) {
console.warn('[soop-delay] 백그라운드 방지 설정 오류:', e);
}
}
function init() {
preventBackgroundThrottling();
createPanel();
start();
observeUrlChange();
['fullscreenchange', 'webkitfullscreenchange', 'mozfullscreenchange', 'MSFullscreenChange']
.forEach(ev => document.addEventListener(ev, handleFullscreenChange));
window.addEventListener('resize', () => {
const panel = document.getElementById('soop-delay-panel');
if (panel) ensurePanelInViewport(panel);
});
window.addEventListener('beforeunload', () => {
try { if (urlObserver) urlObserver.disconnect(); } catch {}
cleanup();
});
}
function ensurePanelInViewport(panel) {
try {
const rect = panel.getBoundingClientRect();
const margin = 8;
let newLeft = rect.left;
let newTop = rect.top;
if (rect.right > window.innerWidth - margin) newLeft -= (rect.right - (window.innerWidth - margin));
if (rect.left < margin) newLeft = margin;
if (rect.bottom > window.innerHeight - margin) newTop -= (rect.bottom - (window.innerHeight - margin));
if (rect.top < margin) newTop = margin;
if (newLeft !== rect.left || newTop !== rect.top) {
panel.style.left = newLeft + 'px';
panel.style.top = newTop + 'px';
panel.style.right = 'auto';
panel.style.bottom = 'auto';
const r2 = panel.getBoundingClientRect();
savePanelPos(r2.left, r2.top);
}
} catch {}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();