您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
soop 방송 딜레이를 목표 시간 이내로 자동 보정
// ==UserScript== // @name soop 방송 딜레이 자동 조정 // @namespace https://greasyfork.org/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(); } })();