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

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

  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.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-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) !important;
  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) !important;
  127. appearance: auto !important;
  128. color: white;
  129. width: fit-content;
  130. border: 1px solid white;
  131. border-radius: 3px;
  132. padding: 2px;
  133. margin-right: 8px;
  134. font-size: 14px;
  135. }
  136. .${uniquePrefix}controller input {
  137. background-color: rgba(0, 0, 0, 0.7);
  138. color: white;
  139. border: 1px solid white;
  140. border-radius: 3px;
  141. padding: 2px;
  142. width: 50px;
  143. margin-right: 5px;
  144. font-size: 14px;
  145. }
  146. .${uniquePrefix}controller button {
  147. background-color: rgba(0, 0, 0, 0.7);
  148. color: white;
  149. border: 1px solid white;
  150. border-radius: 3px;
  151. padding: 2px 5px;
  152. margin-right: 5px;
  153. font-size: 14px;
  154. cursor: pointer;
  155. }
  156. .${uniquePrefix}controller button:hover {
  157. background-color: rgba(255, 255, 255, 0.2);
  158. }
  159. video.${uniquePrefix}enhanced {
  160. z-index: auto !important;
  161. }
  162. `;
  163.  
  164. // Add styles to document
  165. const styleElement = document.createElement('style');
  166. styleElement.textContent = styles;
  167. document.head.appendChild(styleElement);
  168.  
  169. /**
  170. * Global tracking of active videos and controllers
  171. * @type {Map<HTMLVideoElement, VideoControllerData>}
  172. */
  173. const activeVideos = new Map();
  174.  
  175. // Track mouse position globally
  176. let mouseX = 0;
  177. let mouseY = 0;
  178.  
  179. document.addEventListener('mousemove', function(e) {
  180. mouseX = e.clientX;
  181. mouseY = e.clientY;
  182. updateControllerVisibility();
  183. });
  184.  
  185. /**
  186. * Function to update controller visibility based on mouse position
  187. * @returns {void}
  188. */
  189. function updateControllerVisibility() {
  190. activeVideos.forEach((data, video) => {
  191. const rect = video.getBoundingClientRect();
  192. const isMouseOver = (
  193. mouseX >= rect.left &&
  194. mouseX <= rect.right &&
  195. mouseY >= rect.top &&
  196. mouseY <= rect.bottom
  197. );
  198. const isVisible = data.controller.classList.contains(uniquePrefix + 'visible');
  199. // Handle showing controller when mouse is over video
  200. if (isMouseOver) {
  201. // Show controller if not already visible
  202. if (!isVisible) {
  203. data.controller.classList.add(uniquePrefix + 'visible');
  204. }
  205. // Reset hide timer
  206. clearTimeout(data.hideTimer);
  207. data.hideTimer = setTimeout(() => {
  208. if (!data.isSticky) {
  209. data.controller.classList.remove(uniquePrefix + 'visible');
  210. }
  211. }, 2000);
  212. return;
  213. }
  214. // When mouse is not over video and controller isn't sticky, hide it
  215. if (isVisible && !data.isSticky) {
  216. data.controller.classList.remove(uniquePrefix + 'visible');
  217. }
  218. });
  219. }
  220.  
  221. /**
  222. * Function to apply playback rate to a video
  223. * @param {HTMLVideoElement} video - The video element to modify
  224. * @param {number} rate - The playback rate to apply
  225. * @returns {void}
  226. */
  227. function applyPlaybackRate(video, rate) {
  228. // Validate inputs
  229. if (!video || isNaN(rate) || rate <= 0) {
  230. return;
  231. }
  232. // Apply rate to video
  233. video.playbackRate = rate;
  234. video.dataset.preferredRate = rate.toString();
  235.  
  236. // Get controller data
  237. const data = activeVideos.get(video);
  238. if (!data) {
  239. return;
  240. }
  241. // Update dropdown if available
  242. if (data.speedSelector) {
  243. updateSpeedSelector(data.speedSelector, rate);
  244. }
  245. // Update custom input if available
  246. if (data.customSpeedInput) {
  247. data.customSpeedInput.value = rate.toString();
  248. }
  249. }
  250. /**
  251. * Helper function to update speed selector dropdown
  252. * @param {HTMLSelectElement} selector - The speed selector element
  253. * @param {number} rate - The playback rate
  254. */
  255. function updateSpeedSelector(selector, rate) {
  256. const options = selector.options;
  257. // Check if rate matches a preset option
  258. for (let i = 0; i < options.length; i++) {
  259. if (parseFloat(options[i].value) === rate) {
  260. selector.selectedIndex = i;
  261. return;
  262. }
  263. }
  264. // If no match and "custom" option exists, select it
  265. const customOption = selector.querySelector('option[value="custom"]');
  266. if (customOption) {
  267. selector.value = "custom";
  268. }
  269. }
  270.  
  271. /**
  272. * Check for videos and add speed controller
  273. * @returns {void}
  274. */
  275. function initVideoSpeedControl() {
  276. const videos = document.querySelectorAll('video:not(.' + uniquePrefix + 'enhanced)');
  277.  
  278. videos.forEach((video, index) => {
  279. // Skip videos that shouldn't have controllers
  280. if (!shouldAddController(video)) {
  281. // Mark as enhanced to avoid rechecking
  282. video.classList.add(uniquePrefix + 'enhanced');
  283. return;
  284. }
  285.  
  286. // Mark video as enhanced
  287. video.classList.add(uniquePrefix + 'enhanced');
  288.  
  289. // Create controller element
  290. const controller = document.createElement('div');
  291. controller.className = uniquePrefix + 'controller';
  292. controller.setAttribute('id', uniquePrefix + 'controller-' + index);
  293.  
  294. // Create preset selector label
  295. const presetLabel = document.createElement('span');
  296. presetLabel.className = uniquePrefix + 'label';
  297. presetLabel.textContent = 'Preset:';
  298.  
  299. // Create speed selector
  300. const speedSelector = document.createElement('select');
  301.  
  302. speeds.forEach(speed => {
  303. const option = document.createElement('option');
  304. option.value = speed.toString();
  305. option.textContent = speed + 'x';
  306. if (speed === 1) {
  307. option.selected = true;
  308. }
  309. speedSelector.appendChild(option);
  310. });
  311.  
  312. // Add custom option
  313. const customOption = document.createElement('option');
  314. customOption.value = "custom";
  315. customOption.textContent = "Custom";
  316. speedSelector.appendChild(customOption);
  317.  
  318. // Create custom speed label
  319. const customLabel = document.createElement('span');
  320. customLabel.className = uniquePrefix + 'label';
  321. customLabel.textContent = 'Custom:';
  322.  
  323. // Create custom speed input
  324. const customSpeedInput = document.createElement('input');
  325. customSpeedInput.type = "number";
  326. customSpeedInput.min = "0.1";
  327. customSpeedInput.max = "16";
  328. customSpeedInput.step = "0.1";
  329. customSpeedInput.value = "1.0";
  330. customSpeedInput.placeholder = "Speed";
  331.  
  332. // Create apply button
  333. const applyButton = document.createElement('button');
  334. applyButton.textContent = "Apply";
  335.  
  336. // Listen for speed selector changes
  337. speedSelector.addEventListener('change', function() {
  338. if (this.value === "custom") return;
  339. const rate = parseFloat(this.value);
  340. applyPlaybackRate(video, rate);
  341. });
  342.  
  343. // Listen for custom speed input changes
  344. customSpeedInput.addEventListener('keyup', function(e) {
  345. if (e.key !== 'Enter') return;
  346. const rate = parseFloat(this.value);
  347. if (rate > 0) applyPlaybackRate(video, rate);
  348. });
  349.  
  350. // Listen for apply button click
  351. applyButton.addEventListener('click', function() {
  352. const rate = parseFloat(customSpeedInput.value);
  353. if (rate > 0) applyPlaybackRate(video, rate);
  354. });
  355.  
  356. // Ensure playback rate is maintained when video plays
  357. video.addEventListener('play', function() {
  358. const savedRateStr = this.dataset.preferredRate;
  359. if (!savedRateStr) return;
  360. const savedRate = parseFloat(savedRateStr);
  361. if (this.playbackRate === savedRate) return;
  362. this.playbackRate = savedRate;
  363. });
  364.  
  365. // Also check playback rate periodically to ensure it sticks
  366. setInterval(() => {
  367. const savedRateStr = video.dataset.preferredRate;
  368. if (!savedRateStr || video.paused) return;
  369. const savedRate = parseFloat(savedRateStr);
  370. if (video.playbackRate === savedRate) return;
  371. video.playbackRate = savedRate;
  372. }, 1000);
  373.  
  374. // Prevent controller mouse events from bubbling
  375. controller.addEventListener('mouseenter', function(e) {
  376. e.stopPropagation();
  377. activeVideos.get(video).isSticky = true;
  378. });
  379.  
  380. controller.addEventListener('mouseleave', function(e) {
  381. e.stopPropagation();
  382. activeVideos.get(video).isSticky = false;
  383. updateControllerVisibility();
  384. });
  385.  
  386. // Add elements to controller
  387. controller.appendChild(presetLabel);
  388. controller.appendChild(speedSelector);
  389. controller.appendChild(customLabel);
  390. controller.appendChild(customSpeedInput);
  391. controller.appendChild(applyButton);
  392.  
  393. // Add controller directly to document body
  394. document.body.appendChild(controller);
  395.  
  396. // Store data for this video
  397. activeVideos.set(video, {
  398. controller: controller,
  399. hideTimer: null,
  400. isSticky: false,
  401. speedSelector: speedSelector,
  402. customSpeedInput: customSpeedInput
  403. });
  404.  
  405. /**
  406. * Position the controller based on video position
  407. * @returns {void}
  408. */
  409. function positionController() {
  410. const rect = video.getBoundingClientRect();
  411. controller.style.position = 'fixed';
  412. controller.style.top = (rect.top + 10) + 'px';
  413. controller.style.left = (rect.left + 10) + 'px';
  414. }
  415.  
  416. // Position controller initially
  417. positionController();
  418.  
  419. // Update position on window resize and scroll
  420. window.addEventListener('resize', positionController);
  421. window.addEventListener('scroll', positionController);
  422.  
  423. // Initialize playback rate from video if it already has one set
  424. if (video.playbackRate !== 1) {
  425. applyPlaybackRate(video, video.playbackRate);
  426. }
  427.  
  428. // Flash controller briefly
  429. controller.classList.add(uniquePrefix + 'visible');
  430. setTimeout(() => {
  431. if (!activeVideos.get(video).isSticky) {
  432. controller.classList.remove(uniquePrefix + 'visible');
  433. }
  434. }, 1000);
  435. });
  436. }
  437.  
  438. // Run after page and resources are loaded
  439. window.addEventListener('load', function() {
  440. initVideoSpeedControl();
  441.  
  442. // Set up a single observer to detect both new videos and attribute changes
  443. const observer = new MutationObserver(function(mutations) {
  444. let videoAdded = false;
  445.  
  446. mutations.forEach(mutation => {
  447. // Case 1: Check for new videos in added nodes
  448. videoAdded = videoAdded || checkForNewVideos(mutation);
  449. // Case 2: Check for src attribute changes on videos that need controller updates
  450. videoAdded = videoAdded || checkForSrcChanges(mutation);
  451. });
  452.  
  453. if (videoAdded) {
  454. initVideoSpeedControl();
  455. }
  456. });
  457.  
  458. // Observe both child additions and attribute changes in one observer
  459. observer.observe(document.body, {
  460. childList: true,
  461. subtree: true,
  462. attributes: true,
  463. attributeFilter: ['src'],
  464. attributeOldValue: true
  465. });
  466. });
  467. /**
  468. * Check if a mutation contains new videos
  469. * @param {MutationRecord} mutation - The mutation record to check
  470. * @returns {boolean} - Whether new videos were found
  471. */
  472. function checkForNewVideos(mutation) {
  473. if (!mutation.addedNodes.length) return false;
  474. for (let i = 0; i < mutation.addedNodes.length; i++) {
  475. const node = mutation.addedNodes[i];
  476. // Direct video element
  477. if (node.nodeName === 'VIDEO') return true;
  478. // Element that might contain videos
  479. if (node.nodeType === 1 &&
  480. node.querySelector('video:not(.' + uniquePrefix + 'enhanced)')) {
  481. return true;
  482. }
  483. }
  484. return false;
  485. }
  486. /**
  487. * Check if a mutation represents a src change that needs controller updates
  488. * @param {MutationRecord} mutation - The mutation record to check
  489. * @returns {boolean} - Whether video controllers need updating
  490. */
  491. function checkForSrcChanges(mutation) {
  492. // Skip if not on a site with special rules or not a src change on a video
  493. if (!currentSite ||
  494. mutation.type !== 'attributes' ||
  495. mutation.attributeName !== 'src' ||
  496. mutation.target.nodeName !== 'VIDEO') {
  497. return false;
  498. }
  499. const video = /** @type {HTMLVideoElement} */ (mutation.target);
  500. // Skip if not a video we've already processed
  501. if (!video.classList.contains(uniquePrefix + 'enhanced')) {
  502. return false;
  503. }
  504. const shouldHave = shouldAddController(video);
  505. const hasController = activeVideos.has(video);
  506. // Case: Video should have controller but doesn't
  507. if (shouldHave && !hasController) {
  508. video.classList.remove(uniquePrefix + 'enhanced');
  509. return true; // Will trigger initVideoSpeedControl()
  510. }
  511. // Case: Video shouldn't have controller but does
  512. if (!shouldHave && hasController) {
  513. const data = activeVideos.get(video);
  514. if (data?.controller) {
  515. data.controller.remove();
  516. }
  517. activeVideos.delete(video);
  518. }
  519. return false;
  520. }
  521. })();