├── .gitignore
├── icons
├── icon128.png
├── icon16.png
├── icon19.png
├── icon38.png
├── icon48.png
├── icon19_disabled.png
├── icon38_disabled.png
└── icon48_disabled.png
├── .prettierrc
├── .pre-commit-config.yaml
├── popup.html
├── popup.css
├── manifest.json
├── shadow.css
├── popup.js
├── options.css
├── inject.css
├── README.md
├── options.html
├── options.js
└── inject.js
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | local
3 |
4 | # IntelliJ IDEA
5 | .idea/
6 | node_modules
7 |
--------------------------------------------------------------------------------
/icons/icon128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codebicycle/videospeed/HEAD/icons/icon128.png
--------------------------------------------------------------------------------
/icons/icon16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codebicycle/videospeed/HEAD/icons/icon16.png
--------------------------------------------------------------------------------
/icons/icon19.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codebicycle/videospeed/HEAD/icons/icon19.png
--------------------------------------------------------------------------------
/icons/icon38.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codebicycle/videospeed/HEAD/icons/icon38.png
--------------------------------------------------------------------------------
/icons/icon48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codebicycle/videospeed/HEAD/icons/icon48.png
--------------------------------------------------------------------------------
/icons/icon19_disabled.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codebicycle/videospeed/HEAD/icons/icon19_disabled.png
--------------------------------------------------------------------------------
/icons/icon38_disabled.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codebicycle/videospeed/HEAD/icons/icon38_disabled.png
--------------------------------------------------------------------------------
/icons/icon48_disabled.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codebicycle/videospeed/HEAD/icons/icon48_disabled.png
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "none",
3 | "tabWidth": 2,
4 | "printWidth": 80,
5 | "semi": true,
6 | "endOfLine": "auto",
7 | "proseWrap": "always"
8 | }
9 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/popup.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `) 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 |
--------------------------------------------------------------------------------
/options.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Video Speed Controller: Options
5 |
6 |
7 |
8 |
9 |
10 | Video Speed Controller
11 |
12 |
13 |
135 |
136 |
177 |
178 | Save
179 | Restore Defaults
180 | Show Experimental Features
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 = `
137 | Decrease speed
138 | Increase speed
139 | Rewind
140 | Advance
141 | Reset speed
142 | Preferred speed
143 | Mute
144 | Pause
145 | Set marker
146 | Jump to marker
147 | Show/hide controller
148 |
149 |
150 |
151 |
152 | Do not disable website key bindings
153 | Disable website key bindings
154 |
155 | X `;
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------