Video Speed Controller (Control speed on videos in any website)

Adds speed control to all videos on a website. Supports site-specific customization.

当前为 2025-04-27 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Video Speed Controller (Control speed on videos in any website)
// @namespace    https://github.com/lcs-dev1/userscripts
// @version      1.1
// @description  Adds speed control to all videos on a website. Supports site-specific customization.
// @author       lcs-dev1
// @match        *://*/*
// @license      Apache License 2.0
// @grant        GM_log
// ==/UserScript==

/**
 * @typedef {Object} VideoControllerData
 * @property {HTMLElement} controller - The controller element
 * @property {number|null} hideTimer - Timer ID for hiding the controller
 * @property {boolean} isSticky - Whether the controller should stay visible
 * @property {HTMLSelectElement} speedSelector - Speed selector dropdown
 * @property {HTMLInputElement} customSpeedInput - Custom speed input field
 */

/**
 * @typedef {Object} SiteRule
 * @property {function(HTMLVideoElement): boolean} shouldAddController - Function to determine if a controller should be added to a video
 */

/**
 * @typedef {Object.<string, SiteRule>} SiteRules
 */

(function() {
    'use strict';

    const uniquePrefix = 'tm_vid_speed_ver__1-1';
    
    /**
     * Log debug messages
     * @param {any[]} messages - Message to log
     * @returns {void}
     */
    function debugLog(messages) {
        GM_log('[Video Speed Controller]', ...messages);
    }

    // ====================================
    // SITE-SPECIFIC RULES CONFIGURATION
    // ====================================
    
    /**
     * Add new site rules here for easy configuration
     * @type {SiteRules}
     */
    const siteRules = {
        'primevideo.com': {
            /**
             * Only add controller to videos with blob: source
             * @param {HTMLVideoElement} video - The video element to check
             * @returns {boolean} - Whether a controller should be added
             */
            shouldAddController: function(video) {
                const src = video.src || '';
                return src.startsWith('blob:');
            }
        },
    };
    
    /**
     * Helper function to determine if we're on a specific site
     * @returns {string|null} - Site name if matched, null otherwise
     */
    function getCurrentSite() {
        // Get hostname and extract domain without subdomain
        const fullHostname = window.location.hostname;
        
        // Extract the base domain (removing subdomains like www)
        // This regex takes a hostname like "www.example.com" and extracts "example.com"
        const domainMatch = fullHostname.match(/([^.]+\.[^.]+)$/);
        const baseDomain = domainMatch ? domainMatch[1] : fullHostname;
        
        // Directly check if we have rules for this domain
        return siteRules[baseDomain] ? baseDomain : null;
    }
    
    const currentSite = getCurrentSite();
    
    /**
     * Function to check if a video should have a controller
     * @param {HTMLVideoElement} video - The video element to check
     * @returns {boolean} - Whether a controller should be added
     */
    function shouldAddController(video) {
        // If we're on a site with special rules, apply them
        if (currentSite && siteRules[currentSite].shouldAddController) {
            return siteRules[currentSite].shouldAddController(video);
        }
        
        // Default behavior for all other sites: add controller to all videos
        return true;
    }
    // ====================================
    // END SITE-SPECIFIC RULES
    // ====================================

    /** Speed options available in the dropdown */
    const speeds = [0.1, 0.5, 1, 1.5, 2, 2.5, 3, 4];

    // CSS styles with unique class names
    const styles = `
        .${uniquePrefix}controller {
            position: absolute;
            top: 10px;
            left: 10px;
            background-color: rgba(0, 0, 0, 0.7);
            color: white;
            padding: 5px;
            border-radius: 4px;
            z-index: 999999;
            font-family: Arial, sans-serif;
            font-size: 14px;
            opacity: 0;
            pointer-events: none;
            transition: opacity 0.3s ease;
            display: flex;
            align-items: center;
            flex-wrap: wrap;
        }
        .${uniquePrefix}controller.${uniquePrefix}visible {
            opacity: 1;
            pointer-events: auto;
        }
        .${uniquePrefix}controller .${uniquePrefix}label {
            margin-right: 5px;
        }
        .${uniquePrefix}controller select {
            background-color: rgba(0, 0, 0, 0.7);
            color: white;
            width: fit-content;
            border: 1px solid white;
            border-radius: 3px;
            padding: 2px;
            margin-right: 8px;
            font-size: 14px;
        }
        .${uniquePrefix}controller input {
            background-color: rgba(0, 0, 0, 0.7);
            color: white;
            border: 1px solid white;
            border-radius: 3px;
            padding: 2px;
            width: 50px;
            margin-right: 5px;
            font-size: 14px;
        }
        .${uniquePrefix}controller button {
            background-color: rgba(0, 0, 0, 0.7);
            color: white;
            border: 1px solid white;
            border-radius: 3px;
            padding: 2px 5px;
            margin-right: 5px;
            font-size: 14px;
            cursor: pointer;
        }
        .${uniquePrefix}controller button:hover {
            background-color: rgba(255, 255, 255, 0.2);
        }
        video.${uniquePrefix}enhanced {
            z-index: auto !important;
        }
    `;

    // Add styles to document
    const styleElement = document.createElement('style');
    styleElement.textContent = styles;
    document.head.appendChild(styleElement);

    /**
     * Global tracking of active videos and controllers
     * @type {Map<HTMLVideoElement, VideoControllerData>}
     */
    const activeVideos = new Map();

    // Track mouse position globally
    let mouseX = 0;
    let mouseY = 0;

    document.addEventListener('mousemove', function(e) {
        mouseX = e.clientX;
        mouseY = e.clientY;
        updateControllerVisibility();
    });

    /**
     * Function to update controller visibility based on mouse position
     * @returns {void}
     */
    function updateControllerVisibility() {
        activeVideos.forEach((data, video) => {
            const rect = video.getBoundingClientRect();
            const isMouseOver = (
                mouseX >= rect.left &&
                mouseX <= rect.right &&
                mouseY >= rect.top &&
                mouseY <= rect.bottom
            );
            const isVisible = data.controller.classList.contains(uniquePrefix + 'visible');
            
            // Handle showing controller when mouse is over video
            if (isMouseOver) {
                // Show controller if not already visible
                if (!isVisible) {
                    data.controller.classList.add(uniquePrefix + 'visible');
                }
                
                // Reset hide timer
                clearTimeout(data.hideTimer);
                data.hideTimer = setTimeout(() => {
                    if (!data.isSticky) {
                        data.controller.classList.remove(uniquePrefix + 'visible');
                    }
                }, 2000);
                return;
            }
            
            // When mouse is not over video and controller isn't sticky, hide it
            if (isVisible && !data.isSticky) {
                data.controller.classList.remove(uniquePrefix + 'visible');
            }
        });
    }

    /**
     * Function to apply playback rate to a video
     * @param {HTMLVideoElement} video - The video element to modify
     * @param {number} rate - The playback rate to apply
     * @returns {void}
     */
    function applyPlaybackRate(video, rate) {
        // Validate inputs
        if (!video || isNaN(rate) || rate <= 0) {
            return;
        }
        
        // Apply rate to video
        video.playbackRate = rate;
        video.dataset.preferredRate = rate.toString();

        // Get controller data
        const data = activeVideos.get(video);
        if (!data) {
            return;
        }
        
        // Update dropdown if available
        if (data.speedSelector) {
            updateSpeedSelector(data.speedSelector, rate);
        }
        
        // Update custom input if available
        if (data.customSpeedInput) {
            data.customSpeedInput.value = rate.toString();
        }
    }
    
    /**
     * Helper function to update speed selector dropdown
     * @param {HTMLSelectElement} selector - The speed selector element
     * @param {number} rate - The playback rate
     */
    function updateSpeedSelector(selector, rate) {
        const options = selector.options;
        
        // Check if rate matches a preset option
        for (let i = 0; i < options.length; i++) {
            if (parseFloat(options[i].value) === rate) {
                selector.selectedIndex = i;
                return;
            }
        }
        
        // If no match and "custom" option exists, select it
        const customOption = selector.querySelector('option[value="custom"]');
        if (customOption) {
            selector.value = "custom";
        }
    }

    /**
     * Check for videos and add speed controller
     * @returns {void}
     */
    function initVideoSpeedControl() {
        const videos = document.querySelectorAll('video:not(.' + uniquePrefix + 'enhanced)');

        videos.forEach((video, index) => {
            // Skip videos that shouldn't have controllers
            if (!shouldAddController(video)) {
                // Mark as enhanced to avoid rechecking
                video.classList.add(uniquePrefix + 'enhanced');
                return;
            }

            // Mark video as enhanced
            video.classList.add(uniquePrefix + 'enhanced');

            // Create controller element
            const controller = document.createElement('div');
            controller.className = uniquePrefix + 'controller';
            controller.setAttribute('id', uniquePrefix + 'controller-' + index);

            // Create preset selector label
            const presetLabel = document.createElement('span');
            presetLabel.className = uniquePrefix + 'label';
            presetLabel.textContent = 'Preset:';

            // Create speed selector
            const speedSelector = document.createElement('select');

            speeds.forEach(speed => {
                const option = document.createElement('option');
                option.value = speed.toString();
                option.textContent = speed + 'x';
                if (speed === 1) {
                    option.selected = true;
                }
                speedSelector.appendChild(option);
            });

            // Add custom option
            const customOption = document.createElement('option');
            customOption.value = "custom";
            customOption.textContent = "Custom";
            speedSelector.appendChild(customOption);

            // Create custom speed label
            const customLabel = document.createElement('span');
            customLabel.className = uniquePrefix + 'label';
            customLabel.textContent = 'Custom:';

            // Create custom speed input
            const customSpeedInput = document.createElement('input');
            customSpeedInput.type = "number";
            customSpeedInput.min = "0.1";
            customSpeedInput.max = "16";
            customSpeedInput.step = "0.1";
            customSpeedInput.value = "1.0";
            customSpeedInput.placeholder = "Speed";

            // Create apply button
            const applyButton = document.createElement('button');
            applyButton.textContent = "Apply";

            // Listen for speed selector changes
            speedSelector.addEventListener('change', function() {
                if (this.value === "custom") return;
                
                const rate = parseFloat(this.value);
                applyPlaybackRate(video, rate);
            });

            // Listen for custom speed input changes
            customSpeedInput.addEventListener('keyup', function(e) {
                if (e.key !== 'Enter') return;
                
                const rate = parseFloat(this.value);
                if (rate > 0) applyPlaybackRate(video, rate);
            });

            // Listen for apply button click
            applyButton.addEventListener('click', function() {
                const rate = parseFloat(customSpeedInput.value);
                if (rate > 0) applyPlaybackRate(video, rate);
            });

            // Ensure playback rate is maintained when video plays
            video.addEventListener('play', function() {
                const savedRateStr = this.dataset.preferredRate;
                if (!savedRateStr) return;
                
                const savedRate = parseFloat(savedRateStr);
                if (this.playbackRate === savedRate) return;
                
                this.playbackRate = savedRate;
            });

            // Also check playback rate periodically to ensure it sticks
            setInterval(() => {
                const savedRateStr = video.dataset.preferredRate;
                if (!savedRateStr || video.paused) return;
                
                const savedRate = parseFloat(savedRateStr);
                if (video.playbackRate === savedRate) return;
                
                video.playbackRate = savedRate;
            }, 1000);

            // Prevent controller mouse events from bubbling
            controller.addEventListener('mouseenter', function(e) {
                e.stopPropagation();
                activeVideos.get(video).isSticky = true;
            });

            controller.addEventListener('mouseleave', function(e) {
                e.stopPropagation();
                activeVideos.get(video).isSticky = false;
                updateControllerVisibility();
            });

            // Add elements to controller
            controller.appendChild(presetLabel);
            controller.appendChild(speedSelector);
            controller.appendChild(customLabel);
            controller.appendChild(customSpeedInput);
            controller.appendChild(applyButton);

            // Add controller directly to document body
            document.body.appendChild(controller);

            // Store data for this video
            activeVideos.set(video, {
                controller: controller,
                hideTimer: null,
                isSticky: false,
                speedSelector: speedSelector,
                customSpeedInput: customSpeedInput
            });

            /**
             * Position the controller based on video position
             * @returns {void}
             */
            function positionController() {
                const rect = video.getBoundingClientRect();
                controller.style.position = 'fixed';
                controller.style.top = (rect.top + 10) + 'px';
                controller.style.left = (rect.left + 10) + 'px';
            }

            // Position controller initially
            positionController();

            // Update position on window resize and scroll
            window.addEventListener('resize', positionController);
            window.addEventListener('scroll', positionController);

            // Initialize playback rate from video if it already has one set
            if (video.playbackRate !== 1) {
                applyPlaybackRate(video, video.playbackRate);
            }

            // Flash controller briefly
            controller.classList.add(uniquePrefix + 'visible');
            setTimeout(() => {
                if (!activeVideos.get(video).isSticky) {
                    controller.classList.remove(uniquePrefix + 'visible');
                }
            }, 1000);
        });
    }

    // Run after page and resources are loaded
    window.addEventListener('load', function() {
        initVideoSpeedControl();

        // Set up a single observer to detect both new videos and attribute changes
        const observer = new MutationObserver(function(mutations) {
            let videoAdded = false;

            mutations.forEach(mutation => {
                // Case 1: Check for new videos in added nodes
                videoAdded = videoAdded || checkForNewVideos(mutation);
                
                // Case 2: Check for src attribute changes on videos that need controller updates
                videoAdded = videoAdded || checkForSrcChanges(mutation);
            });

            if (videoAdded) {
                initVideoSpeedControl();
            }
        });

        // Observe both child additions and attribute changes in one observer
        observer.observe(document.body, {
            childList: true,
            subtree: true,
            attributes: true,
            attributeFilter: ['src'],
            attributeOldValue: true
        });
    });
    
    /**
     * Check if a mutation contains new videos
     * @param {MutationRecord} mutation - The mutation record to check
     * @returns {boolean} - Whether new videos were found
     */
    function checkForNewVideos(mutation) {
        if (!mutation.addedNodes.length) return false;
        
        for (let i = 0; i < mutation.addedNodes.length; i++) {
            const node = mutation.addedNodes[i];
            
            // Direct video element
            if (node.nodeName === 'VIDEO') return true;
            
            // Element that might contain videos
            if (node.nodeType === 1 && 
                node.querySelector('video:not(.' + uniquePrefix + 'enhanced)')) {
                return true;
            }
        }
        
        return false;
    }
    
    /**
     * Check if a mutation represents a src change that needs controller updates
     * @param {MutationRecord} mutation - The mutation record to check
     * @returns {boolean} - Whether video controllers need updating
     */
    function checkForSrcChanges(mutation) {
        // Skip if not on a site with special rules or not a src change on a video
        if (!currentSite || 
            mutation.type !== 'attributes' || 
            mutation.attributeName !== 'src' || 
            mutation.target.nodeName !== 'VIDEO') {
            return false;
        }
        
        const video = /** @type {HTMLVideoElement} */ (mutation.target);
        
        // Skip if not a video we've already processed
        if (!video.classList.contains(uniquePrefix + 'enhanced')) {
            return false;
        }
        
        const shouldHave = shouldAddController(video);
        const hasController = activeVideos.has(video);
        
        // Case: Video should have controller but doesn't
        if (shouldHave && !hasController) {
            video.classList.remove(uniquePrefix + 'enhanced');
            return true; // Will trigger initVideoSpeedControl()
        }
        
        // Case: Video shouldn't have controller but does
        if (!shouldHave && hasController) {
            const data = activeVideos.get(video);
            if (data?.controller) {
                data.controller.remove();
            }
            activeVideos.delete(video);
        }
        
        return false;
    }
})();