soop 방송 딜레이 자동 조정

soop 방송 딜레이를 목표 시간 이내로 자동 보정

// ==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();
    }
})();