Simple H5Player Controller

Control HTML5 video players: custom hotkeys, save progress, sync settings globally with smart speed memory.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Simple H5Player Controller
// @namespace    https://github.com/xyseer
// @version      1.2
// @description  Control HTML5 video players: custom hotkeys, save progress, sync settings globally with smart speed memory.
// @author       xyseer
// @license      MIT
// @match        *://*/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// ==/UserScript==

(function() {
    'use strict';

    // --- CONFIGURATION & SAVED DATA ---
    let enableResumeProgress = GM_getValue('ENABLE_RESUME_PROGRESS', true);
    let enableGlobalSpeed = GM_getValue('ENABLE_GLOBAL_SPEED', true);
    let enableGlobalVolume = GM_getValue('ENABLE_GLOBAL_VOLUME', true);
    let enableAutoPlay = GM_getValue('ENABLE_AUTOPLAY', true);

    let currentFPS = GM_getValue('H5PLAYER_FPS', 30);
    let globalPlaybackSpeed = GM_getValue('H5PLAYER_GLOBAL_SPEED', 1.0);
    let globalVolume = GM_getValue('H5PLAYER_GLOBAL_VOLUME', 1.0);

    // Persistent memory for the last custom speed used (defaults to 2.0 if never set)
    let lastModifiedSpeed = GM_getValue('H5PLAYER_LAST_MODIFIED_SPEED', 2.0);
    // ----------------------------------

    // --- EXTENSION MENU OPTIONS ---
    GM_registerMenuCommand(`Toggle Auto-Play (Currently: ${enableAutoPlay ? "ON" : "OFF"})`, () => {
        enableAutoPlay = !enableAutoPlay;
        GM_setValue('ENABLE_AUTOPLAY', enableAutoPlay);
        alert(`"Auto-Play" is now ${enableAutoPlay ? "ON" : "OFF"}.`);
    });

    GM_registerMenuCommand(`Toggle Global Volume Sync (Currently: ${enableGlobalVolume ? "ON" : "OFF"})`, () => {
        enableGlobalVolume = !enableGlobalVolume;
        GM_setValue('ENABLE_GLOBAL_VOLUME', enableGlobalVolume);
        alert(`"Global Volume Sync" is now ${enableGlobalVolume ? "ON" : "OFF"}.`);
    });

    GM_registerMenuCommand(`Toggle Global Speed Sync (Currently: ${enableGlobalSpeed ? "ON" : "OFF"})`, () => {
        enableGlobalSpeed = !enableGlobalSpeed;
        GM_setValue('ENABLE_GLOBAL_SPEED', enableGlobalSpeed);
        alert(`"Global Speed Sync" is now ${enableGlobalSpeed ? "ON" : "OFF"}.`);
    });

    GM_registerMenuCommand(`Toggle Restore Progress (Currently: ${enableResumeProgress ? "ON" : "OFF"})`, () => {
        enableResumeProgress = !enableResumeProgress;
        GM_setValue('ENABLE_RESUME_PROGRESS', enableResumeProgress);
        alert(`"Restore to last progress" is now ${enableResumeProgress ? "ON" : "OFF"}.`);
    });

    GM_registerMenuCommand(`Set Frame Rate for D/F Keys (Currently: ${currentFPS} FPS)`, () => {
        const userInput = prompt("Enter the FPS of the video for frame-seeking (e.g., 24, 30, 60):", currentFPS);
        const parsedFPS = parseInt(userInput, 10);

        if (!isNaN(parsedFPS) && parsedFPS > 0) {
            currentFPS = parsedFPS;
            GM_setValue('H5PLAYER_FPS', currentFPS);
            alert(`FPS set to ${currentFPS}.`);
        }
    });
    // -----------------------------

    // --- PERFORMANCE / MEMORY STATE ---
    let toastTimeout = null;
    let saveSpeedTimeout = null;
    let saveVolumeTimeout = null;
    let cachedVideo = null;

    function isTyping(e) {
        const target = e.target;
        return ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) || target.isContentEditable;
    }

    function getVideo() {
        if (cachedVideo && document.contains(cachedVideo)) {
            return cachedVideo;
        }

        const videos = document.querySelectorAll('video');
        let bestVideo = null;
        let maxArea = 0;
        videos.forEach(v => {
            const rect = v.getBoundingClientRect();
            const area = rect.width * rect.height;
            if (area > maxArea) {
                maxArea = area;
                bestVideo = v;
            }
        });

        cachedVideo = bestVideo;
        return bestVideo;
    }

    function showToast(video, message) {
        // Find optimal container context so toast remains visible during native fullscreen modes
        let container = document.fullscreenElement || video.parentElement || document.body;
        let toast = document.getElementById('h5player-toast');
        
        if (!toast) {
            toast = document.createElement('div');
            toast.id = 'h5player-toast';
            toast.style.cssText = `
                position: absolute;
                top: 10px;
                left: 10px;
                background: rgba(0, 0, 0, 0.7);
                color: white;
                padding: 5px 10px;
                border-radius: 4px;
                font-family: sans-serif;
                font-size: 14px;
                z-index: 2147483647;
                pointer-events: none;
                transition: opacity 0.2s;
            `;
        }

        if (container !== document.body && window.getComputedStyle(container).position === 'static') {
            container.style.position = 'relative';
        }

        if (toast.parentElement !== container) {
            container.appendChild(toast);
        }

        if (container === document.body) {
            const rect = video.getBoundingClientRect();
            toast.style.position = 'absolute';
            toast.style.top = `${rect.top + window.scrollY + 10}px`;
            toast.style.left = `${rect.left + window.scrollX + 10}px`;
        } else {
            toast.style.position = 'absolute';
            toast.style.top = '10px';
            toast.style.left = '10px';
        }

        toast.textContent = message;
        toast.style.opacity = '1';

        if (toastTimeout) clearTimeout(toastTimeout);

        toastTimeout = setTimeout(() => {
            if (toast) toast.style.opacity = '0';
        }, 1500);
    }

    // Generates a dynamic unique tracking token mixing both full URL state and internal source stream
    function getUniqueVideoKey(video) {
        const url = window.location.href;
        const src = video.currentSrc || '';
        const combined = url + "_" + src;
        let hash = 0;
        for (let i = 0; i < combined.length; i++) {
            hash = (hash << 5) - hash + combined.charCodeAt(i);
            hash |= 0;
        }
        return `h5_progress_${Math.abs(hash)}`;
    }

    function safeRestoreProgress(video) {
        if (!enableResumeProgress) return;

        const progressKey = getUniqueVideoKey(video);
        
        // Block progress bleeding by ensuring this exact stream/URL instance hasn't been verified yet
        if (video.__h5_last_restored_key === progressKey) return;

        const savedTime = localStorage.getItem(progressKey);
        if (savedTime && parseFloat(savedTime) > 0) {
            const time = parseFloat(savedTime);
            if (Math.abs(video.currentTime - time) > 2) {
                video.currentTime = time;
                showToast(video, `Restored to ${Math.floor(time)}s`);
            }
        }
        video.__h5_last_restored_key = progressKey;
    }

    // Helper: Update global speed with Debounce & Persistent Memory
    function updateSpeed(video, newSpeed) {
        const formattedSpeed = parseFloat(newSpeed.toFixed(1));
        video.playbackRate = formattedSpeed;

        if (enableGlobalSpeed) {
            globalPlaybackSpeed = formattedSpeed;

            // If the user picked a speed other than 1.0, update our "last custom speed" memory
            if (formattedSpeed !== 1.0) {
                lastModifiedSpeed = formattedSpeed;
                GM_setValue('H5PLAYER_LAST_MODIFIED_SPEED', lastModifiedSpeed);
            }

            if (saveSpeedTimeout) clearTimeout(saveSpeedTimeout);
            saveSpeedTimeout = setTimeout(() => {
                GM_setValue('H5PLAYER_GLOBAL_SPEED', globalPlaybackSpeed);
            }, 500);
        }
        showToast(video, `Speed: ${formattedSpeed}x`);
    }

    // Helper: Update global volume with Debounce
    function updateVolume(video, newVolume) {
        video.volume = newVolume;
        if (enableGlobalVolume) {
            globalVolume = newVolume;

            if (saveVolumeTimeout) clearTimeout(saveVolumeTimeout);
            saveVolumeTimeout = setTimeout(() => {
                GM_setValue('H5PLAYER_GLOBAL_VOLUME', globalVolume);
            }, 500);
        }
        showToast(video, `Volume: ${Math.round(video.volume * 100)}%`);
    }

    // Handle Keyboard Shortcuts
    window.addEventListener('keydown', function(e) {
        if (isTyping(e)) return;

        const video = getVideo();
        if (!video) return;

        let handled = true;

        switch(e.key.toLowerCase()) {
            // Fullscreen Toggle
            case 'enter':
                if (!document.fullscreenElement) {
                    const requestFS = video.requestFullscreen || video.webkitRequestFullscreen || video.mozRequestFullScreen || video.msRequestFullscreen;
                    if (requestFS) {
                        requestFS.call(video).catch(() => {
                            if (video.parentElement) {
                                const reqParent = video.parentElement.requestFullscreen || video.parentElement.webkitRequestFullscreen;
                                if (reqParent) reqParent.call(video.parentElement).catch(() => {});
                            }
                        });
                    }
                } else {
                    const exitFS = document.exitFullscreen || document.webkitExitFullscreen || document.mozCancelFullScreen || document.msExitFullscreen;
                    if (exitFS) exitFS.call(document).catch(() => {});
                }
                break;

            // Play / Pause Toggle
            case ' ':
                if (video.paused) {
                    video.play().catch(() => {});
                    showToast(video, "Play");
                } else {
                    video.pause();
                    showToast(video, "Pause");
                }
                break;

            // Mute / Unmute Toggle
            case 'm':
                video.muted = !video.muted;
                showToast(video, video.muted ? "Muted" : `Volume: ${Math.round(video.volume * 100)}%`);
                break;

            // Speed Control
            case 'c':
                updateSpeed(video, Math.min(16, video.playbackRate + 0.1));
                break;
            case 'x':
                updateSpeed(video, Math.max(0.1, video.playbackRate - 0.1));
                break;
            case 'z':
                if (parseFloat(video.playbackRate.toFixed(1)) !== 1.0) {
                    // Currently fast -> Reset to 1.0x normal speed
                    updateSpeed(video, 1.0);
                } else {
                    // Currently normal -> Apply the last modified speed
                    updateSpeed(video, lastModifiedSpeed);
                }
                break;

            case '1': case '2': case '3': case '4': case '5': case '6':
                updateSpeed(video, parseInt(e.key));
                break;

            case 'd':
                video.currentTime -= (1 / currentFPS);
                showToast(video, `Prev Frame (${currentFPS}fps)`);
                break;
            case 'f':
                video.currentTime += (1 / currentFPS);
                showToast(video, `Next Frame (${currentFPS}fps)`);
                break;

            case 'arrowright':
                video.currentTime += e.shiftKey ? 90 : 5;
                showToast(video, `Forward ${e.shiftKey ? 90 : 5}s`);
                break;
            case 'arrowleft':
                video.currentTime -= e.shiftKey ? 90 : 5;
                showToast(video, `Backward ${e.shiftKey ? 90 : 5}s`);
                break;
            case 'arrowup':
                updateVolume(video, Math.min(1, video.volume + 0.1));
                break;
            case 'arrowdown':
                updateVolume(video, Math.max(0, video.volume - 0.1));
                break;

            default:
                handled = false;
        }

        if (handled) {
            e.preventDefault();
            e.stopPropagation();
        }
    }, true);

    // Apply saved settings on video load
    window.addEventListener('loadedmetadata', function(e) {
        if (e.target.tagName === 'VIDEO') {
            const video = e.target;

            // 1. Apply Global Speed
            if (enableGlobalSpeed) {
                video.playbackRate = globalPlaybackSpeed;
            }

            // 2. Apply Global Volume
            if (enableGlobalVolume) {
                video.volume = globalVolume;
            }

            // 3. Apply Saved Progress
            safeRestoreProgress(video);

            // 4. Auto-Play
            if (enableAutoPlay) {
                const playPromise = video.play();
                if (playPromise !== undefined) {
                    playPromise.catch(() => {
                        // Suppress console spam; browser policies block autoplay often.
                    });
                }
            }
        }
    }, true);

    // Explicit hook interception directly onto play events captures lazy-loaded content switches on SPAs
    window.addEventListener('play', function(e) {
        if (e.target.tagName === 'VIDEO') {
            safeRestoreProgress(e.target);
        }
    }, true);

    // Continuous progress saving - Throttled when page is hidden
    if (enableResumeProgress) {
        setInterval(() => {
            if (document.hidden) return;

            const video = getVideo();
            if (video && !video.paused && video.currentTime > 5) {
                const progressKey = getUniqueVideoKey(video);
                if (video.duration - video.currentTime > 5) {
                    localStorage.setItem(progressKey, video.currentTime);
                } else {
                    localStorage.removeItem(progressKey);
                }
            }
        }, 2000);
    }
})();