YouTube Speed and Loop

Enhances YouTube with playback speeds beyond 2x and repeat functionality

  1. // ==UserScript==
  2. // @name YouTube Speed and Loop
  3. // @name:zh-TW YouTube 播放速度與循環
  4. // @namespace https://github.com/Hank8933
  5. // @version 1.0.1
  6. // @description Enhances YouTube with playback speeds beyond 2x and repeat functionality
  7. // @description:zh-TW 為 YouTube 提供超過 2 倍的播放速度控制和重複播放功能
  8. // @author Hank8933
  9. // @homepage https://github.com/Hank8933/YouTube-Speed-and-Loop
  10. // @match https://www.youtube.com/*
  11. // @grant none
  12. // @license MIT
  13. // ==/UserScript==
  14.  
  15. (function() {
  16. 'use strict';
  17.  
  18. // Define CSS with variables
  19. const panelCSS = `
  20. :root {
  21. --primary-bg: #212121;
  22. --hover-bg: #333;
  23. --active-bg: #f00;
  24. --panel-bg: rgba(33, 33, 33, 0.9);
  25. --text-color: #fff;
  26. --shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
  27. }
  28. .yt-custom-control-panel {
  29. position: relative;
  30. top: 0;
  31. left: 0;
  32. z-index: 99999;
  33. font-family: Roboto, Arial, sans-serif;
  34. align-self: center;
  35. }
  36. .yt-custom-control-toggle {
  37. background-color: var(--primary-bg);
  38. color: var(--text-color);
  39. padding: 8px 16px;
  40. border-radius: 20px;
  41. border: none;
  42. font-weight: bold;
  43. cursor: pointer;
  44. transition: background-color 0.3s;
  45. display: flex;
  46. align-items: center;
  47. justify-content: center;
  48. }
  49. .yt-custom-control-toggle:hover {
  50. background-color: var(--hover-bg);
  51. }
  52. .yt-custom-control-content {
  53. position: absolute;
  54. top: calc(100% + 5px);
  55. left: 50%;
  56. transform: translateX(-50%);
  57. background-color: var(--panel-bg);
  58. color: var(--text-color);
  59. padding: 10px;
  60. border-radius: 8px;
  61. box-shadow: var(--shadow);
  62. display: none;
  63. flex-direction: column;
  64. gap: 5px;
  65. min-width: 300px;
  66. white-space: nowrap;
  67. }
  68. .yt-custom-control-panel.expanded .yt-custom-control-content {
  69. display: flex;
  70. }
  71. .yt-custom-control-title {
  72. font-weight: bold;
  73. margin-bottom: 5px;
  74. }
  75. .yt-custom-control-section {
  76. margin-bottom: 5px;
  77. }
  78. .yt-custom-btn {
  79. background-color: #444;
  80. border: none;
  81. color: var(--text-color);
  82. padding: 5px 10px;
  83. border-radius: 4px;
  84. cursor: pointer;
  85. font-size: 12px;
  86. white-space: nowrap;
  87. text-align: center;
  88. flex: 1;
  89. margin-right: 5px;
  90. }
  91. .yt-custom-btn:last-child {
  92. margin-right: 0;
  93. }
  94. .yt-custom-btn:hover {
  95. background-color: #555;
  96. }
  97. .yt-custom-btn.active {
  98. background-color: var(--active-bg);
  99. }
  100. .yt-speed-controls {
  101. display: flex;
  102. flex-direction: column;
  103. gap: 5px;
  104. white-space: nowrap;
  105. }
  106. .yt-slider-row {
  107. display: flex;
  108. align-items: center;
  109. width: 100%;
  110. }
  111. .yt-custom-slider {
  112. flex-grow: 1;
  113. min-width: 100px;
  114. }
  115. .yt-preset-speeds {
  116. display: flex;
  117. gap: 5px;
  118. width: 100%;
  119. }
  120. .yt-custom-slider-value {
  121. min-width: 40px;
  122. text-align: right;
  123. }
  124. #end {
  125. display: flex;
  126. align-items: center;
  127. }
  128. #buttons {
  129. margin-left: 10px;
  130. }
  131. `;
  132.  
  133. // Add CSS to document head
  134. const styleEl = document.createElement('style');
  135. styleEl.textContent = panelCSS;
  136. document.head.appendChild(styleEl);
  137.  
  138. // Utility function to create DOM elements
  139. function createElement(tag, className, textContent) {
  140. const el = document.createElement(tag);
  141. if (className) el.className = className;
  142. if (textContent) el.textContent = textContent;
  143. return el;
  144. }
  145.  
  146. // Store disconnect functions for cleanup
  147. let playbackRateDisconnect = () => {};
  148. let loopDisconnect = () => {};
  149.  
  150. // Clean up existing panels and observers
  151. function cleanUpPanels() {
  152. // Disconnect observers to stop intervals
  153. playbackRateDisconnect();
  154. loopDisconnect();
  155. // Reset disconnect functions
  156. playbackRateDisconnect = () => {};
  157. loopDisconnect = () => {};
  158. // Remove existing panels
  159. const existingPanels = document.querySelectorAll('.yt-custom-control-panel');
  160. existingPanels.forEach(panel => panel.remove());
  161. }
  162.  
  163. // Create control panel DOM structure
  164. function createControlPanel() {
  165. const panel = createElement('div', 'yt-custom-control-panel');
  166. const toggleBtn = createElement('button', 'yt-custom-control-toggle', '≡');
  167. toggleBtn.id = 'yt-toggle-panel';
  168. const contentDiv = createElement('div', 'yt-custom-control-content');
  169.  
  170. const titleDiv = createElement('div', 'yt-custom-control-title');
  171. titleDiv.appendChild(createElement('span', '', 'YouTube Enhanced Controls'));
  172.  
  173. const speedSection = createElement('div', 'yt-custom-control-section');
  174. const speedText = createElement('div', '');
  175. speedText.textContent = 'Playback Speed: ';
  176. const speedValue = createElement('span', '', '1.0');
  177. speedValue.id = 'yt-speed-value';
  178. speedText.appendChild(speedValue);
  179. speedText.append('x');
  180. const speedControls = createElement('div', 'yt-speed-controls');
  181. const sliderRow = createElement('div', 'yt-slider-row');
  182. const speedSlider = createElement('input', 'yt-custom-slider');
  183. speedSlider.type = 'range';
  184. speedSlider.id = 'yt-speed-slider';
  185. speedSlider.min = '0.25';
  186. speedSlider.max = '5';
  187. speedSlider.step = '0.25';
  188. speedSlider.value = '1';
  189. sliderRow.appendChild(speedSlider);
  190. speedControls.appendChild(sliderRow);
  191. const presetSpeeds = createElement('div', 'yt-preset-speeds');
  192. [1, 1.5, 2, 3, 4, 5].forEach(speed => {
  193. const btn = createElement('button', 'yt-custom-btn yt-speed-preset', `${speed}x`);
  194. btn.dataset.speed = speed;
  195. presetSpeeds.appendChild(btn);
  196. });
  197. speedControls.appendChild(presetSpeeds);
  198. speedSection.appendChild(speedText);
  199. speedSection.appendChild(speedControls);
  200.  
  201. const loopSection = createElement('div', 'yt-custom-control-section');
  202. loopSection.appendChild(createElement('div', '', 'Loop Playback'));
  203. const loopToggle = createElement('button', 'yt-custom-btn', 'Off');
  204. loopToggle.id = 'yt-loop-toggle';
  205. loopSection.appendChild(loopToggle);
  206.  
  207. const loopRangeSection = createElement('div', 'yt-custom-control-section');
  208. loopRangeSection.appendChild(createElement('div', '', 'Loop Range'));
  209. const rangeButtons = createElement('div', '');
  210. const loopStartBtn = createElement('button', 'yt-custom-btn', 'Set Start');
  211. loopStartBtn.id = 'yt-loop-start-btn';
  212. const loopEndBtn = createElement('button', 'yt-custom-btn', 'Set End');
  213. loopEndBtn.id = 'yt-loop-end-btn';
  214. const loopClearBtn = createElement('button', 'yt-custom-btn', 'Clear');
  215. loopClearBtn.id = 'yt-loop-clear-btn';
  216. rangeButtons.append(loopStartBtn, loopEndBtn, loopClearBtn);
  217. const loopInfo = createElement('div', '', 'No loop range set');
  218. loopInfo.id = 'yt-loop-info';
  219. loopRangeSection.append(rangeButtons, loopInfo);
  220.  
  221. contentDiv.append(titleDiv, speedSection, loopSection, loopRangeSection);
  222. panel.append(toggleBtn, contentDiv);
  223.  
  224. const endDiv = document.querySelector('#end');
  225. if (endDiv) {
  226. endDiv.insertBefore(panel, endDiv.querySelector('#buttons'));
  227. } else {
  228. document.body.appendChild(panel);
  229. }
  230. return panel;
  231. }
  232.  
  233. // Wait for video element
  234. function waitForVideo() {
  235. return new Promise(resolve => {
  236. const checkVideo = () => {
  237. const video = document.querySelector('video');
  238. if (video) resolve(video);
  239. else setTimeout(checkVideo, 200);
  240. };
  241. checkVideo();
  242. });
  243. }
  244.  
  245. // Observe native playback rate changes
  246. function observePlaybackRate(video) {
  247. let lastRate = video.playbackRate;
  248. const interval = setInterval(() => {
  249. const newRate = video.playbackRate;
  250. if (newRate !== lastRate) {
  251. SpeedController.updatePlaybackRate(newRate);
  252. lastRate = newRate;
  253. }
  254. }, 500);
  255. return { disconnect: () => clearInterval(interval) };
  256. }
  257.  
  258. // Observe native loop changes
  259. function observeNativeLoop(video, toggle, updateLoopState) {
  260. let lastLoopState = video.loop;
  261. const interval = setInterval(() => {
  262. const currentLoopState = video.loop;
  263. if (currentLoopState !== lastLoopState) {
  264. updateLoopState(currentLoopState, toggle);
  265. lastLoopState = currentLoopState;
  266. }
  267. }, 500);
  268. return { disconnect: () => clearInterval(interval) };
  269. }
  270.  
  271. // Speed Controller Module
  272. const SpeedController = {
  273. updatePlaybackRate(rate) {
  274. const video = document.querySelector('video');
  275. if (!video) return;
  276. const speedValue = document.getElementById('yt-speed-value');
  277. const speedSlider = document.getElementById('yt-speed-slider');
  278. const speedPresets = document.querySelectorAll('.yt-speed-preset');
  279. if (speedValue) speedValue.textContent = rate.toFixed(2);
  280. if (speedSlider) speedSlider.value = rate;
  281. speedPresets.forEach(btn => {
  282. btn.classList.toggle('active', parseFloat(btn.dataset.speed) === rate);
  283. });
  284. },
  285. init(video, slider, presetSpeeds) {
  286. slider.addEventListener('input', () => {
  287. const rate = parseFloat(slider.value);
  288. video.playbackRate = rate;
  289. this.updatePlaybackRate(rate);
  290. });
  291. presetSpeeds.addEventListener('click', (e) => {
  292. const btn = e.target.closest('.yt-speed-preset');
  293. if (btn) {
  294. const rate = parseFloat(btn.dataset.speed);
  295. video.playbackRate = rate;
  296. this.updatePlaybackRate(rate);
  297. }
  298. });
  299. }
  300. };
  301.  
  302. // Loop Controller Module
  303. const LoopController = {
  304. init(video, toggle, startBtn, endBtn, clearBtn, info) {
  305. let isLooping = video.loop;
  306. let loopStart = null;
  307. let loopEnd = null;
  308.  
  309. toggle.textContent = isLooping ? 'On' : 'Off';
  310. toggle.classList.toggle('active', isLooping);
  311.  
  312. const updateLoopState = (newState, toggleBtn) => {
  313. isLooping = newState;
  314. toggleBtn.textContent = isLooping ? 'On' : 'Off';
  315. toggleBtn.classList.toggle('active', isLooping);
  316. };
  317.  
  318. toggle.addEventListener('click', () => {
  319. isLooping = !isLooping;
  320. video.loop = isLooping;
  321. updateLoopState(isLooping, toggle);
  322. });
  323.  
  324. startBtn.addEventListener('click', () => {
  325. loopStart = video.currentTime;
  326. this.updateLoopInfo(loopStart, loopEnd, info);
  327. });
  328.  
  329. endBtn.addEventListener('click', () => {
  330. loopEnd = video.currentTime;
  331. this.updateLoopInfo(loopStart, loopEnd, info);
  332. });
  333.  
  334. clearBtn.addEventListener('click', () => {
  335. loopStart = null;
  336. loopEnd = null;
  337. this.updateLoopInfo(loopStart, loopEnd, info);
  338. });
  339.  
  340. video.addEventListener('timeupdate', () => {
  341. if (isLooping && loopStart !== null && loopEnd !== null && loopStart < loopEnd) {
  342. if (video.currentTime >= loopEnd) {
  343. video.currentTime = loopStart;
  344. }
  345. }
  346. });
  347.  
  348. const loopObserver = observeNativeLoop(video, toggle, updateLoopState);
  349. loopDisconnect = loopObserver?.disconnect || (() => {});
  350. },
  351. updateLoopInfo(start, end, info) {
  352. if (start !== null && end !== null) {
  353. info.textContent = `From ${this.formatTime(start)} to ${this.formatTime(end)}`;
  354. } else if (start !== null) {
  355. info.textContent = `Start: ${this.formatTime(start)}, End: Not set`;
  356. } else if (end !== null) {
  357. info.textContent = `Start: Not set, End: ${this.formatTime(end)}`;
  358. } else {
  359. info.textContent = 'No loop range set';
  360. }
  361. },
  362. formatTime(seconds) {
  363. const mins = Math.floor(seconds / 60);
  364. const secs = Math.floor(seconds % 60);
  365. return `${mins}:${secs.toString().padStart(2, '0')}`;
  366. }
  367. };
  368.  
  369. // Main initialization
  370. async function init() {
  371. const video = await waitForVideo();
  372. cleanUpPanels();
  373. const panel = createControlPanel();
  374.  
  375. setTimeout(() => {
  376. const toggleBtn = document.getElementById('yt-toggle-panel');
  377. const speedSlider = document.getElementById('yt-speed-slider');
  378. const presetSpeeds = document.querySelector('.yt-preset-speeds');
  379. const loopToggle = document.getElementById('yt-loop-toggle');
  380. const loopStartBtn = document.getElementById('yt-loop-start-btn');
  381. const loopEndBtn = document.getElementById('yt-loop-end-btn');
  382. const loopClearBtn = document.getElementById('yt-loop-clear-btn');
  383. const loopInfo = document.getElementById('yt-loop-info');
  384.  
  385. toggleBtn.addEventListener('click', () => {
  386. panel.classList.toggle('expanded');
  387. toggleBtn.textContent = panel.classList.contains('expanded') ? '_' : '≡';
  388. });
  389.  
  390. SpeedController.init(video, speedSlider, presetSpeeds);
  391. LoopController.init(video, loopToggle, loopStartBtn, loopEndBtn, loopClearBtn, loopInfo);
  392. const playbackRateObserver = observePlaybackRate(video);
  393. playbackRateDisconnect = playbackRateObserver?.disconnect || (() => {});
  394. SpeedController.updatePlaybackRate(video.playbackRate || 1);
  395. }, 2000);
  396. }
  397.  
  398. // Ensure DOM is loaded
  399. document.addEventListener('DOMContentLoaded', () => {
  400. if (!document.body) return;
  401. setTimeout(init, 1000);
  402. });
  403.  
  404. // Detect page navigation
  405. let lastUrl = location.href;
  406. const observer = new MutationObserver(() => {
  407. if (lastUrl !== location.href) {
  408. lastUrl = location.href;
  409. cleanUpPanels();
  410. setTimeout(init, 1000);
  411. }
  412. });
  413. observer.observe(document, { subtree: true, childList: true });
  414. })();