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
  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. // Create control panel DOM structure
  147. function createControlPanel() {
  148. const panel = createElement('div', 'yt-custom-control-panel');
  149. const toggleBtn = createElement('button', 'yt-custom-control-toggle', '≡');
  150. toggleBtn.id = 'yt-toggle-panel';
  151. const contentDiv = createElement('div', 'yt-custom-control-content');
  152.  
  153. const titleDiv = createElement('div', 'yt-custom-control-title');
  154. titleDiv.appendChild(createElement('span', '', 'YouTube Enhanced Controls'));
  155.  
  156. const speedSection = createElement('div', 'yt-custom-control-section');
  157. const speedText = createElement('div', '');
  158. speedText.textContent = 'Playback Speed: ';
  159. const speedValue = createElement('span', '', '1.0');
  160. speedValue.id = 'yt-speed-value';
  161. speedText.appendChild(speedValue);
  162. speedText.append('x');
  163. const speedControls = createElement('div', 'yt-speed-controls');
  164. const sliderRow = createElement('div', 'yt-slider-row');
  165. const speedSlider = createElement('input', 'yt-custom-slider');
  166. speedSlider.type = 'range';
  167. speedSlider.id = 'yt-speed-slider';
  168. speedSlider.min = '0.25';
  169. speedSlider.max = '5';
  170. speedSlider.step = '0.25';
  171. speedSlider.value = '1';
  172. sliderRow.appendChild(speedSlider);
  173. speedControls.appendChild(sliderRow);
  174. const presetSpeeds = createElement('div', 'yt-preset-speeds');
  175. [1, 1.5, 2, 3, 4, 5].forEach(speed => {
  176. const btn = createElement('button', 'yt-custom-btn yt-speed-preset', `${speed}x`);
  177. btn.dataset.speed = speed;
  178. presetSpeeds.appendChild(btn);
  179. });
  180. speedControls.appendChild(presetSpeeds);
  181. speedSection.appendChild(speedText);
  182. speedSection.appendChild(speedControls);
  183.  
  184. const loopSection = createElement('div', 'yt-custom-control-section');
  185. loopSection.appendChild(createElement('div', '', 'Loop Playback'));
  186. const loopToggle = createElement('button', 'yt-custom-btn', 'Off');
  187. loopToggle.id = 'yt-loop-toggle';
  188. loopSection.appendChild(loopToggle);
  189.  
  190. const loopRangeSection = createElement('div', 'yt-custom-control-section');
  191. loopRangeSection.appendChild(createElement('div', '', 'Loop Range'));
  192. const rangeButtons = createElement('div', '');
  193. const loopStartBtn = createElement('button', 'yt-custom-btn', 'Set Start');
  194. loopStartBtn.id = 'yt-loop-start-btn';
  195. const loopEndBtn = createElement('button', 'yt-custom-btn', 'Set End');
  196. loopEndBtn.id = 'yt-loop-end-btn';
  197. const loopClearBtn = createElement('button', 'yt-custom-btn', 'Clear');
  198. loopClearBtn.id = 'yt-loop-clear-btn';
  199. rangeButtons.append(loopStartBtn, loopEndBtn, loopClearBtn);
  200. const loopInfo = createElement('div', '', 'No loop range set');
  201. loopInfo.id = 'yt-loop-info';
  202. loopRangeSection.append(rangeButtons, loopInfo);
  203.  
  204. contentDiv.append(titleDiv, speedSection, loopSection, loopRangeSection);
  205. panel.append(toggleBtn, contentDiv);
  206.  
  207. const endDiv = document.querySelector('#end');
  208. if (endDiv) {
  209. endDiv.insertBefore(panel, endDiv.querySelector('#buttons'));
  210. } else {
  211. document.body.appendChild(panel);
  212. }
  213. return panel;
  214. }
  215.  
  216. // Wait for video element
  217. function waitForVideo() {
  218. return new Promise(resolve => {
  219. const checkVideo = () => {
  220. const video = document.querySelector('video');
  221. if (video) resolve(video);
  222. else setTimeout(checkVideo, 200);
  223. };
  224. checkVideo();
  225. });
  226. }
  227.  
  228. // Observe native playback rate changes
  229. function observePlaybackRate(video) {
  230. let lastRate = video.playbackRate;
  231. const interval = setInterval(() => {
  232. const newRate = video.playbackRate;
  233. if (newRate !== lastRate) {
  234. SpeedController.updatePlaybackRate(newRate);
  235. lastRate = newRate;
  236. }
  237. }, 500);
  238. return { disconnect: () => clearInterval(interval) };
  239. }
  240.  
  241. // Speed Controller Module
  242. const SpeedController = {
  243. updatePlaybackRate(rate) {
  244. const video = document.querySelector('video');
  245. if (!video) return;
  246. const speedValue = document.getElementById('yt-speed-value');
  247. const speedSlider = document.getElementById('yt-speed-slider');
  248. const speedPresets = document.querySelectorAll('.yt-speed-preset');
  249. if (speedValue) speedValue.textContent = rate.toFixed(2);
  250. if (speedSlider) speedSlider.value = rate;
  251. speedPresets.forEach(btn => {
  252. btn.classList.toggle('active', parseFloat(btn.dataset.speed) === rate);
  253. });
  254. },
  255. init(video, slider, presetSpeeds) {
  256. slider.addEventListener('input', () => {
  257. const rate = parseFloat(slider.value);
  258. video.playbackRate = rate;
  259. this.updatePlaybackRate(rate);
  260. });
  261. presetSpeeds.addEventListener('click', (e) => {
  262. const btn = e.target.closest('.yt-speed-preset');
  263. if (btn) {
  264. const rate = parseFloat(btn.dataset.speed);
  265. video.playbackRate = rate;
  266. this.updatePlaybackRate(rate);
  267. }
  268. });
  269. }
  270. };
  271.  
  272. // Loop Controller Module
  273. const LoopController = {
  274. init(video, toggle, startBtn, endBtn, clearBtn, info) {
  275. let isLooping = false;
  276. let loopStart = null;
  277. let loopEnd = null;
  278.  
  279. toggle.addEventListener('click', () => {
  280. isLooping = !isLooping;
  281. video.loop = isLooping;
  282. toggle.textContent = isLooping ? 'On' : 'Off';
  283. toggle.classList.toggle('active', isLooping);
  284. });
  285.  
  286. startBtn.addEventListener('click', () => {
  287. loopStart = video.currentTime;
  288. this.updateLoopInfo(loopStart, loopEnd, info);
  289. });
  290.  
  291. endBtn.addEventListener('click', () => {
  292. loopEnd = video.currentTime;
  293. this.updateLoopInfo(loopStart, loopEnd, info);
  294. });
  295.  
  296. clearBtn.addEventListener('click', () => {
  297. loopStart = null;
  298. loopEnd = null;
  299. this.updateLoopInfo(loopStart, loopEnd, info);
  300. });
  301.  
  302. video.addEventListener('timeupdate', () => {
  303. if (isLooping && loopStart !== null && loopEnd !== null && loopStart < loopEnd) {
  304. if (video.currentTime >= loopEnd) {
  305. video.currentTime = loopStart;
  306. }
  307. }
  308. });
  309. },
  310. updateLoopInfo(start, end, info) {
  311. if (start !== null && end !== null) {
  312. info.textContent = `From ${this.formatTime(start)} to ${this.formatTime(end)}`;
  313. } else if (start !== null) {
  314. info.textContent = `Start: ${this.formatTime(start)}, End: Not set`;
  315. } else if (end !== null) {
  316. info.textContent = `Start: Not set, End: ${this.formatTime(end)}`;
  317. } else {
  318. info.textContent = 'No loop range set';
  319. }
  320. },
  321. formatTime(seconds) {
  322. const mins = Math.floor(seconds / 60);
  323. const secs = Math.floor(seconds % 60);
  324. return `${mins}:${secs.toString().padStart(2, '0')}`;
  325. }
  326. };
  327.  
  328. // Main initialization
  329. async function init() {
  330. const video = await waitForVideo();
  331. const panel = createControlPanel();
  332.  
  333. setTimeout(() => {
  334. const toggleBtn = document.getElementById('yt-toggle-panel');
  335. const speedSlider = document.getElementById('yt-speed-slider');
  336. const presetSpeeds = document.querySelector('.yt-preset-speeds');
  337. const loopToggle = document.getElementById('yt-loop-toggle');
  338. const loopStartBtn = document.getElementById('yt-loop-start-btn');
  339. const loopEndBtn = document.getElementById('yt-loop-end-btn');
  340. const loopClearBtn = document.getElementById('yt-loop-clear-btn');
  341. const loopInfo = document.getElementById('yt-loop-info');
  342.  
  343. toggleBtn.addEventListener('click', () => {
  344. panel.classList.toggle('expanded');
  345. toggleBtn.textContent = panel.classList.contains('expanded') ? '_' : '≡';
  346. });
  347.  
  348. SpeedController.init(video, speedSlider, presetSpeeds);
  349. LoopController.init(video, loopToggle, loopStartBtn, loopEndBtn, loopClearBtn, loopInfo);
  350. const playbackRateObserver = observePlaybackRate(video);
  351. SpeedController.updatePlaybackRate(video.playbackRate || 1);
  352. }, 2000);
  353. }
  354.  
  355. // Ensure DOM is loaded
  356. document.addEventListener('DOMContentLoaded', () => {
  357. if (!document.body) return;
  358. setTimeout(init, 1000);
  359. });
  360.  
  361. // Detect page navigation
  362. let lastUrl = location.href;
  363. const observer = new MutationObserver(() => {
  364. if (lastUrl !== location.href) {
  365. lastUrl = location.href;
  366. const oldPanel = document.querySelector('.yt-custom-control-panel');
  367. if (oldPanel) oldPanel.remove();
  368. setTimeout(init, 1000);
  369. }
  370. });
  371. observer.observe(document, { subtree: true, childList: true });
  372. })();