soop 방송 딜레이 자동 조정

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

当前为 2025-10-09 提交的版本,查看 最新版本

// ==UserScript==
// @name         soop 방송 딜레이 자동 조정
// @namespace    https://greasyfork.org/ko/scripts/539405
// @version      2.1
// @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 ENABLE_PER_CHANNEL_SETTINGS = true;  // 채널별 목표 설정 기능 (true: 켜짐, false: 꺼짐)
    
    const CONFIG = {
        CHECK_INTERVAL_MS: 100,          // 딜레이 체크 주기
        HISTORY_DURATION_MS: 1000,       // 최근 평균 딜레이 계산 구간
        DEFAULT_TARGET_DELAY_MS: 1500,   // 기본 목표 딜레이
        START_THRESHOLD_MS: 50,          // 목표 초과시 조정 시작 임계값 (첫 시작)
        RESTART_THRESHOLD_MS: 200,       // 목표 초과시 조정 재시작 임계값 (해제 후)
        STOP_THRESHOLD_MS: 200,          // 목표 이하시 조정 해제 임계값
        REVERSE_START_THRESHOLD_MS: 200, // 목표 미달시 역방향 조정 시작 임계값
        REVERSE_STOP_THRESHOLD_MS: 200,  // 목표 근처시 역방향 조정 해제 임계값
        CONSECUTIVE_REQUIRED: 3,         // 연속 조건 충족 횟수
        ADJUSTMENT_SPEED: 3,             // 배속 조정 속도(1~5)
        MAX_RATE: 1.5,                   // 최대 배속
        MIN_RATE: 0.8,                   // 최소 배속
        PRECISE_DEADZONE_MS: 50,         // 정배속 고정 범위 1
        WIDE_DEADZONE_MS: 200            // 정배속 고정 범위 2
    };

    const STORAGE_KEYS = {
        ENABLED: 'soop_delay_enabled',
        TARGET_DELAY: 'soop_delay_target_ms',
        PANEL_POS: 'soop_delay_panel_pos',
        CHANNEL_TARGETS: 'soop_delay_channel_targets'
    };

    let video = null;
    let intervalId = null;
    let delayHistory = [];
    let isEnabled = loadEnabled();
    let currentChannelId = getCurrentChannelId();
    let targetDelayMs = loadTargetDelay();
    let isAdjusting = false;
    let currentPlaybackRate = 1.0;
    let lastDisplayUpdate = 0;
    let urlObserver = null;
    let consecutiveOverCount = 0;
    let consecutiveUnderCount = 0;
    let consecutiveReverseCount = 0;
    let consecutiveReverseStopCount = 0;
    let hasBeenAdjusted = false;
    let isReverseAdjusting = false;
    let lastDeadzoneCheck = 0;
    let forceDeadzoneCount = 0;

    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) {
        }
        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, isCurrentlyAdjusting = false) {
        const errorMs = averageDelayMs - targetDelayMs;
        
        if (Math.abs(errorMs) <= CONFIG.PRECISE_DEADZONE_MS) {
            return 1.0;
        }
        
        if (!isCurrentlyAdjusting && Math.abs(errorMs) <= CONFIG.WIDE_DEADZONE_MS) {
            return 1.0;
        }
        
        const speedMultipliers = {
            1: 0.05,
            2: 0.125,
            3: 0.25,
            4: 0.4,
            5: 0.6
        };
        
        const kp = speedMultipliers[CONFIG.ADJUSTMENT_SPEED] || 0.125;
        const errorSec = errorMs / 1000;
        let rate;
        
        if (errorMs > 0) {
            rate = 1.0 + kp * errorSec;
        } else {
            rate = 1.0 + kp * 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;
    }

    let isSettingRate = false;
    
    function setPlaybackRateSafely(rate) {
        if (!video) return;
        try {
            isSettingRate = true;
            video.playbackRate = rate;
            currentPlaybackRate = rate;
            setTimeout(() => { isSettingRate = false; }, 50);
        } catch (e) {
            isSettingRate = false;
        }
    }

    function protectRateChange() {
        if (!video) return;
        const onRateChange = (e) => {
            if (!video || isSettingRate) return;

            const avgMs = getAverageDelayMs();
            const errorMs = avgMs - targetDelayMs;
            const isInPreciseDeadzone = Math.abs(errorMs) <= CONFIG.PRECISE_DEADZONE_MS;
            const isInWideDeadzone = Math.abs(errorMs) <= CONFIG.WIDE_DEADZONE_MS;
            const isInDeadZone = isInPreciseDeadzone || (!isAdjusting && !isReverseAdjusting && isInWideDeadzone);
            
            if (!isInDeadZone) {
                if (!isAdjusting && !isReverseAdjusting && Math.abs(video.playbackRate - 1.0) > 0.1) {
                    e.stopPropagation();
                    setPlaybackRateSafely(1.0);
                    return;
                }
                
                if ((isAdjusting || isReverseAdjusting) && Math.abs(video.playbackRate - currentPlaybackRate) > 0.1) {
                    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 || isReverseAdjusting) {
                isAdjusting = false;
                isReverseAdjusting = false;
                setPlaybackRateSafely(1.0);
            }
            consecutiveOverCount = 0;
            consecutiveUnderCount = 0;
            consecutiveReverseCount = 0;
            consecutiveReverseStopCount = 0;
            hasBeenAdjusted = false;
            return;
        }

        const errorMs = avgMs - targetDelayMs;
        
        const thresholdToUse = hasBeenAdjusted ? CONFIG.RESTART_THRESHOLD_MS : CONFIG.START_THRESHOLD_MS;
        const avgOverTarget = avgMs > (targetDelayMs + thresholdToUse);
        const avgFarUnderTarget = avgMs < (targetDelayMs - CONFIG.REVERSE_START_THRESHOLD_MS);
        
        const isInWideDeadzone = Math.abs(errorMs) <= CONFIG.WIDE_DEADZONE_MS;

        if (!isAdjusting && !isReverseAdjusting) {
            if (avgOverTarget) {
                consecutiveOverCount++;
                consecutiveUnderCount = 0;
                consecutiveReverseCount = 0;
                if (consecutiveOverCount >= CONFIG.CONSECUTIVE_REQUIRED) {
                    isAdjusting = true;
                    hasBeenAdjusted = true;
                }
            } else if (avgFarUnderTarget) {
                consecutiveReverseCount++;
                consecutiveOverCount = 0;
                consecutiveUnderCount = 0;
                if (consecutiveReverseCount >= CONFIG.CONSECUTIVE_REQUIRED) {
                    isReverseAdjusting = true;
                    hasBeenAdjusted = true;
                }
            } else {
                consecutiveOverCount = 0;
                consecutiveReverseCount = 0;
            }
        } else if (isAdjusting) {
            const isInPreciseDeadzone = Math.abs(errorMs) <= CONFIG.PRECISE_DEADZONE_MS;
            if (isInPreciseDeadzone) {
                consecutiveUnderCount++;
                consecutiveOverCount = 0;
                if (consecutiveUnderCount >= CONFIG.CONSECUTIVE_REQUIRED) {
                    isAdjusting = false;
                }
            } else {
                consecutiveUnderCount = 0;
            }

            if (isAdjusting) {
                const rate = computeAutoRate(avgMs, true);
                setPlaybackRateSafely(rate);
            }
        } else if (isReverseAdjusting) {
            const isInPreciseDeadzone = Math.abs(errorMs) <= CONFIG.PRECISE_DEADZONE_MS;
            if (isInPreciseDeadzone) {
                consecutiveReverseStopCount++;
                consecutiveReverseCount = 0;
                if (consecutiveReverseStopCount >= CONFIG.CONSECUTIVE_REQUIRED) {
                    isReverseAdjusting = false;
                }
            } else {
                consecutiveReverseStopCount = 0;
            }

            if (isReverseAdjusting) {
                const rate = computeAutoRate(avgMs, true);
                setPlaybackRateSafely(rate);
            }
        }

        if (!isAdjusting && !isReverseAdjusting) {
            const rate = computeAutoRate(avgMs, false);
            setPlaybackRateSafely(rate);
            
            const now = Date.now();
            
            if (isInWideDeadzone && Math.abs(currentPlaybackRate - 1.0) > 0.001) {
                if (now - lastDeadzoneCheck > 50) {
                    forceDeadzoneCount++;
                    setPlaybackRateSafely(1.0);
                    lastDeadzoneCheck = now;
                }
            } else {
                lastDeadzoneCheck = 0;
                if (forceDeadzoneCount > 0) {
                    forceDeadzoneCount = 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;
        isReverseAdjusting = false;
        currentPlaybackRate = 1.0;
        consecutiveOverCount = 0;
        consecutiveUnderCount = 0;
        consecutiveReverseCount = 0;
        consecutiveReverseStopCount = 0;
        hasBeenAdjusted = false;
        lastDeadzoneCheck = 0;
        forceDeadzoneCount = 0;
        if (video) {
            try { video.playbackRate = 1.0; } catch (e) {}
        }
        video = null;
    }

    function handleFullscreenChange() {
        const fs = !!(document.fullscreenElement || document.webkitFullscreenElement || document.mozFullScreenElement || document.msFullscreenElement);
        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) {
            const actualRate = video ? video.playbackRate : 1.0;
            rateNode.textContent = `${actualRate.toFixed(3)}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 getCurrentChannelId() {
        try {
            const match = location.pathname.match(/\/([^\/]+)\/[^\/]+$/);
            return match ? match[1] : null;
        } catch {
            return null;
        }
    }

    function loadChannelTargets() {
        try {
            const data = localStorage.getItem(STORAGE_KEYS.CHANNEL_TARGETS) || '{}';
            return JSON.parse(data);
        } catch { 
            return {}; 
        }
    }

    function saveChannelTargets(targets) {
        try {
            const data = JSON.stringify(targets);
            localStorage.setItem(STORAGE_KEYS.CHANNEL_TARGETS, data);
        } catch {}
    }

    function loadChannelTargetDelay(channelId) {
        const targets = loadChannelTargets();
        const delay = targets[channelId];
        return (delay && delay >= 200 && delay <= 8000) ? delay : CONFIG.DEFAULT_TARGET_DELAY_MS;
    }

    function saveChannelTargetDelay(channelId, ms) {
        const targets = loadChannelTargets();
        targets[channelId] = ms;
        saveChannelTargets(targets);
    }

    function loadTargetDelay() {
        if (ENABLE_PER_CHANNEL_SETTINGS && currentChannelId) {
            return loadChannelTargetDelay(currentChannelId);
        }
        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) {
        if (ENABLE_PER_CHANNEL_SETTINGS && currentChannelId) {
            saveChannelTargetDelay(currentChannelId, ms);
        } else {
            try { localStorage.setItem(STORAGE_KEYS.TARGET_DELAY, String(ms)); } catch {}
        }
    }



    function loadPanelPos() {
        try {
            const pos = JSON.parse(localStorage.getItem(STORAGE_KEYS.PANEL_POS) || 'null');
            if (pos && typeof pos.x === 'number' && typeof pos.y === 'number') {
                return { x: pos.x, y: pos.y };
            }
            return null;
        } catch {}
        return null;
    }

    function savePanelPos(x, y) {
        try {
            localStorage.setItem(STORAGE_KEYS.PANEL_POS, JSON.stringify({ x: x, y: y }));
        } 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(';');

        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;
            } 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;
        });

        let preservedCompositionState = null;
        targetInput.addEventListener('focus', (e) => {
            if (preservedCompositionState) {
                try {
                    const selection = window.getSelection();
                    if (selection && preservedCompositionState.range) {
                        selection.removeAllRanges();
                        selection.addRange(preservedCompositionState.range);
                    }
                } catch (err) {}
            }
        });

        targetInput.addEventListener('blur', (e) => {
            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 updateChannelSettings() {
        const newChannelId = getCurrentChannelId();
        if (newChannelId !== currentChannelId) {
            currentChannelId = newChannelId;
            if (ENABLE_PER_CHANNEL_SETTINGS) {
                targetDelayMs = loadTargetDelay();
                const targetInput = document.querySelector('#soop-delay-panel input[type="number"]');
                if (targetInput) {
                    targetInput.value = String(targetDelayMs);
                }
            }
        }
    }

    function observeUrlChange() {
        let last = location.href;
        if (urlObserver) urlObserver.disconnect();
        urlObserver = new MutationObserver(() => {
            if (location.href !== last) {
                last = location.href;
                cleanup();
                updateChannelSettings();
                createPanel();
                start();
            }
        });
        urlObserver.observe(document, { subtree: true, childList: true });
    }

    function handleVisibilityChange() {
        if (!document.hidden && document.visibilityState === 'visible') {
            if (isAdjusting || isReverseAdjusting) {
                isAdjusting = false;
                isReverseAdjusting = false;
                setPlaybackRateSafely(1.0);
            }
            consecutiveOverCount = 0;
            consecutiveUnderCount = 0;
            consecutiveReverseCount = 0;
            consecutiveReverseStopCount = 0;
            delayHistory = [];
        }
    }

    function preventBackgroundThrottling() {
        try {
            Object.defineProperty(document, 'hidden', {
                get: () => false,
                configurable: true
            });
            Object.defineProperty(document, 'visibilityState', {
                get: () => 'visible',
                configurable: true
            });

            document.addEventListener('visibilitychange', handleVisibilityChange);

            const originalRAF = window.requestAnimationFrame;
            window.requestAnimationFrame = function(callback) {
                return originalRAF.call(window, function() {
                    try {
                        callback.apply(this, arguments);
                    } catch (e) {
                    }
                });
            };

            const keepAlive = () => {
                if (!document.hidden) return;
                const start = performance.now();
                while (performance.now() - start < 1) {
                }
            };
            setInterval(keepAlive, 1000);

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