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 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Video Speed Controller (Control speed on videos in any website)
  3. // @namespace https://github.com/lcs-dev1/userscripts
  4. // @version 1.1
  5. // @description Adds speed control to all videos on a website. Supports site-specific customization.
  6. // @author lcs-dev1
  7. // @match *://*/*
  8. // @license Apache License 2.0
  9. // @grant GM_log
  10. // ==/UserScript==
  11.  
  12. /**
  13. * @typedef {Object} VideoControllerData
  14. * @property {HTMLElement} controller - The controller element
  15. * @property {number|null} hideTimer - Timer ID for hiding the controller
  16. * @property {boolean} isSticky - Whether the controller should stay visible
  17. * @property {HTMLSelectElement} speedSelector - Speed selector dropdown
  18. * @property {HTMLInputElement} customSpeedInput - Custom speed input field
  19. */
  20.  
  21. /**
  22. * @typedef {Object} SiteRule
  23. * @property {function(HTMLVideoElement): boolean} shouldAddController - Function to determine if a controller should be added to a video
  24. */
  25.  
  26. /**
  27. * @typedef {Object.<string, SiteRule>} SiteRules
  28. */
  29.  
  30. (function() {
  31. 'use strict';
  32.  
  33. const uniquePrefix = 'tm_vid_speed_ver__1-1';
  34. /**
  35. * Log debug messages
  36. * @param {any[]} messages - Message to log
  37. * @returns {void}
  38. */
  39. function debugLog(messages) {
  40. GM_log('[Video Speed Controller]', ...messages);
  41. }
  42.  
  43. // ====================================
  44. // SITE-SPECIFIC RULES CONFIGURATION
  45. // ====================================
  46. /**
  47. * Add new site rules here for easy configuration
  48. * @type {SiteRules}
  49. */
  50. const siteRules = {
  51. 'primevideo.com': {
  52. /**
  53. * Only add controller to videos with blob: source
  54. * @param {HTMLVideoElement} video - The video element to check
  55. * @returns {boolean} - Whether a controller should be added
  56. */
  57. shouldAddController: function(video) {
  58. const src = video.src || '';
  59. return src.startsWith('blob:');
  60. }
  61. },
  62. };
  63. /**
  64. * Helper function to determine if we're on a specific site
  65. * @returns {string|null} - Site name if matched, null otherwise
  66. */
  67. function getCurrentSite() {
  68. // Get hostname and extract domain without subdomain
  69. const fullHostname = window.location.hostname;
  70. // Extract the base domain (removing subdomains like www)
  71. // This regex takes a hostname like "www.example.com" and extracts "example.com"
  72. const domainMatch = fullHostname.match(/([^.]+\.[^.]+)$/);
  73. const baseDomain = domainMatch ? domainMatch[1] : fullHostname;
  74. // Directly check if we have rules for this domain
  75. return siteRules[baseDomain] ? baseDomain : null;
  76. }
  77. const currentSite = getCurrentSite();
  78. /**
  79. * Function to check if a video should have a controller
  80. * @param {HTMLVideoElement} video - The video element to check
  81. * @returns {boolean} - Whether a controller should be added
  82. */
  83. function shouldAddController(video) {
  84. // If we're on a site with special rules, apply them
  85. if (currentSite && siteRules[currentSite].shouldAddController) {
  86. return siteRules[currentSite].shouldAddController(video);
  87. }
  88. // Default behavior for all other sites: add controller to all videos
  89. return true;
  90. }
  91. // ====================================
  92. // END SITE-SPECIFIC RULES
  93. // ====================================
  94.  
  95. /** Speed options available in the dropdown */
  96. const speeds = [0.1, 0.5, 1, 1.5, 2, 2.5, 3, 4];
  97.  
  98. // CSS styles with unique class names
  99. const styles = `
  100. .${uniquePrefix}controller {
  101. position: absolute;
  102. top: 10px;
  103. left: 10px;
  104. background-color: rgba(0, 0, 0, 0.7);
  105. color: white;
  106. padding: 5px;
  107. border-radius: 4px;
  108. z-index: 999999;
  109. font-family: Arial, sans-serif;
  110. font-size: 14px;
  111. opacity: 0;
  112. pointer-events: none;
  113. transition: opacity 0.3s ease;
  114. display: flex;
  115. align-items: center;
  116. flex-wrap: wrap;
  117. }
  118. .${uniquePrefix}controller.${uniquePrefix}visible {
  119. opacity: 1;
  120. pointer-events: auto;
  121. }
  122. .${uniquePrefix}controller .${uniquePrefix}label {
  123. margin-right: 5px;
  124. }
  125. .${uniquePrefix}controller select {
  126. background-color: rgba(0, 0, 0, 0.7);
  127. color: white;
  128. width: fit-content;
  129. border: 1px solid white;
  130. border-radius: 3px;
  131. padding: 2px;
  132. margin-right: 8px;
  133. font-size: 14px;
  134. }
  135. .${uniquePrefix}controller input {
  136. background-color: rgba(0, 0, 0, 0.7);
  137. color: white;
  138. border: 1px solid white;
  139. border-radius: 3px;
  140. padding: 2px;
  141. width: 50px;
  142. margin-right: 5px;
  143. font-size: 14px;
  144. }
  145. .${uniquePrefix}controller button {
  146. background-color: rgba(0, 0, 0, 0.7);
  147. color: white;
  148. border: 1px solid white;
  149. border-radius: 3px;
  150. padding: 2px 5px;
  151. margin-right: 5px;
  152. font-size: 14px;
  153. cursor: pointer;
  154. }
  155. .${uniquePrefix}controller button:hover {
  156. background-color: rgba(255, 255, 255, 0.2);
  157. }
  158. video.${uniquePrefix}enhanced {
  159. z-index: auto !important;
  160. }
  161. `;
  162.  
  163. // Add styles to document
  164. const styleElement = document.createElement('style');
  165. styleElement.textContent = styles;
  166. document.head.appendChild(styleElement);
  167.  
  168. /**
  169. * Global tracking of active videos and controllers
  170. * @type {Map<HTMLVideoElement, VideoControllerData>}
  171. */
  172. const activeVideos = new Map();
  173.  
  174. // Track mouse position globally
  175. let mouseX = 0;
  176. let mouseY = 0;
  177.  
  178. document.addEventListener('mousemove', function(e) {
  179. mouseX = e.clientX;
  180. mouseY = e.clientY;
  181. updateControllerVisibility();
  182. });
  183.  
  184. /**
  185. * Function to update controller visibility based on mouse position
  186. * @returns {void}
  187. */
  188. function updateControllerVisibility() {
  189. activeVideos.forEach((data, video) => {
  190. const rect = video.getBoundingClientRect();
  191. const isMouseOver = (
  192. mouseX >= rect.left &&
  193. mouseX <= rect.right &&
  194. mouseY >= rect.top &&
  195. mouseY <= rect.bottom
  196. );
  197. const isVisible = data.controller.classList.contains(uniquePrefix + 'visible');
  198. // Handle showing controller when mouse is over video
  199. if (isMouseOver) {
  200. // Show controller if not already visible
  201. if (!isVisible) {
  202. data.controller.classList.add(uniquePrefix + 'visible');
  203. }
  204. // Reset hide timer
  205. clearTimeout(data.hideTimer);
  206. data.hideTimer = setTimeout(() => {
  207. if (!data.isSticky) {
  208. data.controller.classList.remove(uniquePrefix + 'visible');
  209. }
  210. }, 2000);
  211. return;
  212. }
  213. // When mouse is not over video and controller isn't sticky, hide it
  214. if (isVisible && !data.isSticky) {
  215. data.controller.classList.remove(uniquePrefix + 'visible');
  216. }
  217. });
  218. }
  219.  
  220. /**
  221. * Function to apply playback rate to a video
  222. * @param {HTMLVideoElement} video - The video element to modify
  223. * @param {number} rate - The playback rate to apply
  224. * @returns {void}
  225. */
  226. function applyPlaybackRate(video, rate) {
  227. // Validate inputs
  228. if (!video || isNaN(rate) || rate <= 0) {
  229. return;
  230. }
  231. // Apply rate to video
  232. video.playbackRate = rate;
  233. video.dataset.preferredRate = rate.toString();
  234.  
  235. // Get controller data
  236. const data = activeVideos.get(video);
  237. if (!data) {
  238. return;
  239. }
  240. // Update dropdown if available
  241. if (data.speedSelector) {
  242. updateSpeedSelector(data.speedSelector, rate);
  243. }
  244. // Update custom input if available
  245. if (data.customSpeedInput) {
  246. data.customSpeedInput.value = rate.toString();
  247. }
  248. }
  249. /**
  250. * Helper function to update speed selector dropdown
  251. * @param {HTMLSelectElement} selector - The speed selector element
  252. * @param {number} rate - The playback rate
  253. */
  254. function updateSpeedSelector(selector, rate) {
  255. const options = selector.options;
  256. // Check if rate matches a preset option
  257. for (let i = 0; i < options.length; i++) {
  258. if (parseFloat(options[i].value) === rate) {
  259. selector.selectedIndex = i;
  260. return;
  261. }
  262. }
  263. // If no match and "custom" option exists, select it
  264. const customOption = selector.querySelector('option[value="custom"]');
  265. if (customOption) {
  266. selector.value = "custom";
  267. }
  268. }
  269.  
  270. /**
  271. * Check for videos and add speed controller
  272. * @returns {void}
  273. */
  274. function initVideoSpeedControl() {
  275. const videos = document.querySelectorAll('video:not(.' + uniquePrefix + 'enhanced)');
  276.  
  277. videos.forEach((video, index) => {
  278. // Skip videos that shouldn't have controllers
  279. if (!shouldAddController(video)) {
  280. // Mark as enhanced to avoid rechecking
  281. video.classList.add(uniquePrefix + 'enhanced');
  282. return;
  283. }
  284.  
  285. // Mark video as enhanced
  286. video.classList.add(uniquePrefix + 'enhanced');
  287.  
  288. // Create controller element
  289. const controller = document.createElement('div');
  290. controller.className = uniquePrefix + 'controller';
  291. controller.setAttribute('id', uniquePrefix + 'controller-' + index);
  292.  
  293. // Create preset selector label
  294. const presetLabel = document.createElement('span');
  295. presetLabel.className = uniquePrefix + 'label';
  296. presetLabel.textContent = 'Preset:';
  297.  
  298. // Create speed selector
  299. const speedSelector = document.createElement('select');
  300.  
  301. speeds.forEach(speed => {
  302. const option = document.createElement('option');
  303. option.value = speed.toString();
  304. option.textContent = speed + 'x';
  305. if (speed === 1) {
  306. option.selected = true;
  307. }
  308. speedSelector.appendChild(option);
  309. });
  310.  
  311. // Add custom option
  312. const customOption = document.createElement('option');
  313. customOption.value = "custom";
  314. customOption.textContent = "Custom";
  315. speedSelector.appendChild(customOption);
  316.  
  317. // Create custom speed label
  318. const customLabel = document.createElement('span');
  319. customLabel.className = uniquePrefix + 'label';
  320. customLabel.textContent = 'Custom:';
  321.  
  322. // Create custom speed input
  323. const customSpeedInput = document.createElement('input');
  324. customSpeedInput.type = "number";
  325. customSpeedInput.min = "0.1";
  326. customSpeedInput.max = "16";
  327. customSpeedInput.step = "0.1";
  328. customSpeedInput.value = "1.0";
  329. customSpeedInput.placeholder = "Speed";
  330.  
  331. // Create apply button
  332. const applyButton = document.createElement('button');
  333. applyButton.textContent = "Apply";
  334.  
  335. // Listen for speed selector changes
  336. speedSelector.addEventListener('change', function() {
  337. if (this.value === "custom") return;
  338. const rate = parseFloat(this.value);
  339. applyPlaybackRate(video, rate);
  340. });
  341.  
  342. // Listen for custom speed input changes
  343. customSpeedInput.addEventListener('keyup', function(e) {
  344. if (e.key !== 'Enter') return;
  345. const rate = parseFloat(this.value);
  346. if (rate > 0) applyPlaybackRate(video, rate);
  347. });
  348.  
  349. // Listen for apply button click
  350. applyButton.addEventListener('click', function() {
  351. const rate = parseFloat(customSpeedInput.value);
  352. if (rate > 0) applyPlaybackRate(video, rate);
  353. });
  354.  
  355. // Ensure playback rate is maintained when video plays
  356. video.addEventListener('play', function() {
  357. const savedRateStr = this.dataset.preferredRate;
  358. if (!savedRateStr) return;
  359. const savedRate = parseFloat(savedRateStr);
  360. if (this.playbackRate === savedRate) return;
  361. this.playbackRate = savedRate;
  362. });
  363.  
  364. // Also check playback rate periodically to ensure it sticks
  365. setInterval(() => {
  366. const savedRateStr = video.dataset.preferredRate;
  367. if (!savedRateStr || video.paused) return;
  368. const savedRate = parseFloat(savedRateStr);
  369. if (video.playbackRate === savedRate) return;
  370. video.playbackRate = savedRate;
  371. }, 1000);
  372.  
  373. // Prevent controller mouse events from bubbling
  374. controller.addEventListener('mouseenter', function(e) {
  375. e.stopPropagation();
  376. activeVideos.get(video).isSticky = true;
  377. });
  378.  
  379. controller.addEventListener('mouseleave', function(e) {
  380. e.stopPropagation();
  381. activeVideos.get(video).isSticky = false;
  382. updateControllerVisibility();
  383. });
  384.  
  385. // Add elements to controller
  386. controller.appendChild(presetLabel);
  387. controller.appendChild(speedSelector);
  388. controller.appendChild(customLabel);
  389. controller.appendChild(customSpeedInput);
  390. controller.appendChild(applyButton);
  391.  
  392. // Add controller directly to document body
  393. document.body.appendChild(controller);
  394.  
  395. // Store data for this video
  396. activeVideos.set(video, {
  397. controller: controller,
  398. hideTimer: null,
  399. isSticky: false,
  400. speedSelector: speedSelector,
  401. customSpeedInput: customSpeedInput
  402. });
  403.  
  404. /**
  405. * Position the controller based on video position
  406. * @returns {void}
  407. */
  408. function positionController() {
  409. const rect = video.getBoundingClientRect();
  410. controller.style.position = 'fixed';
  411. controller.style.top = (rect.top + 10) + 'px';
  412. controller.style.left = (rect.left + 10) + 'px';
  413. }
  414.  
  415. // Position controller initially
  416. positionController();
  417.  
  418. // Update position on window resize and scroll
  419. window.addEventListener('resize', positionController);
  420. window.addEventListener('scroll', positionController);
  421.  
  422. // Initialize playback rate from video if it already has one set
  423. if (video.playbackRate !== 1) {
  424. applyPlaybackRate(video, video.playbackRate);
  425. }
  426.  
  427. // Flash controller briefly
  428. controller.classList.add(uniquePrefix + 'visible');
  429. setTimeout(() => {
  430. if (!activeVideos.get(video).isSticky) {
  431. controller.classList.remove(uniquePrefix + 'visible');
  432. }
  433. }, 1000);
  434. });
  435. }
  436.  
  437. // Run after page and resources are loaded
  438. window.addEventListener('load', function() {
  439. initVideoSpeedControl();
  440.  
  441. // Set up a single observer to detect both new videos and attribute changes
  442. const observer = new MutationObserver(function(mutations) {
  443. let videoAdded = false;
  444.  
  445. mutations.forEach(mutation => {
  446. // Case 1: Check for new videos in added nodes
  447. videoAdded = videoAdded || checkForNewVideos(mutation);
  448. // Case 2: Check for src attribute changes on videos that need controller updates
  449. videoAdded = videoAdded || checkForSrcChanges(mutation);
  450. });
  451.  
  452. if (videoAdded) {
  453. initVideoSpeedControl();
  454. }
  455. });
  456.  
  457. // Observe both child additions and attribute changes in one observer
  458. observer.observe(document.body, {
  459. childList: true,
  460. subtree: true,
  461. attributes: true,
  462. attributeFilter: ['src'],
  463. attributeOldValue: true
  464. });
  465. });
  466. /**
  467. * Check if a mutation contains new videos
  468. * @param {MutationRecord} mutation - The mutation record to check
  469. * @returns {boolean} - Whether new videos were found
  470. */
  471. function checkForNewVideos(mutation) {
  472. if (!mutation.addedNodes.length) return false;
  473. for (let i = 0; i < mutation.addedNodes.length; i++) {
  474. const node = mutation.addedNodes[i];
  475. // Direct video element
  476. if (node.nodeName === 'VIDEO') return true;
  477. // Element that might contain videos
  478. if (node.nodeType === 1 &&
  479. node.querySelector('video:not(.' + uniquePrefix + 'enhanced)')) {
  480. return true;
  481. }
  482. }
  483. return false;
  484. }
  485. /**
  486. * Check if a mutation represents a src change that needs controller updates
  487. * @param {MutationRecord} mutation - The mutation record to check
  488. * @returns {boolean} - Whether video controllers need updating
  489. */
  490. function checkForSrcChanges(mutation) {
  491. // Skip if not on a site with special rules or not a src change on a video
  492. if (!currentSite ||
  493. mutation.type !== 'attributes' ||
  494. mutation.attributeName !== 'src' ||
  495. mutation.target.nodeName !== 'VIDEO') {
  496. return false;
  497. }
  498. const video = /** @type {HTMLVideoElement} */ (mutation.target);
  499. // Skip if not a video we've already processed
  500. if (!video.classList.contains(uniquePrefix + 'enhanced')) {
  501. return false;
  502. }
  503. const shouldHave = shouldAddController(video);
  504. const hasController = activeVideos.has(video);
  505. // Case: Video should have controller but doesn't
  506. if (shouldHave && !hasController) {
  507. video.classList.remove(uniquePrefix + 'enhanced');
  508. return true; // Will trigger initVideoSpeedControl()
  509. }
  510. // Case: Video shouldn't have controller but does
  511. if (!shouldHave && hasController) {
  512. const data = activeVideos.get(video);
  513. if (data?.controller) {
  514. data.controller.remove();
  515. }
  516. activeVideos.delete(video);
  517. }
  518. return false;
  519. }
  520. })();