├── .gitignore ├── .pre-commit-config.yaml ├── .prettierrc ├── README.md ├── icons ├── icon128.png ├── icon16.png ├── icon19.png ├── icon19_disabled.png ├── icon38.png ├── icon38_disabled.png ├── icon48.png └── icon48_disabled.png ├── inject.css ├── inject.js ├── manifest.json ├── options.css ├── options.html ├── options.js ├── popup.css ├── popup.html ├── popup.js └── shadow.css /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | local 3 | 4 | # IntelliJ IDEA 5 | .idea/ 6 | node_modules 7 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v2.5.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | - id: check-yaml 10 | - id: check-added-large-files 11 | 12 | - repo: https://github.com/prettier/prettier 13 | rev: 1.19.1 # Use the sha or tag you want to point at 14 | hooks: 15 | - id: prettier 16 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "tabWidth": 2, 4 | "printWidth": 80, 5 | "semi": true, 6 | "endOfLine": "auto", 7 | "proseWrap": "always" 8 | } 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # The science of accelerated playback 2 | 3 | **TL;DR: faster playback translates to better engagement and retention.** 4 | 5 | Average adult reads prose text at 6 | [250 to 300 words per minute](http://www.paperbecause.com/PIOP/files/f7/f7bb6bc5-2c4a-466f-9ae7-b483a2c0dca4.pdf) 7 | (wpm). By contrast, the average rate of speech for English speakers is ~150 wpm, 8 | with slide presentations often closer to 100 wpm. As a result, when given the 9 | choice, many viewers 10 | [speed up video playback to ~1.3\~1.5 its recorded rate](http://research.microsoft.com/en-us/um/redmond/groups/coet/compression/chi99/paper.pdf) 11 | to compensate for the difference. 12 | 13 | Many viewers report that 14 | [accelerated viewing keeps their attention longer](http://www.enounce.com/docs/BYUPaper020319.pdf): 15 | faster delivery keeps the viewer more engaged with the content. In fact, with a 16 | little training many end up watching videos at 2x+ the recorded speed. Some 17 | studies report that after being exposed to accelerated playback, 18 | [listeners become uncomfortable](http://alumni.media.mit.edu/~barons/html/avios92.html#beasleyalteredspeech) 19 | if they are forced to return to normal rate of presentation. 20 | 21 | ## Faster HTML5 Video 22 | 23 | HTML5 video provides a native API to accelerate playback of any video. The 24 | problem is, many players either hide, or limit this functionality. For best 25 | results playback speed adjustments should be easy and frequent to match the pace 26 | and content being covered: we don't read at a fixed speed, and similarly, we 27 | need an easy way to accelerate the video, slow it down, and quickly rewind the 28 | last point to listen to it a few more times. 29 | 30 | ![Player](https://cloud.githubusercontent.com/assets/2400185/24076745/5723e6ae-0c41-11e7-820c-1d8e814a2888.png) 31 | 32 | #### *Install [Chrome](https://chrome.google.com/webstore/detail/video-speed-controller/nffaoalbilbmmfgbnbgppjihopabppdk) or [Firefox](https://addons.mozilla.org/en-us/firefox/addon/videospeed/) Extension* 33 | 34 | \*\* Once the extension is installed simply navigate to any page that offers 35 | HTML5 video ([example](http://www.youtube.com/watch?v=E9FxNzv1Tr8)), and you'll 36 | see a speed indicator in top left corner. Hover over the indicator to reveal the 37 | controls to accelerate, slowdown, and quickly rewind or advance the video. Or, 38 | even better, simply use your keyboard: 39 | 40 | - **S** - decrease playback speed. 41 | - **D** - increase playback speed. 42 | - **R** - reset playback speed to 1.0x. 43 | - **Z** - rewind video by 10 seconds. 44 | - **X** - advance video by 10 seconds. 45 | - **G** - toggle between current and user configurable preferred speed. 46 | - **V** - show/hide the controller. 47 | 48 | You can customize and reassign the default shortcut keys in the extensions 49 | settings page, as well as add additional shortcut keys to match your 50 | preferences. For example, you can assign multiple different "preferred speed" 51 | shortcuts with different values, which will allow you to quickly toggle between 52 | your most commonly used speeds. To add a new shortcut, open extension settings 53 | and click "Add New". 54 | 55 | ![settings Add New shortcut](https://user-images.githubusercontent.com/121805/50726471-50242200-1172-11e9-902f-0e5958387617.jpg) 56 | 57 | Some sites may assign other functionality to one of the assigned shortcut keys — 58 | these collisions are inevitable, unfortunately. As a workaround, the extension 59 | listens both for lower and upper case values (i.e. you can use 60 | `Shift-`) if there is other functionality assigned to the lowercase 61 | key. This is not a perfect solution, as some sites may listen to both, but works 62 | most of the time. 63 | 64 | ### FAQ 65 | 66 | **The video controls are not showing up?** This extension is only compatible 67 | with HTML5 video. If you don't see the controls showing up, chances are you are 68 | viewing a Flash video. If you want to confirm, try right-clicking on the video 69 | and inspect the menu: if it mentions flash, then that's the issue. That said, 70 | most sites will fallback to HTML5 if they detect that Flash is not available. 71 | You can try manually disabling Flash from the browser. 72 | 73 | **What is this fork of `igrigorik/videospeed` all about?** This fork of the 74 | [`igrigorik/videospeed`](https://github.com/igrigorik/videospeed) repository 75 | is a port of [`igrigorik`](https://github.com/igrigorik)'s videospeed Chrome 76 | add-on for Firefox. This fork modifies the Chrome add-on code so that it works 77 | in Firefox. This repo is the code behind the [Firefox Extension](https://addons.mozilla.org/en-us/firefox/addon/videospeed/) 78 | whereas the [`igrigorik/videospeed`](https://github.com/igrigorik/videospeed) 79 | repository contains the code behind the [Chrome Extension](https://chrome.google.com/webstore/detail/video-speed-controller/nffaoalbilbmmfgbnbgppjihopabppdk). 80 | 81 | ### License 82 | 83 | (MIT License) - Copyright (c) 2014 Ilya Grigorik 84 | -------------------------------------------------------------------------------- /icons/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codebicycle/videospeed/da0eaad79ef125bf3a4e70da392cdd9b14f49f5c/icons/icon128.png -------------------------------------------------------------------------------- /icons/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codebicycle/videospeed/da0eaad79ef125bf3a4e70da392cdd9b14f49f5c/icons/icon16.png -------------------------------------------------------------------------------- /icons/icon19.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codebicycle/videospeed/da0eaad79ef125bf3a4e70da392cdd9b14f49f5c/icons/icon19.png -------------------------------------------------------------------------------- /icons/icon19_disabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codebicycle/videospeed/da0eaad79ef125bf3a4e70da392cdd9b14f49f5c/icons/icon19_disabled.png -------------------------------------------------------------------------------- /icons/icon38.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codebicycle/videospeed/da0eaad79ef125bf3a4e70da392cdd9b14f49f5c/icons/icon38.png -------------------------------------------------------------------------------- /icons/icon38_disabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codebicycle/videospeed/da0eaad79ef125bf3a4e70da392cdd9b14f49f5c/icons/icon38_disabled.png -------------------------------------------------------------------------------- /icons/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codebicycle/videospeed/da0eaad79ef125bf3a4e70da392cdd9b14f49f5c/icons/icon48.png -------------------------------------------------------------------------------- /icons/icon48_disabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codebicycle/videospeed/da0eaad79ef125bf3a4e70da392cdd9b14f49f5c/icons/icon48_disabled.png -------------------------------------------------------------------------------- /inject.css: -------------------------------------------------------------------------------- 1 | .vsc-nosource { 2 | display: none !important; 3 | } 4 | .vsc-hidden { 5 | display: none !important; 6 | } 7 | .vsc-manual { 8 | visibility: visible !important; 9 | opacity: 1 !important; 10 | } 11 | 12 | .vsc-controller { 13 | /* In case of pages using `white-space: pre-line` (eg Discord), don't render vsc's whitespace */ 14 | white-space: normal; 15 | } 16 | 17 | /* Origin specific overrides */ 18 | /* YouTube player */ 19 | .ytp-hide-info-bar .vsc-controller { 20 | position: relative; 21 | top: 10px; 22 | } 23 | 24 | .ytp-autohide .vsc-controller { 25 | visibility: hidden; 26 | transition: opacity 0.25s cubic-bezier(0.4, 0, 0.2, 1); 27 | opacity: 0; 28 | } 29 | 30 | .ytp-autohide .vcs-show { 31 | visibility: visible; 32 | opacity: 1; 33 | } 34 | 35 | /* YouTube embedded player */ 36 | /* e.g. https://www.igvita.com/2012/09/12/web-fonts-performance-making-pretty-fast/ */ 37 | .html5-video-player:not(.ytp-hide-info-bar) .vsc-controller { 38 | position: relative; 39 | top: 60px; 40 | } 41 | 42 | /* Facebook player */ 43 | #facebook .vsc-controller { 44 | position: relative; 45 | top: 40px; 46 | } 47 | 48 | /* Google Photos player */ 49 | /* Inline preview doesn't have any additional hooks, relying on Aria label */ 50 | a[aria-label^="Video"] .vsc-controller { 51 | position: relative; 52 | top: 35px; 53 | } 54 | /* Google Photos full-screen view */ 55 | #player .house-brand .vsc-controller { 56 | position: relative; 57 | top: 50px; 58 | } 59 | 60 | /* Netflix player */ 61 | #netflix-player:not(.player-cinema-mode) .vsc-controller { 62 | position: relative; 63 | top: 85px; 64 | } 65 | 66 | /* shift controller on vine.co */ 67 | /* e.g. https://vine.co/v/OrJj39YlL57 */ 68 | .video-container .vine-video-container .vsc-controller { 69 | margin-left: 40px; 70 | } 71 | 72 | /* shift YT 3D controller down */ 73 | /* e.g. https://www.youtube.com/watch?v=erftYPflJzQ */ 74 | .ytp-webgl-spherical-control { 75 | top: 60px !important; 76 | } 77 | 78 | .ytp-fullscreen .ytp-webgl-spherical-control { 79 | top: 100px !important; 80 | } 81 | 82 | /* disable Vimeo video overlay */ 83 | div.video-wrapper + div.target { 84 | height: 0; 85 | } 86 | 87 | /* Fix black overlay on Kickstarter */ 88 | div.video-player.has_played.vertically_center:before, 89 | div.legacy-video-player.has_played.vertically_center:before { 90 | content: none !important; 91 | } 92 | 93 | /* Fix black overlay on openai.com */ 94 | .Shared-Video-player > .vsc-controller { 95 | height: 0; 96 | } 97 | -------------------------------------------------------------------------------- /inject.js: -------------------------------------------------------------------------------- 1 | var regStrip = /^[\r\t\f\v ]+|[\r\t\f\v ]+$/gm; 2 | 3 | var tc = { 4 | settings: { 5 | lastSpeed: 1.0, // default 1x 6 | enabled: true, // default enabled 7 | speeds: {}, // empty object to hold speed for each source 8 | 9 | displayKeyCode: 86, // default: V 10 | rememberSpeed: false, // default: false 11 | forceLastSavedSpeed: false, //default: false 12 | audioBoolean: false, // default: false 13 | startHidden: false, // default: false 14 | controllerOpacity: 0.3, // default: 0.3 15 | keyBindings: [], 16 | blacklist: `\ 17 | www.instagram.com 18 | twitter.com 19 | vine.co 20 | imgur.com 21 | teams.microsoft.com 22 | `.replace(regStrip, ""), 23 | defaultLogLevel: 4, 24 | logLevel: 3 25 | }, 26 | 27 | // Holds a reference to all of the AUDIO/VIDEO DOM elements we've attached to 28 | mediaElements: [] 29 | }; 30 | 31 | /* Log levels (depends on caller specifying the correct level) 32 | 1 - none 33 | 2 - error 34 | 3 - warning 35 | 4 - info 36 | 5 - debug 37 | 6 - debug high verbosity + stack trace on each message 38 | */ 39 | function log(message, level) { 40 | verbosity = tc.settings.logLevel; 41 | if (typeof level === "undefined") { 42 | level = tc.settings.defaultLogLevel; 43 | } 44 | if (verbosity >= level) { 45 | if (level === 2) { 46 | console.log("ERROR:" + message); 47 | } else if (level === 3) { 48 | console.log("WARNING:" + message); 49 | } else if (level === 4) { 50 | console.log("INFO:" + message); 51 | } else if (level === 5) { 52 | console.log("DEBUG:" + message); 53 | } else if (level === 6) { 54 | console.log("DEBUG (VERBOSE):" + message); 55 | console.trace(); 56 | } 57 | } 58 | } 59 | 60 | chrome.storage.sync.get(tc.settings, function (storage) { 61 | tc.settings.keyBindings = storage.keyBindings; // Array 62 | if (storage.keyBindings.length == 0) { 63 | // if first initialization of 0.5.3 64 | // UPDATE 65 | tc.settings.keyBindings.push({ 66 | action: "slower", 67 | key: Number(storage.slowerKeyCode) || 83, 68 | value: Number(storage.speedStep) || 0.1, 69 | force: false, 70 | predefined: true 71 | }); // default S 72 | tc.settings.keyBindings.push({ 73 | action: "faster", 74 | key: Number(storage.fasterKeyCode) || 68, 75 | value: Number(storage.speedStep) || 0.1, 76 | force: false, 77 | predefined: true 78 | }); // default: D 79 | tc.settings.keyBindings.push({ 80 | action: "rewind", 81 | key: Number(storage.rewindKeyCode) || 90, 82 | value: Number(storage.rewindTime) || 10, 83 | force: false, 84 | predefined: true 85 | }); // default: Z 86 | tc.settings.keyBindings.push({ 87 | action: "advance", 88 | key: Number(storage.advanceKeyCode) || 88, 89 | value: Number(storage.advanceTime) || 10, 90 | force: false, 91 | predefined: true 92 | }); // default: X 93 | tc.settings.keyBindings.push({ 94 | action: "reset", 95 | key: Number(storage.resetKeyCode) || 82, 96 | value: 1.0, 97 | force: false, 98 | predefined: true 99 | }); // default: R 100 | tc.settings.keyBindings.push({ 101 | action: "fast", 102 | key: Number(storage.fastKeyCode) || 71, 103 | value: Number(storage.fastSpeed) || 1.8, 104 | force: false, 105 | predefined: true 106 | }); // default: G 107 | tc.settings.version = "0.5.3"; 108 | 109 | chrome.storage.sync.set({ 110 | keyBindings: tc.settings.keyBindings, 111 | version: tc.settings.version, 112 | displayKeyCode: tc.settings.displayKeyCode, 113 | rememberSpeed: tc.settings.rememberSpeed, 114 | forceLastSavedSpeed: tc.settings.forceLastSavedSpeed, 115 | audioBoolean: tc.settings.audioBoolean, 116 | startHidden: tc.settings.startHidden, 117 | enabled: tc.settings.enabled, 118 | controllerOpacity: tc.settings.controllerOpacity, 119 | blacklist: tc.settings.blacklist.replace(regStrip, "") 120 | }); 121 | } 122 | tc.settings.lastSpeed = Number(storage.lastSpeed); 123 | tc.settings.displayKeyCode = Number(storage.displayKeyCode); 124 | tc.settings.rememberSpeed = Boolean(storage.rememberSpeed); 125 | tc.settings.forceLastSavedSpeed = Boolean(storage.forceLastSavedSpeed); 126 | tc.settings.audioBoolean = Boolean(storage.audioBoolean); 127 | tc.settings.enabled = Boolean(storage.enabled); 128 | tc.settings.startHidden = Boolean(storage.startHidden); 129 | tc.settings.controllerOpacity = Number(storage.controllerOpacity); 130 | tc.settings.blacklist = String(storage.blacklist); 131 | 132 | // ensure that there is a "display" binding (for upgrades from versions that had it as a separate binding) 133 | if ( 134 | tc.settings.keyBindings.filter((x) => x.action == "display").length == 0 135 | ) { 136 | tc.settings.keyBindings.push({ 137 | action: "display", 138 | key: Number(storage.displayKeyCode) || 86, 139 | value: 0, 140 | force: false, 141 | predefined: true 142 | }); // default V 143 | } 144 | 145 | initializeWhenReady(document); 146 | }); 147 | 148 | function getKeyBindings(action, what = "value") { 149 | try { 150 | return tc.settings.keyBindings.find((item) => item.action === action)[what]; 151 | } catch (e) { 152 | return false; 153 | } 154 | } 155 | 156 | function setKeyBindings(action, value) { 157 | tc.settings.keyBindings.find((item) => item.action === action)[ 158 | "value" 159 | ] = value; 160 | } 161 | 162 | function defineVideoController() { 163 | // Data structures 164 | // --------------- 165 | // videoController (JS object) instances: 166 | // video = AUDIO/VIDEO DOM element 167 | // parent = A/V DOM element's parentElement OR 168 | // (A/V elements discovered from the Mutation Observer) 169 | // A/V element's parentNode OR the node whose children changed. 170 | // div = Controller's DOM element (which happens to be a DIV) 171 | // speedIndicator = DOM element in the Controller of the speed indicator 172 | 173 | // added to AUDIO / VIDEO DOM elements 174 | // vsc = reference to the videoController 175 | tc.videoController = function (target, parent) { 176 | if (target.vsc) { 177 | return target.vsc; 178 | } 179 | 180 | tc.mediaElements.push(target); 181 | 182 | this.video = target; 183 | this.parent = target.parentElement || parent; 184 | storedSpeed = tc.settings.speeds[target.currentSrc]; 185 | if (!tc.settings.rememberSpeed) { 186 | if (!storedSpeed) { 187 | log( 188 | "Overwriting stored speed to 1.0 due to rememberSpeed being disabled", 189 | 5 190 | ); 191 | storedSpeed = 1.0; 192 | } 193 | setKeyBindings("reset", getKeyBindings("fast")); // resetSpeed = fastSpeed 194 | } else { 195 | log("Recalling stored speed due to rememberSpeed being enabled", 5); 196 | storedSpeed = tc.settings.lastSpeed; 197 | } 198 | 199 | log("Explicitly setting playbackRate to: " + storedSpeed, 5); 200 | target.playbackRate = storedSpeed; 201 | 202 | this.div = this.initializeControls(); 203 | 204 | var mediaEventAction = function (event) { 205 | storedSpeed = tc.settings.speeds[event.target.currentSrc]; 206 | if (!tc.settings.rememberSpeed) { 207 | if (!storedSpeed) { 208 | log("Overwriting stored speed to 1.0 (rememberSpeed not enabled)", 4); 209 | storedSpeed = 1.0; 210 | } 211 | // resetSpeed isn't really a reset, it's a toggle 212 | log("Setting reset keybinding to fast", 5); 213 | setKeyBindings("reset", getKeyBindings("fast")); // resetSpeed = fastSpeed 214 | } else { 215 | log( 216 | "Storing lastSpeed into tc.settings.speeds (rememberSpeed enabled)", 217 | 5 218 | ); 219 | storedSpeed = tc.settings.lastSpeed; 220 | } 221 | // TODO: Check if explicitly setting the playback rate to 1.0 is 222 | // necessary when rememberSpeed is disabled (this may accidentally 223 | // override a website's intentional initial speed setting interfering 224 | // with the site's default behavior) 225 | log("Explicitly setting playbackRate to: " + storedSpeed, 4); 226 | setSpeed(event.target, storedSpeed); 227 | }; 228 | 229 | target.addEventListener( 230 | "play", 231 | (this.handlePlay = mediaEventAction.bind(this)) 232 | ); 233 | 234 | target.addEventListener( 235 | "seeked", 236 | (this.handleSeek = mediaEventAction.bind(this)) 237 | ); 238 | 239 | var observer = new MutationObserver((mutations) => { 240 | mutations.forEach((mutation) => { 241 | if ( 242 | mutation.type === "attributes" && 243 | (mutation.attributeName === "src" || 244 | mutation.attributeName === "currentSrc") 245 | ) { 246 | log("mutation of A/V element", 5); 247 | var controller = this.div; 248 | if (!mutation.target.src && !mutation.target.currentSrc) { 249 | controller.classList.add("vsc-nosource"); 250 | } else { 251 | controller.classList.remove("vsc-nosource"); 252 | } 253 | } 254 | }); 255 | }); 256 | observer.observe(target, { 257 | attributeFilter: ["src", "currentSrc"] 258 | }); 259 | }; 260 | 261 | tc.videoController.prototype.remove = function () { 262 | this.div.remove(); 263 | this.video.removeEventListener("play", this.handlePlay); 264 | this.video.removeEventListener("seek", this.handleSeek); 265 | delete this.video.vsc; 266 | let idx = tc.mediaElements.indexOf(this.video); 267 | if (idx != -1) { 268 | tc.mediaElements.splice(idx, 1); 269 | } 270 | }; 271 | 272 | tc.videoController.prototype.initializeControls = function () { 273 | log("initializeControls Begin", 5); 274 | const document = this.video.ownerDocument; 275 | const speed = this.video.playbackRate.toFixed(2); 276 | var top = Math.max(this.video.offsetTop, 0) + "px", 277 | left = Math.max(this.video.offsetLeft, 0) + "px"; 278 | 279 | log("Speed variable set to: " + speed, 5); 280 | 281 | var wrapper = document.createElement("div"); 282 | wrapper.classList.add("vsc-controller"); 283 | 284 | if (!this.video.src && !this.video.currentSrc) { 285 | wrapper.classList.add("vsc-nosource"); 286 | } 287 | 288 | if (tc.settings.startHidden) { 289 | wrapper.classList.add("vsc-hidden"); 290 | } 291 | 292 | var shadow = wrapper.attachShadow({ mode: "open" }); 293 | var shadowTemplate = ` 294 | 297 | 298 |
301 | ${speed} 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 |
310 | `; 311 | shadow.innerHTML = shadowTemplate; 312 | shadow.querySelector(".draggable").addEventListener( 313 | "mousedown", 314 | (e) => { 315 | runAction(e.target.dataset["action"], false, e); 316 | e.stopPropagation(); 317 | }, 318 | true 319 | ); 320 | 321 | shadow.querySelectorAll("button").forEach(function (button) { 322 | button.addEventListener( 323 | "click", 324 | (e) => { 325 | runAction( 326 | e.target.dataset["action"], 327 | getKeyBindings(e.target.dataset["action"]), 328 | e 329 | ); 330 | e.stopPropagation(); 331 | }, 332 | true 333 | ); 334 | }); 335 | 336 | shadow 337 | .querySelector("#controller") 338 | .addEventListener("click", (e) => e.stopPropagation(), false); 339 | shadow 340 | .querySelector("#controller") 341 | .addEventListener("mousedown", (e) => e.stopPropagation(), false); 342 | 343 | this.speedIndicator = shadow.querySelector("span"); 344 | var fragment = document.createDocumentFragment(); 345 | fragment.appendChild(wrapper); 346 | 347 | switch (true) { 348 | case location.hostname == "www.amazon.com": 349 | case location.hostname == "www.reddit.com": 350 | case /hbogo\./.test(location.hostname): 351 | // insert before parent to bypass overlay 352 | this.parent.parentElement.insertBefore(fragment, this.parent); 353 | break; 354 | case location.hostname == "www.facebook.com": 355 | // this is a monstrosity but new FB design does not have *any* 356 | // semantic handles for us to traverse the tree, and deep nesting 357 | // that we need to bubble up from to get controller to stack correctly 358 | let p = this.parent.parentElement.parentElement.parentElement 359 | .parentElement.parentElement.parentElement.parentElement; 360 | p.insertBefore(fragment, p.firstChild); 361 | break; 362 | case location.hostname == "tv.apple.com": 363 | // insert after parent for correct stacking context 364 | this.parent.getRootNode().querySelector(".scrim").prepend(fragment); 365 | default: 366 | // Note: when triggered via a MutationRecord, it's possible that the 367 | // target is not the immediate parent. This appends the controller as 368 | // the first element of the target, which may not be the parent. 369 | this.parent.insertBefore(fragment, this.parent.firstChild); 370 | } 371 | return wrapper; 372 | }; 373 | } 374 | 375 | function escapeStringRegExp(str) { 376 | matchOperatorsRe = /[|\\{}()[\]^$+*?.]/g; 377 | return str.replace(matchOperatorsRe, "\\$&"); 378 | } 379 | 380 | function isBlacklisted() { 381 | blacklisted = false; 382 | tc.settings.blacklist.split("\n").forEach((match) => { 383 | match = match.replace(regStrip, ""); 384 | if (match.length == 0) { 385 | return; 386 | } 387 | 388 | if (match.startsWith("/")) { 389 | try { 390 | var regexp = new RegExp(match); 391 | } catch (err) { 392 | return; 393 | } 394 | } else { 395 | var regexp = new RegExp(escapeStringRegExp(match)); 396 | } 397 | 398 | if (regexp.test(location.href)) { 399 | blacklisted = true; 400 | return; 401 | } 402 | }); 403 | return blacklisted; 404 | } 405 | 406 | var coolDown = false; 407 | function refreshCoolDown() { 408 | log("Begin refreshCoolDown", 5); 409 | if (coolDown) { 410 | clearTimeout(coolDown); 411 | } 412 | coolDown = setTimeout(function () { 413 | coolDown = false; 414 | }, 1000); 415 | log("End refreshCoolDown", 5); 416 | } 417 | 418 | function setupListener() { 419 | /** 420 | * This function is run whenever a video speed rate change occurs. 421 | * It is used to update the speed that shows up in the display as well as save 422 | * that latest speed into the local storage. 423 | * 424 | * @param {*} video The video element to update the speed indicators for. 425 | */ 426 | function updateSpeedFromEvent(video) { 427 | // It's possible to get a rate change on a VIDEO/AUDIO that doesn't have 428 | // a video controller attached to it. If we do, ignore it. 429 | if (!video.vsc) 430 | return; 431 | var speedIndicator = video.vsc.speedIndicator; 432 | var src = video.currentSrc; 433 | var speed = Number(video.playbackRate.toFixed(2)); 434 | 435 | log("Playback rate changed to " + speed, 4); 436 | 437 | log("Updating controller with new speed", 5); 438 | speedIndicator.textContent = speed.toFixed(2); 439 | tc.settings.speeds[src] = speed; 440 | log("Storing lastSpeed in settings for the rememberSpeed feature", 5); 441 | tc.settings.lastSpeed = speed; 442 | log("Syncing chrome settings for lastSpeed", 5); 443 | chrome.storage.sync.set({ lastSpeed: speed }, function () { 444 | log("Speed setting saved: " + speed, 5); 445 | }); 446 | // show the controller for 1000ms if it's hidden. 447 | runAction("blink", null, null); 448 | } 449 | 450 | document.addEventListener( 451 | "ratechange", 452 | function (event) { 453 | if (coolDown) { 454 | log("Speed event propagation blocked", 4); 455 | event.stopImmediatePropagation(); 456 | } 457 | var video = event.target; 458 | 459 | /** 460 | * If the last speed is forced, only update the speed based on events created by 461 | * video speed instead of all video speed change events. 462 | */ 463 | if (tc.settings.forceLastSavedSpeed) { 464 | if (event.detail && event.detail.origin === "videoSpeed") { 465 | video.playbackRate = event.detail.speed; 466 | updateSpeedFromEvent(video); 467 | } else { 468 | video.playbackRate = tc.settings.lastSpeed; 469 | } 470 | event.stopImmediatePropagation(); 471 | } else { 472 | updateSpeedFromEvent(video); 473 | } 474 | }, 475 | true 476 | ); 477 | } 478 | 479 | function initializeWhenReady(document) { 480 | log("Begin initializeWhenReady", 5); 481 | if (isBlacklisted()) { 482 | return; 483 | } 484 | window.addEventListener('load', () => { 485 | initializeNow(window.document); 486 | }); 487 | if (document) { 488 | if (document.readyState === "complete") { 489 | initializeNow(document); 490 | } else { 491 | document.onreadystatechange = () => { 492 | if (document.readyState === "complete") { 493 | initializeNow(document); 494 | } 495 | }; 496 | } 497 | } 498 | log("End initializeWhenReady", 5); 499 | } 500 | function inIframe() { 501 | try { 502 | return window.self !== window.top; 503 | } catch (e) { 504 | return true; 505 | } 506 | } 507 | function getShadow(parent) { 508 | let result = []; 509 | function getChild(parent) { 510 | if (parent.firstElementChild) { 511 | var child = parent.firstElementChild; 512 | do { 513 | result.push(child); 514 | getChild(child); 515 | if (child.shadowRoot) { 516 | result.push(getShadow(child.shadowRoot)); 517 | } 518 | child = child.nextElementSibling; 519 | } while (child); 520 | } 521 | } 522 | getChild(parent); 523 | return result.flat(Infinity); 524 | } 525 | 526 | function initializeNow(document) { 527 | log("Begin initializeNow", 5); 528 | if (!tc.settings.enabled) return; 529 | // enforce init-once due to redundant callers 530 | if (!document.body || document.body.classList.contains("vsc-initialized")) { 531 | return; 532 | } 533 | try { 534 | setupListener(); 535 | } catch { 536 | // no operation 537 | } 538 | document.body.classList.add("vsc-initialized"); 539 | log("initializeNow: vsc-initialized added to document body", 5); 540 | 541 | if (document === window.document) { 542 | defineVideoController(); 543 | } else { 544 | var link = document.createElement("link"); 545 | link.href = chrome.runtime.getURL("inject.css"); 546 | link.type = "text/css"; 547 | link.rel = "stylesheet"; 548 | document.head.appendChild(link); 549 | } 550 | var docs = Array(document); 551 | try { 552 | if (inIframe()) docs.push(window.top.document); 553 | } catch (e) {} 554 | 555 | docs.forEach(function (doc) { 556 | doc.addEventListener( 557 | "keydown", 558 | function (event) { 559 | var keyCode = event.keyCode; 560 | log("Processing keydown event: " + keyCode, 6); 561 | 562 | // Ignore if following modifier is active. 563 | if ( 564 | !event.getModifierState || 565 | event.getModifierState("Alt") || 566 | event.getModifierState("Control") || 567 | event.getModifierState("Fn") || 568 | event.getModifierState("Meta") || 569 | event.getModifierState("Hyper") || 570 | event.getModifierState("OS") 571 | ) { 572 | log("Keydown event ignored due to active modifier: " + keyCode, 5); 573 | return; 574 | } 575 | 576 | // Ignore keydown event if typing in an input box 577 | if ( 578 | event.target.nodeName === "INPUT" || 579 | event.target.nodeName === "TEXTAREA" || 580 | event.target.isContentEditable 581 | ) { 582 | return false; 583 | } 584 | 585 | // Ignore keydown event if typing in a page without vsc 586 | if (!tc.mediaElements.length) { 587 | return false; 588 | } 589 | 590 | var item = tc.settings.keyBindings.find((item) => item.key === keyCode); 591 | if (item) { 592 | runAction(item.action, item.value); 593 | if (item.force === "true") { 594 | // disable websites key bindings 595 | event.preventDefault(); 596 | event.stopPropagation(); 597 | } 598 | } 599 | 600 | return false; 601 | }, 602 | true 603 | ); 604 | }); 605 | 606 | function checkForVideo(node, parent, added) { 607 | // Only proceed with supposed removal if node is missing from DOM 608 | if (!added && document.body.contains(node)) { 609 | return; 610 | } 611 | if ( 612 | node.nodeName === "VIDEO" || 613 | (node.nodeName === "AUDIO" && tc.settings.audioBoolean) 614 | ) { 615 | if (added) { 616 | node.vsc = new tc.videoController(node, parent); 617 | } else { 618 | if (node.vsc) { 619 | node.vsc.remove(); 620 | } 621 | } 622 | } else if (node.children != undefined) { 623 | for (var i = 0; i < node.children.length; i++) { 624 | const child = node.children[i]; 625 | checkForVideo(child, child.parentNode || parent, added); 626 | } 627 | } 628 | } 629 | 630 | var observer = new MutationObserver(function (mutations) { 631 | // Process the DOM nodes lazily 632 | requestIdleCallback( 633 | (_) => { 634 | mutations.forEach(function (mutation) { 635 | switch (mutation.type) { 636 | case "childList": 637 | mutation.addedNodes.forEach(function (node) { 638 | if (typeof node === "function") return; 639 | checkForVideo(node, node.parentNode || mutation.target, true); 640 | }); 641 | mutation.removedNodes.forEach(function (node) { 642 | if (typeof node === "function") return; 643 | checkForVideo(node, node.parentNode || mutation.target, false); 644 | }); 645 | break; 646 | case "attributes": 647 | if ( 648 | mutation.target.attributes["aria-hidden"] && 649 | mutation.target.attributes["aria-hidden"].value == "false" 650 | ) { 651 | var flattenedNodes = getShadow(document.body); 652 | var node = flattenedNodes.filter( 653 | (x) => x.tagName == "VIDEO" 654 | )[0]; 655 | if (node) { 656 | if (node.vsc) 657 | node.vsc.remove(); 658 | checkForVideo(node, node.parentNode || mutation.target, true); 659 | } 660 | } 661 | break; 662 | } 663 | }); 664 | }, 665 | { timeout: 1000 } 666 | ); 667 | }); 668 | observer.observe(document, { 669 | attributeFilter: ["aria-hidden"], 670 | childList: true, 671 | subtree: true 672 | }); 673 | 674 | if (tc.settings.audioBoolean) { 675 | var mediaTags = document.querySelectorAll("video,audio"); 676 | } else { 677 | var mediaTags = document.querySelectorAll("video"); 678 | } 679 | 680 | mediaTags.forEach(function (video) { 681 | video.vsc = new tc.videoController(video); 682 | }); 683 | 684 | var frameTags = document.getElementsByTagName("iframe"); 685 | Array.prototype.forEach.call(frameTags, function (frame) { 686 | // Ignore frames we don't have permission to access (different origin). 687 | try { 688 | var childDocument = frame.contentDocument; 689 | } catch (e) { 690 | return; 691 | } 692 | initializeWhenReady(childDocument); 693 | }); 694 | log("End initializeNow", 5); 695 | } 696 | 697 | function setSpeed(video, speed) { 698 | log("setSpeed started: " + speed, 5); 699 | var speedvalue = speed.toFixed(2); 700 | if (tc.settings.forceLastSavedSpeed) { 701 | video.dispatchEvent( 702 | new CustomEvent("ratechange", { 703 | detail: { origin: "videoSpeed", speed: speedvalue } 704 | }) 705 | ); 706 | } else { 707 | video.playbackRate = Number(speedvalue); 708 | } 709 | var speedIndicator = video.vsc.speedIndicator; 710 | speedIndicator.textContent = speedvalue; 711 | tc.settings.lastSpeed = speed; 712 | refreshCoolDown(); 713 | log("setSpeed finished: " + speed, 5); 714 | } 715 | 716 | function runAction(action, value, e) { 717 | log("runAction Begin", 5); 718 | 719 | var mediaTags = tc.mediaElements; 720 | 721 | // Get the controller that was used if called from a button press event e 722 | if (e) { 723 | var targetController = e.target.getRootNode().host; 724 | } 725 | 726 | mediaTags.forEach(function (v) { 727 | var controller = v.vsc.div; 728 | 729 | // Don't change video speed if the video has a different controller 730 | if (e && !(targetController == controller)) { 731 | return; 732 | } 733 | 734 | showController(controller); 735 | 736 | if (!v.classList.contains("vsc-cancelled")) { 737 | if (action === "rewind") { 738 | log("Rewind", 5); 739 | v.currentTime -= value; 740 | } else if (action === "advance") { 741 | log("Fast forward", 5); 742 | v.currentTime += value; 743 | } else if (action === "faster") { 744 | log("Increase speed", 5); 745 | // Maximum playback speed in Chrome is set to 16: 746 | // https://cs.chromium.org/chromium/src/third_party/blink/renderer/core/html/media/html_media_element.cc?gsn=kMinRate&l=166 747 | var s = Math.min( 748 | (v.playbackRate < 0.1 ? 0.0 : v.playbackRate) + value, 749 | 16 750 | ); 751 | setSpeed(v, s); 752 | } else if (action === "slower") { 753 | log("Decrease speed", 5); 754 | // Video min rate is 0.0625: 755 | // https://cs.chromium.org/chromium/src/third_party/blink/renderer/core/html/media/html_media_element.cc?gsn=kMinRate&l=165 756 | var s = Math.max(v.playbackRate - value, 0.07); 757 | setSpeed(v, s); 758 | } else if (action === "reset") { 759 | log("Reset speed", 5); 760 | resetSpeed(v, 1.0); 761 | } else if (action === "display") { 762 | log("Showing controller", 5); 763 | controller.classList.add("vsc-manual"); 764 | controller.classList.toggle("vsc-hidden"); 765 | } else if (action === "blink") { 766 | log("Showing controller momentarily", 5); 767 | // if vsc is hidden, show it briefly to give the use visual feedback that the action is excuted. 768 | if ( 769 | controller.classList.contains("vsc-hidden") || 770 | controller.blinkTimeOut !== undefined 771 | ) { 772 | clearTimeout(controller.blinkTimeOut); 773 | controller.classList.remove("vsc-hidden"); 774 | controller.blinkTimeOut = setTimeout( 775 | () => { 776 | controller.classList.add("vsc-hidden"); 777 | controller.blinkTimeOut = undefined; 778 | }, 779 | value ? value : 1000 780 | ); 781 | } 782 | } else if (action === "drag") { 783 | handleDrag(v, e); 784 | } else if (action === "fast") { 785 | resetSpeed(v, value); 786 | } else if (action === "pause") { 787 | pause(v); 788 | } else if (action === "muted") { 789 | muted(v); 790 | } else if (action === "mark") { 791 | setMark(v); 792 | } else if (action === "jump") { 793 | jumpToMark(v); 794 | } 795 | } 796 | }); 797 | log("runAction End", 5); 798 | } 799 | 800 | function pause(v) { 801 | if (v.paused) { 802 | log("Resuming video", 5); 803 | v.play(); 804 | } else { 805 | log("Pausing video", 5); 806 | v.pause(); 807 | } 808 | } 809 | 810 | function resetSpeed(v, target) { 811 | if (v.playbackRate === target) { 812 | if (v.playbackRate === getKeyBindings("reset")) { 813 | if (target !== 1.0) { 814 | log("Resetting playback speed to 1.0", 4); 815 | setSpeed(v, 1.0); 816 | } else { 817 | log('Toggling playback speed to "fast" speed', 4); 818 | setSpeed(v, getKeyBindings("fast")); 819 | } 820 | } else { 821 | log('Toggling playback speed to "reset" speed', 4); 822 | setSpeed(v, getKeyBindings("reset")); 823 | } 824 | } else { 825 | log('Toggling playback speed to "reset" speed', 4); 826 | setKeyBindings("reset", v.playbackRate); 827 | setSpeed(v, target); 828 | } 829 | } 830 | 831 | function muted(v) { 832 | v.muted = v.muted !== true; 833 | } 834 | 835 | function setMark(v) { 836 | log("Adding marker", 5); 837 | v.vsc.mark = v.currentTime; 838 | } 839 | 840 | function jumpToMark(v) { 841 | log("Recalling marker", 5); 842 | if (v.vsc.mark && typeof v.vsc.mark === "number") { 843 | v.currentTime = v.vsc.mark; 844 | } 845 | } 846 | 847 | function handleDrag(video, e) { 848 | const controller = video.vsc.div; 849 | const shadowController = controller.shadowRoot.querySelector("#controller"); 850 | 851 | // Find nearest parent of same size as video parent. 852 | var parentElement = controller.parentElement; 853 | while ( 854 | parentElement.parentNode && 855 | parentElement.parentNode.offsetHeight === parentElement.offsetHeight && 856 | parentElement.parentNode.offsetWidth === parentElement.offsetWidth 857 | ) { 858 | parentElement = parentElement.parentNode; 859 | } 860 | 861 | video.classList.add("vcs-dragging"); 862 | shadowController.classList.add("dragging"); 863 | 864 | const initialMouseXY = [e.clientX, e.clientY]; 865 | const initialControllerXY = [ 866 | parseInt(shadowController.style.left), 867 | parseInt(shadowController.style.top) 868 | ]; 869 | 870 | const startDragging = (e) => { 871 | let style = shadowController.style; 872 | let dx = e.clientX - initialMouseXY[0]; 873 | let dy = e.clientY - initialMouseXY[1]; 874 | style.left = initialControllerXY[0] + dx + "px"; 875 | style.top = initialControllerXY[1] + dy + "px"; 876 | }; 877 | 878 | const stopDragging = () => { 879 | parentElement.removeEventListener("mousemove", startDragging); 880 | parentElement.removeEventListener("mouseup", stopDragging); 881 | parentElement.removeEventListener("mouseleave", stopDragging); 882 | 883 | shadowController.classList.remove("dragging"); 884 | video.classList.remove("vcs-dragging"); 885 | }; 886 | 887 | parentElement.addEventListener("mouseup", stopDragging); 888 | parentElement.addEventListener("mouseleave", stopDragging); 889 | parentElement.addEventListener("mousemove", startDragging); 890 | } 891 | 892 | var timer = null; 893 | function showController(controller) { 894 | log("Showing controller", 4); 895 | controller.classList.add("vcs-show"); 896 | 897 | if (timer) clearTimeout(timer); 898 | 899 | timer = setTimeout(function () { 900 | controller.classList.remove("vcs-show"); 901 | timer = false; 902 | log("Hiding controller", 5); 903 | }, 2000); 904 | } 905 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Video Speed Controller", 3 | "short_name": "videospeed", 4 | "version": "0.6.3.3", 5 | "manifest_version": 2, 6 | "description": "Speed up, slow down, advance and rewind HTML5 audio/video with shortcuts", 7 | "homepage_url": "https://github.com/codebicycle/videospeed", 8 | "browser_specific_settings": { 9 | "gecko": { 10 | "id": "{7be2ba16-0f1e-4d93-9ebc-5164397477a9}" 11 | } 12 | }, 13 | "icons": { 14 | "16": "icons/icon16.png", 15 | "48": "icons/icon48.png", 16 | "128": "icons/icon128.png" 17 | }, 18 | "permissions": ["storage"], 19 | "options_ui": { 20 | "page": "options.html", 21 | "open_in_tab": true 22 | }, 23 | "browser_action": { 24 | "default_icon": { 25 | "19": "icons/icon19.png", 26 | "38": "icons/icon38.png", 27 | "48": "icons/icon48.png" 28 | }, 29 | "default_popup": "popup.html" 30 | }, 31 | "content_scripts": [ 32 | { 33 | "all_frames": true, 34 | "matches": ["http://*/*", "https://*/*", "file:///*"], 35 | "match_about_blank": true, 36 | "exclude_matches": [ 37 | "https://plus.google.com/hangouts/*", 38 | "https://hangouts.google.com/*", 39 | "https://meet.google.com/*" 40 | ], 41 | "css": ["inject.css"], 42 | "js": ["inject.js"] 43 | } 44 | ], 45 | "web_accessible_resources": ["inject.css", "shadow.css"] 46 | } 47 | -------------------------------------------------------------------------------- /options.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding-left: 15px; 4 | padding-top: 53px; 5 | font-family: sans-serif; 6 | font-size: 12px; 7 | color: rgb(48, 57, 66); 8 | } 9 | 10 | h1, 11 | h2, 12 | h3 { 13 | font-weight: normal; 14 | line-height: 1; 15 | user-select: none; 16 | cursor: default; 17 | } 18 | h1 { 19 | font-size: 1.5em; 20 | margin: 21px 0 13px; 21 | } 22 | h3 { 23 | font-size: 1.2em; 24 | margin-bottom: 0.8em; 25 | color: black; 26 | } 27 | p { 28 | margin: 0.65em 0; 29 | } 30 | 31 | header { 32 | position: fixed; 33 | top: 0; 34 | left: 15px; 35 | right: 0; 36 | border-bottom: 1px solid #eee; 37 | background: linear-gradient(white, white 40%, rgba(255, 255, 255, 0.92)); 38 | } 39 | header, 40 | section { 41 | min-width: 600px; 42 | max-width: 738px; 43 | } 44 | section { 45 | padding-left: 18px; 46 | margin-top: 8px; 47 | margin-bottom: 24px; 48 | } 49 | section h3 { 50 | margin-left: -18px; 51 | } 52 | 53 | button { 54 | -webkit-appearance: none; 55 | position: relative; 56 | 57 | margin: 0 1px 0 0; 58 | padding: 0 10px; 59 | min-width: 4em; 60 | min-height: 2em; 61 | 62 | background-image: linear-gradient(#ededed, #ededed 38%, #dedede); 63 | border: 1px solid rgba(0, 0, 0, 0.25); 64 | border-radius: 2px; 65 | outline: none; 66 | box-shadow: 0 1px 0 rgba(0, 0, 0, 0.08), 67 | inset 0 1px 2px rgba(255, 255, 255, 0.75); 68 | color: #444; 69 | text-shadow: 0 1px 0 rgb(240, 240, 240); 70 | font: inherit; 71 | 72 | user-select: none; 73 | } 74 | 75 | input[type="text"] { 76 | width: 75px; 77 | text-align: center; 78 | } 79 | 80 | .row { 81 | margin: 5px 0px; 82 | } 83 | 84 | label { 85 | display: inline-block; 86 | width: 170px; 87 | vertical-align: top; 88 | } 89 | 90 | #status { 91 | color: #9d9d9d; 92 | display: inline-block; 93 | margin-left: 50px; 94 | } 95 | 96 | #faq { 97 | margin-top: 2em; 98 | } 99 | 100 | select { 101 | width: 170px; 102 | } 103 | 104 | .customForce { 105 | display: none; 106 | width: 250px; 107 | } 108 | 109 | .customKey { 110 | color: transparent; 111 | text-shadow: 0 0 0 #000000; 112 | } 113 | -------------------------------------------------------------------------------- /options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Video Speed Controller: Options 5 | 6 | 7 | 8 | 9 |
10 |

Video Speed Controller

11 |
12 | 13 |
14 |

Shortcuts

15 |
16 | 19 | 25 | 26 | 30 |
31 |
32 | 35 | 41 | 42 | 46 |
47 |
48 | 51 | 57 | 58 | 62 |
63 |
64 | 67 | 73 | 74 | 78 |
79 |
80 | 83 | 89 | 90 | 94 |
95 |
96 | 99 | 105 | 111 | 115 |
116 |
117 | 120 | 126 | 127 | 131 |
132 | 133 | 134 |
135 | 136 |
137 |

Other

138 |
139 | 140 | 141 |
142 |
143 | 144 | 145 |
146 |
147 | 148 | 149 |
150 |
151 | 153 | 154 |
155 |
156 | 157 | 158 |
159 |
160 | 161 | 162 |
163 |
164 | 174 | 175 |
176 |
177 | 178 | 179 | 180 | 181 | 182 |
183 | 184 |
185 |
186 | 187 |

Extension controls not appearing?

188 |

189 | This extension is only compatible with HTML5 audio and video. If you don't 190 | see the controls showing up, chances are you are viewing a Flash content. 191 | If you want to confirm, try right-clicking on the content and inspect the 192 | menu: if it mentions flash, then that's the issue. That said, most sites 193 | will fallback to HTML5 if they detect that Flash is not available. You 194 | can try manually disabling Flash from the browser. 195 |

196 |
197 | 198 | 199 | -------------------------------------------------------------------------------- /options.js: -------------------------------------------------------------------------------- 1 | var regStrip = /^[\r\t\f\v ]+|[\r\t\f\v ]+$/gm; 2 | 3 | var tcDefaults = { 4 | speed: 1.0, // default: 5 | displayKeyCode: 86, // default: V 6 | rememberSpeed: false, // default: false 7 | audioBoolean: false, // default: false 8 | startHidden: false, // default: false 9 | forceLastSavedSpeed: false, //default: false 10 | enabled: true, // default enabled 11 | controllerOpacity: 0.3, // default: 0.3 12 | keyBindings: [ 13 | { action: "display", key: 86, value: 0, force: false, predefined: true }, // V 14 | { action: "slower", key: 83, value: 0.1, force: false, predefined: true }, // S 15 | { action: "faster", key: 68, value: 0.1, force: false, predefined: true }, // D 16 | { action: "rewind", key: 90, value: 10, force: false, predefined: true }, // Z 17 | { action: "advance", key: 88, value: 10, force: false, predefined: true }, // X 18 | { action: "reset", key: 82, value: 1, force: false, predefined: true }, // R 19 | { action: "fast", key: 71, value: 1.8, force: false, predefined: true } // G 20 | ], 21 | blacklist: `www.instagram.com 22 | twitter.com 23 | imgur.com 24 | teams.microsoft.com 25 | `.replace(regStrip, "") 26 | }; 27 | 28 | var keyBindings = []; 29 | 30 | var keyCodeAliases = { 31 | 0: "null", 32 | null: "null", 33 | undefined: "null", 34 | 32: "Space", 35 | 37: "Left", 36 | 38: "Up", 37 | 39: "Right", 38 | 40: "Down", 39 | 96: "Num 0", 40 | 97: "Num 1", 41 | 98: "Num 2", 42 | 99: "Num 3", 43 | 100: "Num 4", 44 | 101: "Num 5", 45 | 102: "Num 6", 46 | 103: "Num 7", 47 | 104: "Num 8", 48 | 105: "Num 9", 49 | 106: "Num *", 50 | 107: "Num +", 51 | 109: "Num -", 52 | 110: "Num .", 53 | 111: "Num /", 54 | 112: "F1", 55 | 113: "F2", 56 | 114: "F3", 57 | 115: "F4", 58 | 116: "F5", 59 | 117: "F6", 60 | 118: "F7", 61 | 119: "F8", 62 | 120: "F9", 63 | 121: "F10", 64 | 122: "F11", 65 | 123: "F12", 66 | 186: ";", 67 | 188: "<", 68 | 189: "-", 69 | 187: "+", 70 | 190: ">", 71 | 191: "/", 72 | 192: "~", 73 | 219: "[", 74 | 220: "\\", 75 | 221: "]", 76 | 222: "'", 77 | 59: ";", 78 | 61: "+", 79 | 173: "-", 80 | }; 81 | 82 | function recordKeyPress(e) { 83 | if ( 84 | (e.keyCode >= 48 && e.keyCode <= 57) || // Numbers 0-9 85 | (e.keyCode >= 65 && e.keyCode <= 90) || // Letters A-Z 86 | keyCodeAliases[e.keyCode] // Other character keys 87 | ) { 88 | e.target.value = 89 | keyCodeAliases[e.keyCode] || String.fromCharCode(e.keyCode); 90 | e.target.keyCode = e.keyCode; 91 | 92 | e.preventDefault(); 93 | e.stopPropagation(); 94 | } else if (e.keyCode === 8) { 95 | // Clear input when backspace pressed 96 | e.target.value = ""; 97 | } else if (e.keyCode === 27) { 98 | // When esc clicked, clear input 99 | e.target.value = "null"; 100 | e.target.keyCode = null; 101 | } 102 | } 103 | 104 | function inputFilterNumbersOnly(e) { 105 | var char = String.fromCharCode(e.keyCode); 106 | if (!/[\d\.]$/.test(char) || !/^\d+(\.\d*)?$/.test(e.target.value + char)) { 107 | e.preventDefault(); 108 | e.stopPropagation(); 109 | } 110 | } 111 | 112 | function inputFocus(e) { 113 | e.target.value = ""; 114 | } 115 | 116 | function inputBlur(e) { 117 | e.target.value = 118 | keyCodeAliases[e.target.keyCode] || String.fromCharCode(e.target.keyCode); 119 | } 120 | 121 | function updateShortcutInputText(inputId, keyCode) { 122 | document.getElementById(inputId).value = 123 | keyCodeAliases[keyCode] || String.fromCharCode(keyCode); 124 | document.getElementById(inputId).keyCode = keyCode; 125 | } 126 | 127 | function updateCustomShortcutInputText(inputItem, keyCode) { 128 | inputItem.value = keyCodeAliases[keyCode] || String.fromCharCode(keyCode); 129 | inputItem.keyCode = keyCode; 130 | } 131 | 132 | // List of custom actions for which customValue should be disabled 133 | var customActionsNoValues = ["pause", "muted", "mark", "jump", "display"]; 134 | 135 | function add_shortcut() { 136 | var html = ` 149 | 150 | 151 | 155 | `; 156 | var div = document.createElement("div"); 157 | div.setAttribute("class", "row customs"); 158 | div.innerHTML = html; 159 | var customs_element = document.getElementById("customs"); 160 | customs_element.insertBefore( 161 | div, 162 | customs_element.children[customs_element.childElementCount - 1] 163 | ); 164 | } 165 | 166 | function createKeyBindings(item) { 167 | const action = item.querySelector(".customDo").value; 168 | const key = item.querySelector(".customKey").keyCode; 169 | const value = Number(item.querySelector(".customValue").value); 170 | const force = item.querySelector(".customForce").value; 171 | const predefined = !!item.id; //item.id ? true : false; 172 | 173 | keyBindings.push({ 174 | action: action, 175 | key: key, 176 | value: value, 177 | force: force, 178 | predefined: predefined 179 | }); 180 | } 181 | 182 | // Validates settings before saving 183 | function validate() { 184 | var valid = true; 185 | var status = document.getElementById("status"); 186 | document 187 | .getElementById("blacklist") 188 | .value.split("\n") 189 | .forEach((match) => { 190 | match = match.replace(regStrip, ""); 191 | if (match.startsWith("/")) { 192 | try { 193 | var regexp = new RegExp(match); 194 | } catch (err) { 195 | status.textContent = 196 | "Error: Invalid blacklist regex: " + match + ". Unable to save"; 197 | valid = false; 198 | return; 199 | } 200 | } 201 | }); 202 | return valid; 203 | } 204 | 205 | // Saves options to chrome.storage 206 | function save_options() { 207 | if (validate() === false) { 208 | return; 209 | } 210 | keyBindings = []; 211 | Array.from(document.querySelectorAll(".customs")).forEach((item) => 212 | createKeyBindings(item) 213 | ); // Remove added shortcuts 214 | 215 | var rememberSpeed = document.getElementById("rememberSpeed").checked; 216 | var forceLastSavedSpeed = document.getElementById("forceLastSavedSpeed").checked; 217 | var audioBoolean = document.getElementById("audioBoolean").checked; 218 | var enabled = document.getElementById("enabled").checked; 219 | var startHidden = document.getElementById("startHidden").checked; 220 | var controllerOpacity = document.getElementById("controllerOpacity").value; 221 | var blacklist = document.getElementById("blacklist").value; 222 | 223 | chrome.storage.sync.remove([ 224 | "resetSpeed", 225 | "speedStep", 226 | "fastSpeed", 227 | "rewindTime", 228 | "advanceTime", 229 | "resetKeyCode", 230 | "slowerKeyCode", 231 | "fasterKeyCode", 232 | "rewindKeyCode", 233 | "advanceKeyCode", 234 | "fastKeyCode" 235 | ]); 236 | chrome.storage.sync.set( 237 | { 238 | rememberSpeed: rememberSpeed, 239 | forceLastSavedSpeed: forceLastSavedSpeed, 240 | audioBoolean: audioBoolean, 241 | enabled: enabled, 242 | startHidden: startHidden, 243 | controllerOpacity: controllerOpacity, 244 | keyBindings: keyBindings, 245 | blacklist: blacklist.replace(regStrip, "") 246 | }, 247 | function () { 248 | // Update status to let user know options were saved. 249 | var status = document.getElementById("status"); 250 | status.textContent = "Options saved"; 251 | setTimeout(function () { 252 | status.textContent = ""; 253 | }, 1000); 254 | } 255 | ); 256 | } 257 | 258 | // Restores options from chrome.storage 259 | function restore_options() { 260 | chrome.storage.sync.get(tcDefaults, function (storage) { 261 | document.getElementById("rememberSpeed").checked = storage.rememberSpeed; 262 | document.getElementById("forceLastSavedSpeed").checked = storage.forceLastSavedSpeed; 263 | document.getElementById("audioBoolean").checked = storage.audioBoolean; 264 | document.getElementById("enabled").checked = storage.enabled; 265 | document.getElementById("startHidden").checked = storage.startHidden; 266 | document.getElementById("controllerOpacity").value = 267 | storage.controllerOpacity; 268 | document.getElementById("blacklist").value = storage.blacklist; 269 | 270 | // ensure that there is a "display" binding for upgrades from versions that had it as a separate binding 271 | if (storage.keyBindings.filter((x) => x.action == "display").length == 0) { 272 | storage.keyBindings.push({ 273 | action: "display", 274 | value: 0, 275 | force: false, 276 | predefined: true 277 | }); 278 | } 279 | 280 | for (let i in storage.keyBindings) { 281 | var item = storage.keyBindings[i]; 282 | if (item.predefined) { 283 | //do predefined ones because their value needed for overlay 284 | // document.querySelector("#" + item["action"] + " .customDo").value = item["action"]; 285 | if (item["action"] == "display" && typeof item["key"] === "undefined") { 286 | item["key"] = storage.displayKeyCode || tcDefaults.displayKeyCode; // V 287 | } 288 | 289 | if (customActionsNoValues.includes(item["action"])) 290 | document.querySelector( 291 | "#" + item["action"] + " .customValue" 292 | ).disabled = true; 293 | 294 | updateCustomShortcutInputText( 295 | document.querySelector("#" + item["action"] + " .customKey"), 296 | item["key"] 297 | ); 298 | document.querySelector("#" + item["action"] + " .customValue").value = 299 | item["value"]; 300 | document.querySelector("#" + item["action"] + " .customForce").value = 301 | item["force"]; 302 | } else { 303 | // new ones 304 | add_shortcut(); 305 | const dom = document.querySelector(".customs:last-of-type"); 306 | dom.querySelector(".customDo").value = item["action"]; 307 | 308 | if (customActionsNoValues.includes(item["action"])) 309 | dom.querySelector(".customValue").disabled = true; 310 | 311 | updateCustomShortcutInputText( 312 | dom.querySelector(".customKey"), 313 | item["key"] 314 | ); 315 | dom.querySelector(".customValue").value = item["value"]; 316 | dom.querySelector(".customForce").value = item["force"]; 317 | } 318 | } 319 | }); 320 | } 321 | 322 | function restore_defaults() { 323 | chrome.storage.sync.set(tcDefaults, function () { 324 | restore_options(); 325 | document 326 | .querySelectorAll(".removeParent") 327 | .forEach((button) => button.click()); // Remove added shortcuts 328 | // Update status to let user know options were saved. 329 | var status = document.getElementById("status"); 330 | status.textContent = "Default options restored"; 331 | setTimeout(function () { 332 | status.textContent = ""; 333 | }, 1000); 334 | }); 335 | } 336 | 337 | function show_experimental() { 338 | document 339 | .querySelectorAll(".customForce") 340 | .forEach((item) => (item.style.display = "inline-block")); 341 | } 342 | 343 | document.addEventListener("DOMContentLoaded", function () { 344 | restore_options(); 345 | 346 | document.getElementById("save").addEventListener("click", save_options); 347 | document.getElementById("add").addEventListener("click", add_shortcut); 348 | document 349 | .getElementById("restore") 350 | .addEventListener("click", restore_defaults); 351 | document 352 | .getElementById("experimental") 353 | .addEventListener("click", show_experimental); 354 | 355 | function eventCaller(event, className, funcName) { 356 | if (!event.target.classList || !event.target.classList.contains(className)) { 357 | return; 358 | } 359 | funcName(event); 360 | } 361 | 362 | document.addEventListener("keypress", (event) => { 363 | eventCaller(event, "customValue", inputFilterNumbersOnly); 364 | }); 365 | document.addEventListener("focus", (event) => { 366 | eventCaller(event, "customKey", inputFocus); 367 | }); 368 | document.addEventListener("blur", (event) => { 369 | eventCaller(event, "customKey", inputBlur); 370 | }); 371 | document.addEventListener("keydown", (event) => { 372 | eventCaller(event, "customKey", recordKeyPress); 373 | }); 374 | document.addEventListener("click", (event) => { 375 | eventCaller(event, "removeParent", function () { 376 | event.target.parentNode.remove(); 377 | }); 378 | }); 379 | document.addEventListener("change", (event) => { 380 | eventCaller(event, "customDo", function () { 381 | if (customActionsNoValues.includes(event.target.value)) { 382 | event.target.nextElementSibling.nextElementSibling.disabled = true; 383 | event.target.nextElementSibling.nextElementSibling.value = 0; 384 | } else { 385 | event.target.nextElementSibling.nextElementSibling.disabled = false; 386 | } 387 | }); 388 | }); 389 | }); 390 | -------------------------------------------------------------------------------- /popup.css: -------------------------------------------------------------------------------- 1 | body { 2 | min-width: 8em; 3 | } 4 | 5 | hr { 6 | width: 100%; 7 | border: 0; 8 | height: 0; 9 | border-top: 1px solid rgba(0, 0, 0, 0.3); 10 | margin: 0.6em 0; 11 | } 12 | 13 | button { 14 | width: 100%; 15 | background-image: linear-gradient(#ededed, #ededed 38%, #dedede); 16 | border: 1px solid rgba(0, 0, 0, 0.25); 17 | border-radius: 2px; 18 | outline: none; 19 | box-shadow: 0 1px 0 rgba(0, 0, 0, 0.08), 20 | inset 0 1px 2px rgba(255, 255, 255, 0.75); 21 | color: #444; 22 | text-shadow: 0 1px 0 rgb(240, 240, 240); 23 | font: inherit; 24 | user-select: none; 25 | } 26 | 27 | .secondary { 28 | font-size: 0.95em; 29 | margin: 0.15em 0; 30 | } 31 | 32 | .hide { 33 | display: none; 34 | } 35 | -------------------------------------------------------------------------------- /popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Video Speed Controller: Popup 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 |
15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /popup.js: -------------------------------------------------------------------------------- 1 | document.addEventListener("DOMContentLoaded", function () { 2 | document.querySelector("#config").addEventListener("click", function () { 3 | window.open(chrome.runtime.getURL("options.html")); 4 | }); 5 | 6 | document.querySelector("#about").addEventListener("click", function () { 7 | window.open("https://github.com/codebicycle/videospeed"); 8 | }); 9 | 10 | document.querySelector("#feedback").addEventListener("click", function () { 11 | window.open("https://github.com/codebicycle/videospeed/issues"); 12 | }); 13 | 14 | document.querySelector("#enable").addEventListener("click", function () { 15 | toggleEnabled(true, settingsSavedReloadMessage); 16 | }); 17 | 18 | document.querySelector("#disable").addEventListener("click", function () { 19 | toggleEnabled(false, settingsSavedReloadMessage); 20 | }); 21 | 22 | chrome.storage.sync.get({ enabled: true }, function (storage) { 23 | toggleEnabledUI(storage.enabled); 24 | }); 25 | 26 | function toggleEnabled(enabled, callback) { 27 | chrome.storage.sync.set( 28 | { 29 | enabled: enabled 30 | }, 31 | function () { 32 | toggleEnabledUI(enabled); 33 | if (callback) callback(enabled); 34 | } 35 | ); 36 | } 37 | 38 | function toggleEnabledUI(enabled) { 39 | document.querySelector("#enable").classList.toggle("hide", enabled); 40 | document.querySelector("#disable").classList.toggle("hide", !enabled); 41 | 42 | const suffix = `${enabled ? "" : "_disabled"}.png`; 43 | chrome.browserAction.setIcon({ 44 | path: { 45 | "19": "icons/icon19" + suffix, 46 | "38": "icons/icon38" + suffix, 47 | "48": "icons/icon48" + suffix 48 | } 49 | }); 50 | } 51 | 52 | function settingsSavedReloadMessage(enabled) { 53 | setStatusMessage( 54 | `${enabled ? "Enabled" : "Disabled"}. Reload page to see changes` 55 | ); 56 | } 57 | 58 | function setStatusMessage(str) { 59 | const status_element = document.querySelector("#status"); 60 | status_element.classList.toggle("hide", false); 61 | status_element.innerText = str; 62 | } 63 | }); 64 | -------------------------------------------------------------------------------- /shadow.css: -------------------------------------------------------------------------------- 1 | * { 2 | line-height: 1.9em; 3 | font-family: sans-serif; 4 | font-size: 13px; 5 | } 6 | 7 | :host(:hover) #controls { 8 | display: inline; 9 | } 10 | 11 | #controller { 12 | position: absolute; 13 | top: 0; 14 | left: 0; 15 | 16 | background: black; 17 | color: white; 18 | 19 | border-radius: 5px; 20 | padding: 5px; 21 | margin: 10px 10px 10px 15px; 22 | 23 | cursor: default; 24 | z-index: 9999999; 25 | } 26 | 27 | #controller:hover { 28 | opacity: 0.7; 29 | } 30 | 31 | #controller:hover > .draggable { 32 | margin-right: 0.8em; 33 | } 34 | 35 | #controls { 36 | display: none; 37 | } 38 | 39 | #controller.dragging { 40 | cursor: -webkit-grabbing; 41 | cursor: -moz-grabbing; 42 | opacity: 0.7; 43 | } 44 | 45 | #controller.dragging #controls { 46 | display: inline; 47 | } 48 | 49 | .draggable { 50 | cursor: -webkit-grab; 51 | cursor: -moz-grab; 52 | } 53 | 54 | .draggable:active { 55 | cursor: -webkit-grabbing; 56 | cursor: -moz-grabbing; 57 | } 58 | 59 | button { 60 | cursor: pointer; 61 | color: black; 62 | background: white; 63 | font-weight: bold; 64 | border-radius: 5px; 65 | padding: 3px 6px 3px 6px; 66 | font-size: 14px; 67 | line-height: 14px; 68 | border: 1px solid white; 69 | font-family: "Lucida Console", Monaco, monospace; 70 | margin-bottom: 2px; 71 | } 72 | 73 | button:focus { 74 | outline: 0; 75 | } 76 | 77 | button:hover { 78 | opacity: 1; 79 | } 80 | 81 | button:active { 82 | background: #ccc; 83 | } 84 | 85 | button.rw { 86 | opacity: 0.65; 87 | } 88 | 89 | button.hideButton { 90 | opacity: 0.65; 91 | margin-right: 2px; 92 | } 93 | --------------------------------------------------------------------------------