Video Speed Controller (Userscript)

Control HTML5 video playback speed with shortcuts and an on-screen controller. Based on codebicycle/videospeed.

  1. // ==UserScript==
  2. // @name Video Speed Controller (Userscript)
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.0.0
  5. // @description Control HTML5 video playback speed with shortcuts and an on-screen controller. Based on codebicycle/videospeed.
  6. // @author Based on codebicycle/videospeed, adapted by [https://github.com/codebicycle/videospeed]
  7. // @match *://*/*
  8. // @grant GM_getValue
  9. // @grant GM_setValue
  10. // @grant GM_addStyle
  11. // @grant GM_registerMenuCommand
  12. // @grant GM_log
  13. // @run-at document-idle
  14. // @license MIT
  15. // ==/UserScript==
  16.  
  17. (function() {
  18. 'use strict';
  19.  
  20. // --- Start: CSS ---
  21. const shadowCSS = `
  22. :host {
  23. all: initial; /* Isolate from host page CSS */
  24. }
  25. #controller {
  26. position: fixed;
  27. z-index: 2147483647; /* Max possible z-index */
  28. top: 10px;
  29. left: 10px;
  30. background-color: rgba(0, 0, 0, 0.7);
  31. border-radius: 5px;
  32. display: flex;
  33. align-items: center;
  34. justify-content: center;
  35. padding: 3px 5px;
  36. transition: opacity 0.2s ease-in-out;
  37. font-family: sans-serif;
  38. font-size: 14px;
  39. color: white;
  40. cursor: pointer;
  41. box-shadow: 0 0 5px rgba(0, 0, 0, 0.5);
  42. }
  43. #controller.dragging {
  44. cursor: grabbing;
  45. }
  46. #controller.vsc-hidden:not(.vsc-manual) {
  47. opacity: 0 !important; /* Override inline style if not manually hidden */
  48. pointer-events: none;
  49. }
  50. #controller.vsc-nosource {
  51. display: none !important;
  52. }
  53. #controls {
  54. display: flex;
  55. align-items: center;
  56. margin-left: 5px;
  57. }
  58. span.draggable {
  59. padding: 5px 8px;
  60. cursor: grab;
  61. user-select: none; /* Prevent text selection during drag */
  62. }
  63. button {
  64. background: none;
  65. border: none;
  66. color: white;
  67. font-size: 16px;
  68. font-weight: bold;
  69. cursor: pointer;
  70. padding: 2px 5px;
  71. margin: 0 1px;
  72. min-width: 20px;
  73. line-height: 1;
  74. }
  75. button:hover {
  76. background-color: rgba(255, 255, 255, 0.2);
  77. border-radius: 3px;
  78. }
  79. button.rw { /* Rewind/Advance buttons */
  80. font-size: 14px;
  81. }
  82. button.hideButton {
  83. font-size: 14px;
  84. padding: 2px 4px;
  85. }
  86. `;
  87.  
  88. const settingsDialogCSS = `
  89. #vscSettingsDialog {
  90. position: fixed;
  91. top: 50%;
  92. left: 50%;
  93. transform: translate(-50%, -50%);
  94. background-color: #f0f0f0;
  95. border: 1px solid #ccc;
  96. box-shadow: 0 5px 15px rgba(0,0,0,0.3);
  97. z-index: 2147483647; /* Above controller */
  98. max-height: 80vh;
  99. overflow-y: auto;
  100. padding: 20px;
  101. border-radius: 8px;
  102. font-family: sans-serif;
  103. font-size: 14px;
  104. color: #333;
  105. min-width: 450px;
  106. max-width: 90vw;
  107. }
  108. #vscSettingsDialog h3 {
  109. margin-top: 0;
  110. margin-bottom: 15px;
  111. border-bottom: 1px solid #ddd;
  112. padding-bottom: 5px;
  113. }
  114. #vscSettingsDialog .vsc-settings-row {
  115. display: flex;
  116. align-items: center;
  117. margin-bottom: 10px;
  118. gap: 10px;
  119. }
  120. #vscSettingsDialog .vsc-settings-row label {
  121. flex-basis: 150px;
  122. flex-shrink: 0;
  123. text-align: right;
  124. font-weight: bold;
  125. }
  126. #vscSettingsDialog .vsc-settings-row label i {
  127. font-weight: normal;
  128. font-size: 0.9em;
  129. color: #666;
  130. display: block;
  131. }
  132. #vscSettingsDialog input[type="text"],
  133. #vscSettingsDialog input[type="number"],
  134. #vscSettingsDialog select,
  135. #vscSettingsDialog textarea {
  136. padding: 5px;
  137. border: 1px solid #ccc;
  138. border-radius: 3px;
  139. flex-grow: 1;
  140. }
  141. #vscSettingsDialog input[type="checkbox"] {
  142. margin-left: 5px;
  143. }
  144. #vscSettingsDialog textarea {
  145. min-height: 100px;
  146. resize: vertical;
  147. }
  148. #vscSettingsDialog .vsc-keybinding-row {
  149. display: grid;
  150. grid-template-columns: 150px 100px 80px 1fr auto;
  151. gap: 5px;
  152. margin-bottom: 5px;
  153. align-items: center;
  154. }
  155. #vscSettingsDialog .vsc-keybinding-row select,
  156. #vscSettingsDialog .vsc-keybinding-row input {
  157. width: 100%;
  158. box-sizing: border-box;
  159. }
  160. #vscSettingsDialog button {
  161. padding: 8px 15px;
  162. cursor: pointer;
  163. border: 1px solid #ccc;
  164. border-radius: 4px;
  165. margin-right: 5px;
  166. }
  167. #vscSettingsDialog button.vsc-save { background-color: #4CAF50; color: white; border-color: #4CAF50; }
  168. #vscSettingsDialog button.vsc-restore { background-color: #ff9800; color: white; border-color: #ff9800; }
  169. #vscSettingsDialog button.vsc-close { background-color: #f44336; color: white; border-color: #f44336;}
  170. #vscSettingsDialog button.vsc-remove-binding { background-color: #ddd; color: #333; border-color: #ccc; padding: 2px 5px; font-size: 12px;}
  171. #vscSettingsDialog .vsc-settings-actions {
  172. margin-top: 20px;
  173. text-align: right;
  174. }
  175. #vscSettingsDialog #vscStatus {
  176. margin-top: 10px;
  177. color: green;
  178. font-weight: bold;
  179. }
  180. `;
  181. // --- End: CSS ---
  182.  
  183.  
  184. var regStrip = /^[\r\t\f\v ]+|[\r\t\f\v ]+$/gm;
  185.  
  186. var tcDefaults = {
  187. lastSpeed: 1.0,
  188. enabled: true,
  189. speeds: {}, // Holds speed per source URL (if rememberSpeed is on)
  190.  
  191. rememberSpeed: false,
  192. forceLastSavedSpeed: false,
  193. audioBoolean: false, // Process <audio> tags
  194. startHidden: false,
  195. controllerOpacity: 0.3,
  196.  
  197. keyBindings: [
  198. { action: "display", key: 86, value: 0, force: false, predefined: true }, // V
  199. { action: "slower", key: 83, value: 0.1, force: false, predefined: true }, // S
  200. { action: "faster", key: 68, value: 0.1, force: false, predefined: true }, // D
  201. { action: "rewind", key: 90, value: 10, force: false, predefined: true }, // Z
  202. { action: "advance", key: 88, value: 10, force: false, predefined: true }, // X
  203. { action: "reset", key: 82, value: 1.0, force: false, predefined: true }, // R - special handling: toggles between 1.0 and 'fast' speed
  204. { action: "fast", key: 71, value: 1.8, force: false, predefined: true } // G - the 'preferred' speed for reset toggle
  205. // Users can add more, e.g., pause, mute, mark, jump
  206. ],
  207. blacklist: `\
  208. www.instagram.com
  209. twitter.com
  210. vine.co
  211. imgur.com
  212. teams.microsoft.com
  213. `.replace(regStrip, ""),
  214. defaultLogLevel: 4, // 1=none, 2=error, 3=warn, 4=info, 5=debug, 6=verbose+trace
  215. logLevel: 3 // Default level
  216. };
  217.  
  218. var tc = {
  219. settings: {}, // Loaded settings will go here
  220. mediaElements: [], // Attached video/audio elements
  221. videoController: null, // Will hold the constructor
  222. coolDown: false // For ratechange event handler
  223. };
  224.  
  225. /* Log levels (depends on caller specifying the correct level)
  226. 1 - none, 2 - error, 3 - warning, 4 - info, 5 - debug, 6 - verbose+trace */
  227. function log(message, level) {
  228. const verbosity = tc.settings.logLevel || tcDefaults.logLevel;
  229. level = level || tc.settings.defaultLogLevel || tcDefaults.defaultLogLevel;
  230. if (verbosity >= level) {
  231. let prefix = "";
  232. switch(level) {
  233. case 2: prefix = "ERROR:"; break;
  234. case 3: prefix = "WARNING:"; break;
  235. case 4: prefix = "INFO:"; break;
  236. case 5: prefix = "DEBUG:"; break;
  237. case 6: prefix = "DEBUG(V):"; break;
  238. }
  239. if (typeof GM_log === 'function') {
  240. GM_log(prefix + message); // Use GM_log if available
  241. } else {
  242. console.log("VSC: " + prefix + message);
  243. }
  244. if (level === 6) console.trace();
  245. }
  246. }
  247.  
  248. // --- KeyCode Utilities (from options.js) ---
  249. var keyCodeAliases = { /* ... (same as in your options.js) ... */
  250. 0: "null", null: "null", undefined: "null", 32: "Space", 37: "Left", 38: "Up", 39: "Right", 40: "Down",
  251. 96: "Num 0", 97: "Num 1", 98: "Num 2", 99: "Num 3", 100: "Num 4", 101: "Num 5", 102: "Num 6", 103: "Num 7", 104: "Num 8", 105: "Num 9",
  252. 106: "Num *", 107: "Num +", 109: "Num -", 110: "Num .", 111: "Num /", 112: "F1", 113: "F2", 114: "F3", 115: "F4", 116: "F5", 117: "F6",
  253. 118: "F7", 119: "F8", 120: "F9", 121: "F10", 122: "F11", 123: "F12", 186: ";", 188: "<", 189: "-", 187: "+", 190: ">", 191: "/",
  254. 192: "~", 219: "[", 220: "\\", 221: "]", 222: "'", 59: ";", 61: "+", 173: "-"
  255. };
  256. var customActionsNoValues = ["pause", "muted", "mark", "jump", "display"];
  257.  
  258. function recordKeyPress(e) { /* ... (same as in your options.js) ... */
  259. if ((e.keyCode >= 48 && e.keyCode <= 57) || (e.keyCode >= 65 && e.keyCode <= 90) || keyCodeAliases[e.keyCode]) {
  260. e.target.value = keyCodeAliases[e.keyCode] || String.fromCharCode(e.keyCode);
  261. e.target.keyCode = e.keyCode;
  262. e.preventDefault(); e.stopPropagation();
  263. } else if (e.keyCode === 8) { e.target.value = ""; e.target.keyCode = null; }
  264. else if (e.keyCode === 27) { e.target.value = "null"; e.target.keyCode = null; }
  265. }
  266. function inputFilterNumbersOnly(e) { /* ... (same as in your options.js) ... */
  267. var char = String.fromCharCode(e.keyCode); if (!/[\d\.]$/.test(char) || !/^\d+(\.\d*)?$/.test(e.target.value + char)) { e.preventDefault(); e.stopPropagation(); }
  268. }
  269. function inputFocus(e) { e.target.value = ""; }
  270. function inputBlur(e) { e.target.value = keyCodeAliases[e.target.keyCode] || String.fromCharCode(e.target.keyCode) || 'press a key'; }
  271. // --- End KeyCode Utilities ---
  272.  
  273. async function loadSettings() {
  274. log("Loading settings...", 5);
  275. const storedSettings = await GM_getValue('vscSettings', {});
  276. // Deep merge defaults and stored settings (simple version)
  277. tc.settings = { ...tcDefaults }; // Start with defaults
  278. for (const key in tcDefaults) {
  279. if (storedSettings.hasOwnProperty(key)) {
  280. // Special handling for potentially complex types like keyBindings
  281. if (key === 'keyBindings' && Array.isArray(storedSettings[key])) {
  282. tc.settings[key] = JSON.parse(JSON.stringify(storedSettings[key])); // Deep copy array
  283. } else if (key === 'speeds' && typeof storedSettings[key] === 'object') {
  284. tc.settings[key] = { ...storedSettings[key] }; // Copy speeds object
  285. } else if (typeof storedSettings[key] === typeof tcDefaults[key] || storedSettings[key] == null) {
  286. // Basic type match or stored is null/undefined -> use stored
  287. tc.settings[key] = storedSettings[key];
  288. }
  289. // Otherwise, keep the default (type mismatch?) - could log a warning
  290. }
  291. }
  292.  
  293. // Ensure essential structure exists (especially after updates)
  294. tc.settings.keyBindings = tc.settings.keyBindings || [];
  295. tc.settings.speeds = tc.settings.speeds || {};
  296.  
  297. // Upgrade/Fix: Ensure 'display' keybinding exists
  298. if (!tc.settings.keyBindings.some(b => b.action === 'display')) {
  299. log("Adding missing 'display' keybinding", 3);
  300. tc.settings.keyBindings.push({ action: "display", key: 86, value: 0, force: false, predefined: true }); // Default V
  301. }
  302.  
  303. // Convert relevant settings to correct types (GM_getValue might return strings)
  304. tc.settings.lastSpeed = Number(tc.settings.lastSpeed) || tcDefaults.lastSpeed;
  305. tc.settings.enabled = tc.settings.enabled === true; // Force boolean
  306. tc.settings.rememberSpeed = tc.settings.rememberSpeed === true;
  307. tc.settings.forceLastSavedSpeed = tc.settings.forceLastSavedSpeed === true;
  308. tc.settings.audioBoolean = tc.settings.audioBoolean === true;
  309. tc.settings.startHidden = tc.settings.startHidden === true;
  310. tc.settings.controllerOpacity = Number(tc.settings.controllerOpacity) || tcDefaults.controllerOpacity;
  311. tc.settings.logLevel = Number(tc.settings.logLevel) || tcDefaults.logLevel;
  312. tc.settings.blacklist = String(tc.settings.blacklist || tcDefaults.blacklist);
  313.  
  314. // Clean up keybindings (ensure numbers)
  315. tc.settings.keyBindings.forEach(b => {
  316. b.key = Number(b.key);
  317. b.value = Number(b.value);
  318. b.force = String(b.force) === 'true'; // Force boolean from string/bool
  319. b.predefined = b.predefined === true;
  320. });
  321.  
  322. log("Settings loaded.", 4);
  323. // GM_log(JSON.stringify(tc.settings, null, 2)); // Debug: Log loaded settings
  324. }
  325.  
  326. async function saveSettings() {
  327. log("Saving settings...", 5);
  328. // Ensure types are correct before saving
  329. const settingsToSave = {
  330. ...tc.settings,
  331. lastSpeed: Number(tc.settings.lastSpeed),
  332. enabled: Boolean(tc.settings.enabled),
  333. rememberSpeed: Boolean(tc.settings.rememberSpeed),
  334. forceLastSavedSpeed: Boolean(tc.settings.forceLastSavedSpeed),
  335. audioBoolean: Boolean(tc.settings.audioBoolean),
  336. startHidden: Boolean(tc.settings.startHidden),
  337. controllerOpacity: Number(tc.settings.controllerOpacity),
  338. blacklist: String(tc.settings.blacklist).replace(regStrip, ""),
  339. logLevel: Number(tc.settings.logLevel),
  340. keyBindings: tc.settings.keyBindings.map(b => ({
  341. action: String(b.action),
  342. key: Number(b.key) || null, // Store null if no key
  343. value: Number(b.value) || 0,
  344. force: String(b.force), // Store as string 'true'/'false'
  345. predefined: Boolean(b.predefined)
  346. }))
  347. // Note: tc.settings.speeds is managed internally by the controller/ratechange,
  348. // but we might want to persist it IF rememberSpeed is true long-term.
  349. // For simplicity here, we are NOT saving the 'speeds' map. It relies on lastSpeed.
  350. };
  351. // Remove the potentially large 'speeds' map before saving general settings
  352. delete settingsToSave.speeds;
  353. delete settingsToSave.defaultLogLevel; // Don't save defaults
  354.  
  355. await GM_setValue('vscSettings', settingsToSave);
  356. log("Settings saved.", 4);
  357. }
  358.  
  359. function getKeyBindings(action, what = "value") {
  360. // Find function remains useful
  361. try {
  362. const binding = tc.settings.keyBindings.find((item) => item.action === action);
  363. return binding ? binding[what] : undefined; // Return undefined if not found
  364. } catch (e) {
  365. log("Error getting key binding for action '" + action + "': " + e, 2);
  366. return undefined;
  367. }
  368. }
  369.  
  370. function setKeyBindings(action, value, what = "value") {
  371. // Find and update function remains useful
  372. try {
  373. const binding = tc.settings.keyBindings.find((item) => item.action === action);
  374. if (binding) {
  375. binding[what] = value;
  376. } else {
  377. log("Could not find key binding for action '" + action + "' to set " + what, 3);
  378. }
  379. } catch (e) {
  380. log("Error setting key binding for action '" + action + "': " + e, 2);
  381. }
  382. }
  383.  
  384. function defineVideoController() {
  385. // --- This function is largely the same as in inject.js ---
  386. // Key differences:
  387. // - CSS is embedded in shadowTemplate
  388. // - GM_addStyle is not needed here (used elsewhere for settings dialog)
  389. // - chrome.runtime.getURL is removed
  390.  
  391. tc.videoController = function (target, parent) {
  392. if (target.vsc) { return target.vsc; }
  393. log("Attaching controller to: " + (target.currentSrc || target.src || target.tagName), 5);
  394. tc.mediaElements.push(target);
  395.  
  396. this.video = target;
  397. this.parent = target.parentElement || parent;
  398.  
  399. // --- Speed initialization logic (slightly modified) ---
  400. let currentSpeed = tc.settings.lastSpeed; // Start with global last speed
  401. if (tc.settings.rememberSpeed && tc.settings.speeds && target.currentSrc && tc.settings.speeds[target.currentSrc]) {
  402. // If remembering and we have a specific speed for this URL, use it
  403. currentSpeed = tc.settings.speeds[target.currentSrc];
  404. log(`Recalling stored speed for ${target.currentSrc}: ${currentSpeed}`, 5);
  405. } else if (!tc.settings.rememberSpeed) {
  406. // If not remembering speed, always reset to 1.0 unless forced
  407. if (!tc.settings.forceLastSavedSpeed) {
  408. currentSpeed = 1.0;
  409. log("Setting speed to 1.0 (rememberSpeed false, forceLastSavedSpeed false)", 5);
  410. } else {
  411. log(`Using last saved speed ${tc.settings.lastSpeed} (rememberSpeed false, forceLastSavedSpeed true)`, 5);
  412. }
  413. } else {
  414. log(`Using global last speed ${tc.settings.lastSpeed} (rememberSpeed true, no specific URL speed)`, 5);
  415. }
  416. // --- End Speed initialization ---
  417.  
  418. log("Applying initial playbackRate: " + currentSpeed, 4);
  419. target.playbackRate = currentSpeed; // Apply the determined speed
  420.  
  421.  
  422. this.div = this.initializeControls(currentSpeed); // Pass speed for initial display
  423.  
  424. var mediaEventAction = (event) => {
  425. // Simplified event action: On play/seek, re-apply the *current* speed setting.
  426. // The ratechange listener handles the actual updates and persistence.
  427. // This prevents sites that reset speed on play/seek from breaking things.
  428. const targetSpeed = target.vsc?.speedIndicator?.textContent ? Number(target.vsc.speedIndicator.textContent) : tc.settings.lastSpeed;
  429. log(`"${event.type}" event detected. Re-applying speed: ${targetSpeed}`, 5);
  430. // Use setSpeed to ensure consistency and events if needed
  431. setSpeed(event.target, targetSpeed);
  432. };
  433.  
  434. target.addEventListener("play", this.handlePlay = mediaEventAction.bind(this));
  435. target.addEventListener("seeked", this.handleSeek = mediaEventAction.bind(this)); // Use 'seeked' not 'seek'
  436.  
  437. // --- MutationObserver for src changes ---
  438. var observer = new MutationObserver((mutations) => {
  439. mutations.forEach((mutation) => {
  440. if (mutation.type === "attributes" && (mutation.attributeName === "src" || mutation.attributeName === "currentSrc")) {
  441. log("Mutation observer: src/currentSrc changed on video element.", 5);
  442. var controllerDiv = this.div; // The outer wrapper div
  443. if (!mutation.target.src && !mutation.target.currentSrc) {
  444. controllerDiv.classList.add("vsc-nosource");
  445. } else {
  446. controllerDiv.classList.remove("vsc-nosource");
  447. // Optionally re-apply speed for the new source
  448. let newSrcSpeed = tc.settings.lastSpeed;
  449. if (tc.settings.rememberSpeed && tc.settings.speeds && mutation.target.currentSrc && tc.settings.speeds[mutation.target.currentSrc]) {
  450. newSrcSpeed = tc.settings.speeds[mutation.target.currentSrc];
  451. } else if (!tc.settings.rememberSpeed && !tc.settings.forceLastSavedSpeed) {
  452. newSrcSpeed = 1.0;
  453. }
  454. log(`Applying speed ${newSrcSpeed} for new source: ${mutation.target.currentSrc}`, 4);
  455. setSpeed(mutation.target, newSrcSpeed);
  456. }
  457. }
  458. });
  459. });
  460. observer.observe(target, { attributeFilter: ["src", "currentSrc"] });
  461. target.vscObserver = observer; // Store observer for cleanup
  462. };
  463.  
  464. tc.videoController.prototype.remove = function () {
  465. log("Removing controller from: " + (this.video.currentSrc || this.video.src || this.video.tagName), 5);
  466. if (this.div) this.div.remove();
  467. this.video.removeEventListener("play", this.handlePlay);
  468. this.video.removeEventListener("seeked", this.handleSeek);
  469. if (this.video.vscObserver) this.video.vscObserver.disconnect();
  470.  
  471. let idx = tc.mediaElements.indexOf(this.video);
  472. if (idx != -1) { tc.mediaElements.splice(idx, 1); }
  473. delete this.video.vscObserver;
  474. delete this.video.vsc; // Remove back-reference
  475. };
  476.  
  477. tc.videoController.prototype.initializeControls = function (initialSpeed) {
  478. log("initializeControls Begin", 5);
  479. const document = this.video.ownerDocument;
  480. const speed = Number(initialSpeed).toFixed(2); // Use passed initial speed
  481. var top = "10px"; // Use fixed position now
  482. var left = "10px"; // Use fixed position now
  483.  
  484. log("Initial speed indicator: " + speed, 5);
  485.  
  486. var wrapper = document.createElement("div");
  487. wrapper.classList.add("vsc-controller"); // Outer wrapper for positioning/visibility
  488.  
  489. if (!this.video.src && !this.video.currentSrc) { wrapper.classList.add("vsc-nosource"); }
  490. if (tc.settings.startHidden) { wrapper.classList.add("vsc-hidden"); }
  491.  
  492. var shadow = wrapper.attachShadow({ mode: "open" });
  493. var shadowTemplate = `
  494. <style>
  495. ${shadowCSS} /* Embed CSS here */
  496. </style>
  497.  
  498. <div id="controller" style="opacity:${tc.settings.controllerOpacity}">
  499. <span data-action="drag" class="draggable" title="Drag to move">${speed}</span>
  500. <span id="controls">
  501. <button data-action="rewind" class="rw" title="Rewind (${getKeyBindings('rewind', 'value')}s)">«</button>
  502. <button data-action="slower" title="Slower (by ${getKeyBindings('slower', 'value')})">−</button>
  503. <button data-action="faster" title="Faster (by ${getKeyBindings('faster', 'value')})">+</button>
  504. <button data-action="advance" class="rw" title="Advance (${getKeyBindings('advance', 'value')}s)">»</button>
  505. <button data-action="display" class="hideButton" title="Show/Hide Controller">×</button>
  506. </span>
  507. </div>
  508. `;
  509. shadow.innerHTML = shadowTemplate;
  510.  
  511. this.speedIndicator = shadow.querySelector("span.draggable"); // Correct selector
  512. const controllerElement = shadow.querySelector("#controller");
  513.  
  514. // --- Event Listeners for Shadow DOM elements ---
  515. this.speedIndicator.addEventListener("mousedown", (e) => {
  516. if (e.button === 0) { // Only drag on left-click
  517. runAction(e.target.dataset["action"], false, e, this.video);
  518. e.stopPropagation();
  519. }
  520. }, true);
  521.  
  522. shadow.querySelectorAll("button").forEach((button) => {
  523. button.addEventListener("click", (e) => {
  524. const action = e.target.dataset["action"];
  525. // Pass the specific video element this controller belongs to
  526. runAction(action, getKeyBindings(action), e, this.video);
  527. e.stopPropagation();
  528. }, true);
  529. });
  530.  
  531. controllerElement.addEventListener("click", e => e.stopPropagation(), false);
  532. controllerElement.addEventListener("mousedown", e => e.stopPropagation(), false);
  533. // --- End Event Listeners ---
  534.  
  535. var fragment = document.createDocumentFragment();
  536. fragment.appendChild(wrapper);
  537.  
  538. // Simplified insertion: Use document.body or a high-level container.
  539. // The fixed positioning makes the exact parent less critical, but stacking context might matter.
  540. // Appending to body is usually safest for fixed elements.
  541. if (document.body) {
  542. document.body.appendChild(fragment);
  543. } else {
  544. // Fallback if body isn't ready? Unlikely with @run-at document-idle
  545. document.documentElement.appendChild(fragment);
  546. }
  547.  
  548.  
  549. return wrapper; // Return the outer wrapper
  550. };
  551. } // end defineVideoController
  552.  
  553. function escapeStringRegExp(str) {
  554. const matchOperatorsRe = /[|\\{}()[\]^$+*?.]/g;
  555. return str.replace(matchOperatorsRe, "\\$&");
  556. }
  557.  
  558. function isBlacklisted() {
  559. let blacklisted = false;
  560. const lines = tc.settings.blacklist?.split("\n") || [];
  561. const currentHref = location.href;
  562.  
  563. lines.forEach((match) => {
  564. match = match.replace(regStrip, "");
  565. if (match.length === 0) return;
  566.  
  567. try {
  568. let regexp;
  569. if (match.startsWith("/") && match.endsWith("/")) {
  570. // Basic regex detection (might need improvement for flags)
  571. regexp = new RegExp(match.slice(1, -1));
  572. } else {
  573. // Treat as plain string, escape for regex
  574. regexp = new RegExp(escapeStringRegExp(match));
  575. }
  576.  
  577. if (regexp.test(currentHref)) {
  578. blacklisted = true;
  579. log(`Current URL ${currentHref} matches blacklist pattern: ${match}`, 4);
  580. }
  581. } catch (err) {
  582. log(`Invalid blacklist pattern: ${match}. Error: ${err}`, 2);
  583. }
  584. });
  585. return blacklisted;
  586. }
  587.  
  588. function refreshCoolDown() {
  589. // log("Begin refreshCoolDown", 6); // Usually too verbose
  590. if (tc.coolDown) { clearTimeout(tc.coolDown); }
  591. tc.coolDown = setTimeout(() => { tc.coolDown = false; }, 50); // Shorter cooldown? 1000ms seems long.
  592. // log("End refreshCoolDown", 6);
  593. }
  594.  
  595. function setupListeners() {
  596. // --- ratechange listener ---
  597. document.addEventListener("ratechange", (event) => {
  598. const video = event.target;
  599. // Ignore if no controller attached OR if cooldown active
  600. if (!video.vsc || tc.coolDown) {
  601. if (tc.coolDown) log("Rate change event blocked by cooldown", 6);
  602. return;
  603. }
  604.  
  605. const currentRate = Number(video.playbackRate.toFixed(2));
  606. const lastTrackedSpeed = tc.settings.lastSpeed;
  607. const origin = event.detail?.origin; // Check for our custom event origin
  608.  
  609. log(`Rate change detected. New rate: ${currentRate}, Origin: ${origin || 'browser/site'}, Forced: ${tc.settings.forceLastSavedSpeed}`, 5);
  610.  
  611. if (tc.settings.forceLastSavedSpeed) {
  612. if (origin === "videoSpeedController") {
  613. // Event originated from us, update everything
  614. updateSpeedFromEvent(video, currentRate);
  615. } else {
  616. // Event originated elsewhere, force speed back
  617. log(`Force mode: Rate changed externally to ${currentRate}, reverting to ${lastTrackedSpeed}`, 4);
  618. // Re-apply speed *without* triggering cooldown/loop
  619. video.playbackRate = lastTrackedSpeed;
  620. // No updateSpeedFromEvent here, as we want the UI to reflect the forced speed
  621. event.stopImmediatePropagation(); // Prevent further listeners
  622. }
  623. } else {
  624. // Not forcing speed, just update based on the event
  625. if (video.vsc.speedIndicator && Number(video.vsc.speedIndicator.textContent) !== currentRate) {
  626. updateSpeedFromEvent(video, currentRate);
  627. } else if (!video.vsc.speedIndicator) {
  628. log("Rate change on video without speed indicator?", 3);
  629. }
  630. }
  631. }, true); // Use capture phase
  632.  
  633. // --- keydown listener ---
  634. document.addEventListener("keydown", (event) => {
  635. const keyCode = event.keyCode;
  636. log("Keydown event: " + keyCode, 6);
  637.  
  638. // Ignore if modifier keys are pressed (Alt, Ctrl, Meta, etc.)
  639. if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { // Added Shift check - often used by sites
  640. // Allow Shift+Key combos if needed? Maybe check specific bindings later.
  641. // log("Keydown ignored due to modifier key.", 6);
  642. return;
  643. }
  644.  
  645. // Ignore if typing in input fields/contentEditable
  646. const target = event.target;
  647. if (target.nodeName === "INPUT" || target.nodeName === "TEXTAREA" || target.isContentEditable) {
  648. log("Keydown ignored in input field.", 6);
  649. return;
  650. }
  651.  
  652. // Ignore if no media elements are being controlled on the page
  653. if (!tc.mediaElements.length) {
  654. // log("Keydown ignored, no controlled media elements.", 6);
  655. return;
  656. }
  657.  
  658. // Find matching key binding
  659. const binding = tc.settings.keyBindings.find(item => item.key === keyCode);
  660. if (binding) {
  661. log(`Matched key binding: Action=${binding.action}, Value=${binding.value}, Force=${binding.force}`, 5);
  662. // Run action on ALL controlled media elements by default
  663. runAction(binding.action, binding.value, event);
  664.  
  665. if (binding.force) {
  666. log("Forcing keybind, stopping propagation.", 5);
  667. event.preventDefault();
  668. event.stopPropagation();
  669. }
  670. }
  671. }, true); // Use capture phase
  672. } // end setupListeners
  673.  
  674. function updateSpeedFromEvent(video, newSpeed) {
  675. if (!video.vsc) return; // Should not happen if called correctly
  676.  
  677. newSpeed = Number(newSpeed.toFixed(2)); // Ensure 2 decimal places
  678. const speedIndicator = video.vsc.speedIndicator;
  679. const src = video.currentSrc;
  680.  
  681. log(`Updating speed indicator to ${newSpeed}`, 5);
  682. if (speedIndicator) {
  683. speedIndicator.textContent = newSpeed.toFixed(2);
  684. }
  685.  
  686. tc.settings.lastSpeed = newSpeed; // Update global last speed
  687.  
  688. if (tc.settings.rememberSpeed && src) {
  689. log(`Storing speed ${newSpeed} for src: ${src}`, 5);
  690. tc.settings.speeds[src] = newSpeed; // Update speed for this specific source
  691. }
  692.  
  693. // Persist global lastSpeed (throttled saving might be better)
  694. saveSettings(); // Consider debouncing this for performance
  695.  
  696. // Blink controller momentarily
  697. runAction("blink", 1000, null, video); // Blink only the specific video's controller
  698. }
  699.  
  700. function initializeWhenReady(doc) {
  701. log("initializeWhenReady called", 5);
  702. // Blacklist check moved here - crucial first step
  703. if (isBlacklisted()) {
  704. log("Page is blacklisted. Aborting initialization.", 4);
  705. return;
  706. }
  707. // Enabled check
  708. if (!tc.settings.enabled) {
  709. log("Extension is disabled. Aborting initialization.", 4);
  710. return;
  711. }
  712.  
  713. // Wait for document readiness
  714. if (doc.readyState === "complete" || doc.readyState === "interactive") {
  715. log("Document ready, initializing now.", 5);
  716. initializeNow(doc);
  717. } else {
  718. log("Document not ready, adding readystatechange listener.", 5);
  719. doc.addEventListener('readystatechange', () => {
  720. if (doc.readyState === "complete") {
  721. log("Document reached complete state, initializing now.", 5);
  722. initializeNow(doc);
  723. }
  724. }, { once: true }); // Use once option
  725. }
  726. }
  727.  
  728. function inIframe() {
  729. try { return window.self !== window.top; } catch (e) { return true; } // Assume iframe if top access fails
  730. }
  731.  
  732. // Helper to find elements including shadow DOM (basic version)
  733. function querySelectorAllIncludingShadows(selector, root = document.body) {
  734. const results = Array.from(root.querySelectorAll(selector));
  735. const elementsWithShadow = root.querySelectorAll('*');
  736. elementsWithShadow.forEach(el => {
  737. if (el.shadowRoot) {
  738. results.push(...querySelectorAllIncludingShadows(selector, el.shadowRoot));
  739. }
  740. });
  741. return results;
  742. }
  743.  
  744.  
  745. var initialized = false; // Prevent multiple initializations
  746.  
  747. function initializeNow(doc) {
  748. log("initializeNow Begin", 5);
  749. if (initialized || !doc.body || doc.body.classList.contains("vsc-initialized")) {
  750. log(`Initialization skipped: ${initialized ? 'already run' : !doc.body ? 'no body' : 'body flag set'}`, 5);
  751. return;
  752. }
  753. initialized = true; // Set flag early
  754. doc.body.classList.add("vsc-initialized"); // Mark as initialized
  755.  
  756. try {
  757. // Define the controller constructor if it hasn't been defined yet
  758. // This usually happens only once for the top-level document
  759. if (!tc.videoController) {
  760. defineVideoController();
  761. setupListeners(); // Setup global listeners once
  762. }
  763. } catch(e) {
  764. log("Error defining controller or setting up listeners: " + e, 2);
  765. return; // Critical error, stop initialization
  766. }
  767.  
  768.  
  769. // --- Find and attach controllers to existing media ---
  770. const mediaSelector = tc.settings.audioBoolean ? 'video, audio' : 'video';
  771. // Use a function that can potentially pierce shadow DOM if necessary
  772. // const mediaTags = querySelectorAllIncludingShadows(mediaSelector, doc); // More robust but slower
  773. const mediaTags = doc.querySelectorAll(mediaSelector); // Standard way
  774.  
  775. log(`Found ${mediaTags.length} initial media elements matching selector "${mediaSelector}"`, 4);
  776. mediaTags.forEach(media => {
  777. if (!media.vsc) { // Check if controller not already attached
  778. try {
  779. media.vsc = new tc.videoController(media, media.parentElement);
  780. } catch (e) {
  781. log(`Error creating controller for media element: ${e}`, 2);
  782. }
  783. }
  784. });
  785.  
  786. // --- Setup MutationObserver to detect dynamically added media ---
  787. const observer = new MutationObserver((mutations) => {
  788. // Debounce or throttle observer callback if performance is an issue
  789. mutations.forEach((mutation) => {
  790. mutation.addedNodes.forEach((node) => {
  791. if (node.nodeType === Node.ELEMENT_NODE) { // Check if it's an element
  792. findAndAddControllers(node, mutation.target);
  793. }
  794. });
  795. mutation.removedNodes.forEach((node) => {
  796. if (node.nodeType === Node.ELEMENT_NODE) {
  797. findAndRemoveControllers(node);
  798. }
  799. });
  800. // Handling attribute changes (like aria-hidden) is complex, omitted for simplicity here
  801. // but could be added back if needed for specific sites like the original code did.
  802. });
  803. });
  804.  
  805. observer.observe(doc.body || doc.documentElement, { childList: true, subtree: true });
  806. log("MutationObserver initialized to watch for new media elements.", 4);
  807.  
  808. // --- Handle IFrames ---
  809. // Recurse into iframes only if not already in an iframe (or handle permissions)
  810. if (!inIframe()) { // Only the top level window should scan for iframes
  811. const frameTags = doc.getElementsByTagName("iframe");
  812. log(`Scanning ${frameTags.length} iframes.`, 5);
  813. Array.from(frameTags).forEach((frame) => {
  814. try {
  815. const childDocument = frame.contentDocument;
  816. if (childDocument) {
  817. // Pass the settings down? Or assume iframe runs its own instance?
  818. // Running own instance is simpler for Userscripts.
  819. // However, need to be careful not to double-initialize if script runs in iframe too.
  820. log(`Attempting to initialize in iframe: ${frame.src || '(no src)'}`, 5);
  821. initializeWhenReady(childDocument); // Recurse
  822. }
  823. } catch (e) {
  824. // Log cross-origin iframe access error only once or at lower level
  825. // log(`Cannot access contentDocument of iframe: ${frame.src || '(no src)'}. Error: ${e}`, 6);
  826. }
  827. });
  828. }
  829.  
  830. log("initializeNow End", 5);
  831. } // end initializeNow
  832.  
  833.  
  834. // Helper function for MutationObserver to find media in added nodes
  835. function findAndAddControllers(node, parent) {
  836. const mediaSelector = tc.settings.audioBoolean ? 'video, audio' : 'video';
  837. if (node.matches && node.matches(mediaSelector)) {
  838. if (!node.vsc) {
  839. log(`Dynamically added media found: ${node.tagName}`, 5);
  840. try {
  841. node.vsc = new tc.videoController(node, parent);
  842. } catch (e) {
  843. log(`Error creating controller for dynamic media: ${e}`, 2);
  844. }
  845. }
  846. } else if (node.querySelectorAll) { // Check descendants
  847. node.querySelectorAll(mediaSelector).forEach(media => {
  848. if (!media.vsc) {
  849. log(`Dynamically added media found (descendant): ${media.tagName}`, 5);
  850. try {
  851. media.vsc = new tc.videoController(media, media.parentElement);
  852. } catch (e) {
  853. log(`Error creating controller for dynamic descendant media: ${e}`, 2);
  854. }
  855. }
  856. });
  857. }
  858. }
  859.  
  860. // Helper function for MutationObserver to clean up controllers from removed nodes
  861. function findAndRemoveControllers(node) {
  862. const mediaSelector = tc.settings.audioBoolean ? 'video, audio' : 'video';
  863. if (node.matches && node.matches(mediaSelector)) {
  864. if (node.vsc) {
  865. log(`Media element removed, cleaning up controller: ${node.tagName}`, 5);
  866. node.vsc.remove();
  867. }
  868. } else if (node.querySelectorAll) { // Check descendants
  869. node.querySelectorAll(mediaSelector).forEach(media => {
  870. if (media.vsc) {
  871. log(`Descendant media element removed, cleaning up controller: ${media.tagName}`, 5);
  872. media.vsc.remove();
  873. }
  874. });
  875. }
  876. }
  877.  
  878. function setSpeed(video, speed) {
  879. log(`setSpeed called: ${speed}`, 5);
  880. const speedValue = Number(speed.toFixed(2));
  881. if (isNaN(speedValue)) {
  882. log(`Invalid speed value: ${speed}`, 2);
  883. return;
  884. }
  885.  
  886. // Use a custom event to signal the change *originated* from the controller
  887. // This helps the ratechange listener distinguish our changes from external ones when forceLastSavedSpeed is on.
  888. const eventDetail = { detail: { origin: "videoSpeedController", speed: speedValue } };
  889. const rateChangeEvent = new CustomEvent("ratechange", eventDetail);
  890.  
  891. log(`Dispatching ratechange event with origin. Speed: ${speedValue}`, 6);
  892. video.dispatchEvent(rateChangeEvent); // Dispatch *before* setting rate if !forceLastSavedSpeed? Order matters.
  893.  
  894. // Actually set the playback rate
  895. // Check if the rate is already correct to avoid unnecessary changes/event loops
  896. if (video.playbackRate !== speedValue) {
  897. video.playbackRate = speedValue;
  898. log(`Set video.playbackRate to: ${speedValue}`, 5);
  899. refreshCoolDown(); // Apply cooldown *after* setting rate
  900. } else {
  901. log(`Video.playbackRate already ${speedValue}, no change needed.`, 6);
  902. }
  903.  
  904. // Update UI indicator (redundant if ratechange handler works, but safe fallback)
  905. if (video.vsc && video.vsc.speedIndicator) {
  906. video.vsc.speedIndicator.textContent = speedValue.toFixed(2);
  907. }
  908. // Update global/local speed tracking (also done in ratechange, maybe debounce?)
  909. tc.settings.lastSpeed = speedValue;
  910. if (tc.settings.rememberSpeed && video.currentSrc) {
  911. tc.settings.speeds[video.currentSrc] = speedValue;
  912. }
  913.  
  914. // saveSettings(); // Avoid saving on every single speed change - do it in ratechange or debounced.
  915. log(`setSpeed finished for: ${speedValue}`, 5);
  916. }
  917.  
  918. function runAction(action, value, event, specificVideo = null) {
  919. // log(`runAction Begin: Action=${action}, Value=${value}`, 5);
  920.  
  921. // Determine target elements: either a specific video or all controlled elements
  922. const targetMedia = specificVideo ? [specificVideo] : tc.mediaElements;
  923. if (!targetMedia || targetMedia.length === 0) {
  924. log("runAction: No target media elements found.", 3);
  925. return;
  926. }
  927.  
  928. targetMedia.forEach((v) => {
  929. if (!v || !v.vsc || !v.vsc.div) {
  930. log(`runAction: Skipping element without valid controller. ${v ? v.tagName : 'null'}`, 6);
  931. return; // Skip if controller isn't properly initialized
  932. }
  933. const controllerDiv = v.vsc.div; // The outer wrapper div
  934.  
  935. // Show controller temporarily on most actions (except drag/blink itself)
  936. if (action !== 'drag' && action !== 'blink' && action !== 'display') {
  937. showControllerTemporarily(controllerDiv);
  938. }
  939.  
  940. // Skip action if the video source is invalid (e.g., element removed but not cleaned up yet)
  941. if (controllerDiv.classList.contains("vsc-nosource")) {
  942. log("runAction: Skipping action on element with no source.", 6);
  943. return;
  944. }
  945.  
  946. try {
  947. switch (action) {
  948. case "rewind":
  949. log(`Rewind by ${value}s`, 5);
  950. v.currentTime = Math.max(0, v.currentTime - value);
  951. break;
  952. case "advance":
  953. log(`Advance by ${value}s`, 5);
  954. v.currentTime = Math.min(v.duration || Infinity, v.currentTime + value);
  955. break;
  956. case "faster":
  957. log(`Increase speed by ${value}`, 5);
  958. const nextFasterSpeed = Math.min((v.playbackRate < 0.1 ? 0.0 : v.playbackRate) + value, 16);
  959. setSpeed(v, nextFasterSpeed);
  960. break;
  961. case "slower":
  962. log(`Decrease speed by ${value}`, 5);
  963. const nextSlowerSpeed = Math.max(v.playbackRate - value, 0.07); // Chrome min is ~0.0625
  964. setSpeed(v, nextSlowerSpeed);
  965. break;
  966. case "reset": // Special toggle logic
  967. log("Reset/Toggle speed", 5);
  968. resetSpeed(v); // Uses internal logic based on current/fast/1.0 speeds
  969. break;
  970. case "fast": // Set to preferred fast speed directly
  971. log(`Set preferred speed to ${value}`, 5);
  972. setSpeed(v, value);
  973. break;
  974. case "display":
  975. log("Toggle controller display", 5);
  976. controllerDiv.classList.toggle("vsc-hidden");
  977. controllerDiv.classList.toggle("vsc-manual", controllerDiv.classList.contains("vsc-hidden")); // Mark manual hide/show
  978. break;
  979. case "blink":
  980. log("Blink controller", 5);
  981. blinkController(controllerDiv, value || 1000);
  982. break;
  983. case "drag":
  984. if (event) handleDrag(v, event);
  985. break;
  986. case "pause":
  987. log(v.paused ? "Play" : "Pause", 5);
  988. if (v.paused) v.play(); else v.pause();
  989. break;
  990. case "muted":
  991. log(v.muted ? "Unmute" : "Mute", 5);
  992. v.muted = !v.muted;
  993. break;
  994. case "mark":
  995. log(`Set marker at ${v.currentTime.toFixed(1)}s`, 5);
  996. v.vsc.mark = v.currentTime;
  997. break;
  998. case "jump":
  999. if (v.vsc.mark && typeof v.vsc.mark === "number") {
  1000. log(`Jump to marker at ${v.vsc.mark.toFixed(1)}s`, 5);
  1001. v.currentTime = v.vsc.mark;
  1002. } else {
  1003. log("Jump action failed: No marker set", 3);
  1004. }
  1005. break;
  1006. default:
  1007. log(`Unknown action: ${action}`, 3);
  1008. }
  1009. } catch (e) {
  1010. log(`Error executing action "${action}" on video: ${e}`, 2);
  1011. }
  1012. }); // end forEach media element
  1013.  
  1014. // log("runAction End", 5);
  1015. } // end runAction
  1016.  
  1017. function resetSpeed(v) {
  1018. const currentSpeed = v.playbackRate;
  1019. const fastSpeed = getKeyBindings("fast", "value") || tcDefaults.keyBindings.find(b=>b.action==='fast').value; // Ensure fallback
  1020. const resetSpeedTarget = 1.0; // Standard reset is always 1.0
  1021.  
  1022. if (currentSpeed === resetSpeedTarget) {
  1023. // If currently at 1.0, toggle to 'fast' speed
  1024. log(`Toggling speed from ${resetSpeedTarget} to fast speed ${fastSpeed}`, 4);
  1025. setSpeed(v, fastSpeed);
  1026. } else {
  1027. // Otherwise, reset to 1.0
  1028. log(`Resetting speed from ${currentSpeed} to ${resetSpeedTarget}`, 4);
  1029. setSpeed(v, resetSpeedTarget);
  1030. }
  1031. }
  1032.  
  1033. function handleDrag(video, e) {
  1034. const controller = video.vsc.div; // The outer wrapper
  1035. const shadowController = controller.shadowRoot.querySelector("#controller"); // The element inside shadow DOM
  1036. if (!controller || !shadowController) return;
  1037.  
  1038. controller.classList.add("vcs-dragging"); // Use a class on the *outer* element for potential global styles
  1039. shadowController.classList.add("dragging"); // Class within shadow DOM for styling/cursor
  1040.  
  1041. const initialMouseX = e.clientX;
  1042. const initialMouseY = e.clientY;
  1043. // Get initial position relative to viewport
  1044. const rect = shadowController.getBoundingClientRect();
  1045. const initialControllerX = rect.left;
  1046. const initialControllerY = rect.top;
  1047.  
  1048. const startDragging = (moveEvent) => {
  1049. let dx = moveEvent.clientX - initialMouseX;
  1050. let dy = moveEvent.clientY - initialMouseY;
  1051. // Calculate new top-left based on viewport coordinates
  1052. let newX = initialControllerX + dx;
  1053. let newY = initialControllerY + dy;
  1054.  
  1055. // Basic boundary checks (optional)
  1056. newX = Math.max(0, Math.min(newX, window.innerWidth - shadowController.offsetWidth));
  1057. newY = Math.max(0, Math.min(newY, window.innerHeight - shadowController.offsetHeight));
  1058.  
  1059. shadowController.style.left = newX + "px";
  1060. shadowController.style.top = newY + "px";
  1061. shadowController.style.right = 'auto'; // Ensure right/bottom aren't interfering
  1062. shadowController.style.bottom = 'auto';
  1063. };
  1064.  
  1065. const stopDragging = () => {
  1066. document.removeEventListener("mousemove", startDragging);
  1067. document.removeEventListener("mouseup", stopDragging);
  1068. document.removeEventListener("mouseleave", stopDragging); // In case mouse leaves window
  1069.  
  1070. controller.classList.remove("vcs-dragging");
  1071. shadowController.classList.remove("dragging");
  1072. log(`Drag ended. Final position: left=${shadowController.style.left}, top=${shadowController.style.top}`, 5);
  1073. // TODO: Persist the position? This would require adding properties to settings and saving/loading them.
  1074. // For simplicity, position is not saved in this version.
  1075. };
  1076.  
  1077. // Add listeners to the document to capture mouse movements anywhere
  1078. document.addEventListener("mousemove", startDragging);
  1079. document.addEventListener("mouseup", stopDragging);
  1080. document.addEventListener("mouseleave", stopDragging); // Handle mouse leaving the window
  1081. }
  1082.  
  1083. var controllerTimers = new Map(); // Use a Map to track timers per controller
  1084.  
  1085. function showControllerTemporarily(controllerDiv, duration = 2000) {
  1086. if (!controllerDiv) return;
  1087. // log("Showing controller temporarily", 6);
  1088. controllerDiv.classList.add("vcs-show"); // Maybe use opacity transition instead of a class?
  1089.  
  1090. const existingTimer = controllerTimers.get(controllerDiv);
  1091. if (existingTimer) clearTimeout(existingTimer);
  1092.  
  1093. const timer = setTimeout(() => {
  1094. controllerDiv.classList.remove("vcs-show");
  1095. controllerTimers.delete(controllerDiv);
  1096. // log("Hiding controller after timeout", 6);
  1097. }, duration);
  1098. controllerTimers.set(controllerDiv, timer);
  1099. }
  1100.  
  1101. function blinkController(controllerDiv, duration = 1000) {
  1102. if (!controllerDiv) return;
  1103. const wasHidden = controllerDiv.classList.contains("vsc-hidden");
  1104. const existingTimer = controllerTimers.get(controllerDiv); // Use the same map as showControllerTemporarily
  1105.  
  1106. if (existingTimer) clearTimeout(existingTimer);
  1107.  
  1108. controllerDiv.classList.remove("vsc-hidden"); // Ensure it's visible
  1109.  
  1110. const timer = setTimeout(() => {
  1111. // Only re-hide if it was originally hidden and not manually shown
  1112. if (wasHidden && !controllerDiv.classList.contains("vsc-manual")) {
  1113. controllerDiv.classList.add("vsc-hidden");
  1114. }
  1115. controllerTimers.delete(controllerDiv);
  1116. }, duration);
  1117. controllerTimers.set(controllerDiv, timer);
  1118. }
  1119.  
  1120. // --- Settings Dialog ---
  1121.  
  1122. function showSettingsDialog() {
  1123. let dialog = document.getElementById('vscSettingsDialog');
  1124. if (dialog) {
  1125. dialog.style.display = 'block'; // Show if already exists
  1126. return;
  1127. }
  1128.  
  1129. GM_addStyle(settingsDialogCSS); // Inject CSS if not already done
  1130.  
  1131. dialog = document.createElement('div');
  1132. dialog.id = 'vscSettingsDialog';
  1133.  
  1134. let contentHTML = `
  1135. <h3>Video Speed Controller Settings</h3>
  1136. <div id="vscSettingsContent">
  1137. <h4>General</h4>
  1138. <div class="vsc-settings-row">
  1139. <label for="vscEnabled">Enable Extension:</label>
  1140. <input type="checkbox" id="vscEnabled">
  1141. </div>
  1142. <div class="vsc-settings-row">
  1143. <label for="vscRememberSpeed">Remember Playback Speed:</label>
  1144. <input type="checkbox" id="vscRememberSpeed">
  1145. </div>
  1146. <div class="vsc-settings-row">
  1147. <label for="vscForceLastSavedSpeed">Force Last Saved Speed:<br><i>Overrides website speed changes</i></label>
  1148. <input type="checkbox" id="vscForceLastSavedSpeed">
  1149. </div>
  1150. <div class="vsc-settings-row">
  1151. <label for="vscAudioBoolean">Enable on Audio Elements:</label>
  1152. <input type="checkbox" id="vscAudioBoolean">
  1153. </div>
  1154. <div class="vsc-settings-row">
  1155. <label for="vscStartHidden">Hide Controller By Default:</label>
  1156. <input type="checkbox" id="vscStartHidden">
  1157. </div>
  1158. <div class="vsc-settings-row">
  1159. <label for="vscControllerOpacity">Controller Opacity (0.0-1.0):</label>
  1160. <input type="number" id="vscControllerOpacity" step="0.1" min="0" max="1">
  1161. </div>
  1162.  
  1163. <h4>Shortcuts</h4>
  1164. <div id="vscKeyBindingsContainer">
  1165. <!-- Keybindings will be added here -->
  1166. </div>
  1167. <button id="vscAddBindingBtn">+ Add Shortcut</button>
  1168.  
  1169. <h4>Blacklist</h4>
  1170. <div class="vsc-settings-row">
  1171. <label for="vscBlacklist">Disabled Sites (one per line):<br><i>Plain text or /regex/</i></label>
  1172. <textarea id="vscBlacklist" rows="6"></textarea>
  1173. </div>
  1174.  
  1175. <h4>Debugging</h4>
  1176. <div class="vsc-settings-row">
  1177. <label for="vscLogLevel">Log Level:</label>
  1178. <select id="vscLogLevel">
  1179. <option value="1">1: None</option>
  1180. <option value="2">2: Errors</option>
  1181. <option value="3">3: Warnings</option>
  1182. <option value="4">4: Info</option>
  1183. <option value="5">5: Debug</option>
  1184. <option value="6">6: Verbose Debug</option>
  1185. </select>
  1186. </div>
  1187.  
  1188. </div>
  1189. <div id="vscStatus" style="min-height: 1em;"></div>
  1190. <div class="vsc-settings-actions">
  1191. <button id="vscSaveBtn" class="vsc-save">Save and Close</button>
  1192. <button id="vscRestoreBtn" class="vsc-restore">Restore Defaults</button>
  1193. <button id="vscCloseBtn" class="vsc-close">Close</button>
  1194. </div>
  1195. `;
  1196.  
  1197. dialog.innerHTML = contentHTML;
  1198. document.body.appendChild(dialog);
  1199.  
  1200. // Populate dialog with current settings
  1201. populateSettingsDialog();
  1202.  
  1203. // Add event listeners for dialog elements
  1204. dialog.querySelector('#vscSaveBtn').addEventListener('click', saveSettingsFromDialog);
  1205. dialog.querySelector('#vscRestoreBtn').addEventListener('click', restoreDefaultSettingsInDialog);
  1206. dialog.querySelector('#vscCloseBtn').addEventListener('click', () => dialog.remove());
  1207. dialog.querySelector('#vscAddBindingBtn').addEventListener('click', addKeyBindingRow);
  1208.  
  1209. // Event delegation for dynamic elements (key inputs, remove buttons)
  1210. const bindingsContainer = dialog.querySelector('#vscKeyBindingsContainer');
  1211. bindingsContainer.addEventListener('keydown', e => { if (e.target.classList.contains('vsc-binding-key')) recordKeyPress(e); });
  1212. bindingsContainer.addEventListener('focus', e => { if (e.target.classList.contains('vsc-binding-key')) inputFocus(e); }, true);
  1213. bindingsContainer.addEventListener('blur', e => { if (e.target.classList.contains('vsc-binding-key')) inputBlur(e); }, true);
  1214. bindingsContainer.addEventListener('keypress', e => { if (e.target.classList.contains('vsc-binding-value')) inputFilterNumbersOnly(e); });
  1215. bindingsContainer.addEventListener('click', e => { if (e.target.classList.contains('vsc-remove-binding')) removeKeyBindingRow(e.target); });
  1216. bindingsContainer.addEventListener('change', e => { if (e.target.classList.contains('vsc-binding-action')) toggleValueInputDisabled(e.target); });
  1217. }
  1218.  
  1219. function populateSettingsDialog() {
  1220. const dialog = document.getElementById('vscSettingsDialog');
  1221. if (!dialog) return;
  1222.  
  1223. dialog.querySelector('#vscEnabled').checked = tc.settings.enabled;
  1224. dialog.querySelector('#vscRememberSpeed').checked = tc.settings.rememberSpeed;
  1225. dialog.querySelector('#vscForceLastSavedSpeed').checked = tc.settings.forceLastSavedSpeed;
  1226. dialog.querySelector('#vscAudioBoolean').checked = tc.settings.audioBoolean;
  1227. dialog.querySelector('#vscStartHidden').checked = tc.settings.startHidden;
  1228. dialog.querySelector('#vscControllerOpacity').value = tc.settings.controllerOpacity;
  1229. dialog.querySelector('#vscBlacklist').value = tc.settings.blacklist;
  1230. dialog.querySelector('#vscLogLevel').value = tc.settings.logLevel;
  1231.  
  1232. // Populate keybindings
  1233. const bindingsContainer = dialog.querySelector('#vscKeyBindingsContainer');
  1234. bindingsContainer.innerHTML = ''; // Clear previous bindings
  1235. tc.settings.keyBindings.forEach(binding => addKeyBindingRow(binding));
  1236. }
  1237.  
  1238. function addKeyBindingRow(binding = null) {
  1239. const dialog = document.getElementById('vscSettingsDialog');
  1240. if (!dialog) return;
  1241. const container = dialog.querySelector('#vscKeyBindingsContainer');
  1242.  
  1243. const row = document.createElement('div');
  1244. row.className = 'vsc-keybinding-row';
  1245. if (binding?.predefined) {
  1246. row.dataset.predefined = true; // Mark predefined for potential styling/logic
  1247. }
  1248.  
  1249. const actionOptions = `
  1250. <option value="display" ${binding?.action === 'display' ? 'selected' : ''}>Show/hide controller</option>
  1251. <option value="slower" ${binding?.action === 'slower' ? 'selected' : ''}>Decrease speed</option>
  1252. <option value="faster" ${binding?.action === 'faster' ? 'selected' : ''}>Increase speed</option>
  1253. <option value="rewind" ${binding?.action === 'rewind' ? 'selected' : ''}>Rewind</option>
  1254. <option value="advance" ${binding?.action === 'advance' ? 'selected' : ''}>Advance</option>
  1255. <option value="reset" ${binding?.action === 'reset' ? 'selected' : ''}>Reset/Toggle speed</option>
  1256. <option value="fast" ${binding?.action === 'fast' ? 'selected' : ''}>Preferred speed</option>
  1257. <option value="muted" ${binding?.action === 'muted' ? 'selected' : ''}>Mute/Unmute</option>
  1258. <option value="pause" ${binding?.action === 'pause' ? 'selected' : ''}>Play/Pause</option>
  1259. <option value="mark" ${binding?.action === 'mark' ? 'selected' : ''}>Set marker</option>
  1260. <option value="jump" ${binding?.action === 'jump' ? 'selected' : ''}>Jump to marker</option>
  1261. `;
  1262. const forceOptions = `
  1263. <option value="false" ${binding?.force?.toString() === 'false' ? 'selected': ''}>Allow website keybind</option>
  1264. <option value="true" ${binding?.force?.toString() === 'true' ? 'selected': ''}>Disable website keybind</option>
  1265. `;
  1266.  
  1267. const keyText = binding?.key ? (keyCodeAliases[binding.key] || String.fromCharCode(binding.key)) : '';
  1268. const isValueDisabled = binding ? customActionsNoValues.includes(binding.action) : false;
  1269.  
  1270. row.innerHTML = `
  1271. <select class="vsc-binding-action" ${binding?.predefined ? 'disabled' : ''}>${actionOptions}</select>
  1272. <input class="vsc-binding-key" type="text" value="${keyText}" placeholder="press a key">
  1273. <input class="vsc-binding-value" type="number" step="0.1" value="${binding?.value || 0}" placeholder="value" ${isValueDisabled ? 'disabled' : ''}>
  1274. <select class="vsc-binding-force">${forceOptions}</select>
  1275. <button class="vsc-remove-binding" ${binding?.predefined ? 'disabled style="visibility:hidden;"' : ''} title="Remove shortcut">X</button>
  1276. `;
  1277.  
  1278. // Store the actual keycode on the input element
  1279. const keyInput = row.querySelector('.vsc-binding-key');
  1280. if (binding?.key) keyInput.keyCode = binding.key;
  1281.  
  1282. container.appendChild(row);
  1283. }
  1284.  
  1285. function removeKeyBindingRow(button) {
  1286. button.closest('.vsc-keybinding-row').remove();
  1287. }
  1288.  
  1289. function toggleValueInputDisabled(selectElement) {
  1290. const valueInput = selectElement.closest('.vsc-keybinding-row').querySelector('.vsc-binding-value');
  1291. const action = selectElement.value;
  1292. if (customActionsNoValues.includes(action)) {
  1293. valueInput.disabled = true;
  1294. valueInput.value = 0; // Reset value for actions that don't use it
  1295. } else {
  1296. valueInput.disabled = false;
  1297. // Optionally set a default value based on action?
  1298. // if (action === 'slower' || action === 'faster') valueInput.value = 0.1;
  1299. // if (action === 'rewind' || action === 'advance') valueInput.value = 10;
  1300. // if (action === 'fast') valueInput.value = 1.8;
  1301. }
  1302. }
  1303.  
  1304. function validateSettingsDialog() {
  1305. // Basic validation (e.g., check regex in blacklist)
  1306. const dialog = document.getElementById('vscSettingsDialog');
  1307. const blacklistText = dialog.querySelector('#vscBlacklist').value;
  1308. const status = dialog.querySelector('#vscStatus');
  1309. let isValid = true;
  1310.  
  1311. blacklistText.split('\n').forEach(match => {
  1312. match = match.replace(regStrip, "");
  1313. if (match.startsWith('/') && match.endsWith('/')) {
  1314. try { new RegExp(match.slice(1, -1)); }
  1315. catch (err) {
  1316. status.textContent = `Error: Invalid blacklist regex: ${match}`;
  1317. status.style.color = 'red';
  1318. isValid = false;
  1319. }
  1320. }
  1321. });
  1322. // Validate opacity
  1323. const opacity = Number(dialog.querySelector('#vscControllerOpacity').value);
  1324. if (isNaN(opacity) || opacity < 0 || opacity > 1) {
  1325. status.textContent = `Error: Opacity must be between 0.0 and 1.0`;
  1326. status.style.color = 'red';
  1327. isValid = false;
  1328. }
  1329.  
  1330. // Validate Keybindings (ensure key is set, etc.)
  1331. dialog.querySelectorAll('.vsc-keybinding-row').forEach((row, index) => {
  1332. const keyInput = row.querySelector('.vsc-binding-key');
  1333. if (!keyInput.keyCode && keyInput.value !== 'null') {
  1334. status.textContent = `Error: Key not set for shortcut #${index + 1}`;
  1335. status.style.color = 'red';
  1336. isValid = false;
  1337. }
  1338. // Check for duplicate keys?
  1339. });
  1340.  
  1341.  
  1342. if (isValid) {
  1343. status.textContent = ''; // Clear error
  1344. return true;
  1345. }
  1346. return false;
  1347. }
  1348.  
  1349.  
  1350. async function saveSettingsFromDialog() {
  1351. const dialog = document.getElementById('vscSettingsDialog');
  1352. if (!dialog || !validateSettingsDialog()) return;
  1353.  
  1354. const status = dialog.querySelector('#vscStatus');
  1355. status.textContent = "Saving...";
  1356. status.style.color = 'orange';
  1357.  
  1358. // Read values from dialog
  1359. tc.settings.enabled = dialog.querySelector('#vscEnabled').checked;
  1360. tc.settings.rememberSpeed = dialog.querySelector('#vscRememberSpeed').checked;
  1361. tc.settings.forceLastSavedSpeed = dialog.querySelector('#vscForceLastSavedSpeed').checked;
  1362. tc.settings.audioBoolean = dialog.querySelector('#vscAudioBoolean').checked;
  1363. tc.settings.startHidden = dialog.querySelector('#vscStartHidden').checked;
  1364. tc.settings.controllerOpacity = Number(dialog.querySelector('#vscControllerOpacity').value);
  1365. tc.settings.blacklist = dialog.querySelector('#vscBlacklist').value;
  1366. tc.settings.logLevel = Number(dialog.querySelector('#vscLogLevel').value);
  1367.  
  1368. // Read keybindings
  1369. const newBindings = [];
  1370. dialog.querySelectorAll('.vsc-keybinding-row').forEach(row => {
  1371. const keyInput = row.querySelector('.vsc-binding-key');
  1372. newBindings.push({
  1373. action: row.querySelector('.vsc-binding-action').value,
  1374. key: keyInput.keyCode || null, // Get stored keyCode
  1375. value: Number(row.querySelector('.vsc-binding-value').value) || 0,
  1376. force: row.querySelector('.vsc-binding-force').value, // Store as string 'true'/'false'
  1377. predefined: row.dataset.predefined === 'true'
  1378. });
  1379. });
  1380. tc.settings.keyBindings = newBindings;
  1381.  
  1382. try {
  1383. await saveSettings(); // Use the async save function
  1384. status.textContent = "Settings Saved! Reload page to apply some changes (e.g., blacklist, audio).";
  1385. status.style.color = 'green';
  1386. setTimeout(() => {
  1387. dialog.remove(); // Close dialog after successful save
  1388. // Dynamically update running script? (e.g., attach/detach controllers if enabled changed)
  1389. // For simplicity, we often require a reload for major changes.
  1390. location.reload(); // Force reload to ensure all settings apply cleanly
  1391. }, 1500);
  1392. } catch (e) {
  1393. status.textContent = "Error saving settings: " + e;
  1394. status.style.color = 'red';
  1395. }
  1396. }
  1397.  
  1398. async function restoreDefaultSettingsInDialog() {
  1399. if (!confirm("Are you sure you want to restore default settings? All customizations will be lost.")) {
  1400. return;
  1401. }
  1402. const dialog = document.getElementById('vscSettingsDialog');
  1403. const status = dialog.querySelector('#vscStatus');
  1404. status.textContent = "Restoring defaults...";
  1405. status.style.color = 'orange';
  1406.  
  1407. // Reset tc.settings to defaults
  1408. tc.settings = JSON.parse(JSON.stringify(tcDefaults)); // Deep copy defaults
  1409.  
  1410. // Repopulate the dialog with the new default settings
  1411. populateSettingsDialog();
  1412.  
  1413. status.textContent = "Defaults restored. Click Save to keep them.";
  1414. status.style.color = 'blue';
  1415. }
  1416.  
  1417. function toggleScriptEnabled(enable) {
  1418. log(`Setting script enabled status to: ${enable}`, 4);
  1419. tc.settings.enabled = enable;
  1420. saveSettings().then(() => {
  1421. alert(`Video Speed Controller ${enable ? 'Enabled' : 'Disabled'}. Please reload the page for the change to take full effect.`);
  1422. location.reload(); // Force reload
  1423. });
  1424. }
  1425.  
  1426.  
  1427. // --- Main Execution ---
  1428.  
  1429. async function main() {
  1430. await loadSettings();
  1431.  
  1432. // Register GM Menu Commands
  1433. GM_registerMenuCommand("VSC Settings", showSettingsDialog);
  1434. if (tc.settings.enabled) {
  1435. GM_registerMenuCommand("Disable VSC (Reload Required)", () => toggleScriptEnabled(false));
  1436. } else {
  1437. GM_registerMenuCommand("Enable VSC (Reload Required)", () => toggleScriptEnabled(true));
  1438. }
  1439. // Add more commands? e.g. quick toggle remember speed?
  1440.  
  1441. // Start initialization process if enabled and not blacklisted
  1442. initializeWhenReady(document);
  1443. }
  1444.  
  1445. // --- Run Main ---
  1446. main().catch(e => {
  1447. log("Critical error during script initialization: " + e, 2);
  1448. console.error("VSC Userscript Error:", e);
  1449. });
  1450.  
  1451. })(); // End IIFE

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址