您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds speed control to all videos on a website. Supports site-specific customization.
// ==UserScript== // @name Video Speed Controller (Control speed on videos in any website) // @namespace https://github.com/lcs-dev1/userscripts // @version 1.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-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) !important; 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) !important; appearance: auto !important; 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; } })();