diff --git a/inject.css b/inject.css index 33cc655..828632a 100644 --- a/inject.css +++ b/inject.css @@ -9,6 +9,11 @@ opacity: 1 !important; } +.vsc-controller { + /* In case of pages using `white-space: pre-line` (eg Discord), don't render vsc's whitespace */ + white-space: normal; +} + /* Origin specific overrides */ /* YouTube player */ .ytp-hide-info-bar .vsc-controller { diff --git a/inject.js b/inject.js index ca363ef..a2b21e3 100644 --- a/inject.js +++ b/inject.js @@ -8,6 +8,7 @@ var tc = { displayKeyCode: 86, // default: V rememberSpeed: false, // default: false + forceLastSavedSpeed: false, //default: false audioBoolean: false, // default: false startHidden: false, // default: false controllerOpacity: 0.3, // default: 0.3 @@ -21,7 +22,10 @@ var tc = { `.replace(regStrip, ""), defaultLogLevel: 4, logLevel: 3 - } + }, + + // Holds a reference to all of the AUDIO/VIDEO DOM elements we've attached to + mediaElements: [] }; /* Log levels (depends on caller specifying the correct level) @@ -53,7 +57,7 @@ function log(message, level) { } } -chrome.storage.sync.get(tc.settings, function(storage) { +chrome.storage.sync.get(tc.settings, function (storage) { tc.settings.keyBindings = storage.keyBindings; // Array if (storage.keyBindings.length == 0) { // if first initialization of 0.5.3 @@ -107,6 +111,7 @@ chrome.storage.sync.get(tc.settings, function(storage) { version: tc.settings.version, displayKeyCode: tc.settings.displayKeyCode, rememberSpeed: tc.settings.rememberSpeed, + forceLastSavedSpeed: tc.settings.forceLastSavedSpeed, audioBoolean: tc.settings.audioBoolean, startHidden: tc.settings.startHidden, enabled: tc.settings.enabled, @@ -117,6 +122,7 @@ chrome.storage.sync.get(tc.settings, function(storage) { tc.settings.lastSpeed = Number(storage.lastSpeed); tc.settings.displayKeyCode = Number(storage.displayKeyCode); tc.settings.rememberSpeed = Boolean(storage.rememberSpeed); + tc.settings.forceLastSavedSpeed = Boolean(storage.forceLastSavedSpeed); tc.settings.audioBoolean = Boolean(storage.audioBoolean); tc.settings.enabled = Boolean(storage.enabled); tc.settings.startHidden = Boolean(storage.startHidden); @@ -124,7 +130,9 @@ chrome.storage.sync.get(tc.settings, function(storage) { tc.settings.blacklist = String(storage.blacklist); // ensure that there is a "display" binding (for upgrades from versions that had it as a separate binding) - if (tc.settings.keyBindings.filter(x => x.action == "display").length == 0) { + if ( + tc.settings.keyBindings.filter((x) => x.action == "display").length == 0 + ) { tc.settings.keyBindings.push({ action: "display", key: Number(storage.displayKeyCode) || 86, @@ -137,32 +145,42 @@ chrome.storage.sync.get(tc.settings, function(storage) { initializeWhenReady(document); }); -var forEach = Array.prototype.forEach; - function getKeyBindings(action, what = "value") { try { - return tc.settings.keyBindings.find(item => item.action === action)[what]; + return tc.settings.keyBindings.find((item) => item.action === action)[what]; } catch (e) { return false; } } function setKeyBindings(action, value) { - tc.settings.keyBindings.find(item => item.action === action)["value"] = value; + tc.settings.keyBindings.find((item) => item.action === action)[ + "value" + ] = value; } function defineVideoController() { - tc.videoController = function(target, parent) { - if (target.dataset["vscid"]) { + // Data structures + // --------------- + // videoController (JS object) instances: + // video = AUDIO/VIDEO DOM element + // parent = A/V DOM element's parentElement OR + // (A/V elements discovered from the Mutation Observer) + // A/V element's parentNode OR the node whose children changed. + // div = Controller's DOM element (which happens to be a DIV) + // speedIndicator = DOM element in the Controller of the speed indicator + + // added to AUDIO / VIDEO DOM elements + // vsc = reference to the videoController + tc.videoController = function (target, parent) { + if (target.vsc) { return target.vsc; } + tc.mediaElements.push(target); + this.video = target; this.parent = target.parentElement || parent; - this.document = target.ownerDocument; - this.id = Math.random() - .toString(36) - .substr(2, 9); storedSpeed = tc.settings.speeds[target.currentSrc]; if (!tc.settings.rememberSpeed) { if (!storedSpeed) { @@ -183,48 +201,50 @@ function defineVideoController() { this.div = this.initializeControls(); + var mediaEventAction = function (event) { + storedSpeed = tc.settings.speeds[event.target.currentSrc]; + if (!tc.settings.rememberSpeed) { + if (!storedSpeed) { + log("Overwriting stored speed to 1.0 (rememberSpeed not enabled)", 4); + storedSpeed = 1.0; + } + // resetSpeed isn't really a reset, it's a toggle + log("Setting reset keybinding to fast", 5); + setKeyBindings("reset", getKeyBindings("fast")); // resetSpeed = fastSpeed + } else { + log( + "Storing lastSpeed into tc.settings.speeds (rememberSpeed enabled)", + 5 + ); + storedSpeed = tc.settings.lastSpeed; + } + // TODO: Check if explicitly setting the playback rate to 1.0 is + // necessary when rememberSpeed is disabled (this may accidentally + // override a website's intentional initial speed setting interfering + // with the site's default behavior) + log("Explicitly setting playbackRate to: " + storedSpeed, 4); + setSpeed(event.target, storedSpeed); + }; + target.addEventListener( "play", - (this.handlePlay = function(event) { - storedSpeed = tc.settings.speeds[event.target.currentSrc]; - if (!tc.settings.rememberSpeed) { - if (!storedSpeed) { - log( - "Overwriting stored speed to 1.0 (rememberSpeed not enabled)", - 4 - ); - storedSpeed = 1.0; - } - // resetSpeed isn't really a reset, it's a toggle - log("Setting reset keybinding to fast", 5); - setKeyBindings("reset", getKeyBindings("fast")); // resetSpeed = fastSpeed - } else { - log( - "Storing lastSpeed into tc.settings.speeds (rememberSpeed enabled)", - 5 - ); - storedSpeed = tc.settings.lastSpeed; - } - // TODO: Check if explicitly setting the playback rate to 1.0 is - // necessary when rememberSpeed is disabled (this may accidentally - // override a website's intentional initial speed setting interfering - // with the site's default behavior) - log("Explicitly setting playbackRate to: " + storedSpeed, 4); - event.target.playbackRate = storedSpeed; - }.bind(this)) + (this.handlePlay = mediaEventAction.bind(this)) ); - var observer = new MutationObserver(mutations => { - mutations.forEach(mutation => { + target.addEventListener( + "seeked", + (this.handleSeek = mediaEventAction.bind(this)) + ); + + var observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { if ( mutation.type === "attributes" && (mutation.attributeName === "src" || mutation.attributeName === "currentSrc") ) { - var controller = getController(this.id); - if (!controller) { - return; - } + log("mutation of A/V element", 5); + var controller = this.div; if (!mutation.target.src && !mutation.target.currentSrc) { controller.classList.add("vsc-nosource"); } else { @@ -238,25 +258,29 @@ function defineVideoController() { }); }; - tc.videoController.prototype.remove = function() { + tc.videoController.prototype.remove = function () { this.div.remove(); this.video.removeEventListener("play", this.handlePlay); - delete this.video.dataset["vscid"]; + this.video.removeEventListener("seek", this.handleSeek); delete this.video.vsc; + let idx = tc.mediaElements.indexOf(this.video); + if (idx != -1) { + tc.mediaElements.splice(idx, 1); + } }; - tc.videoController.prototype.initializeControls = function() { + tc.videoController.prototype.initializeControls = function () { log("initializeControls Begin", 5); - var document = this.document; - var speed = this.video.playbackRate.toFixed(2), - top = Math.max(this.video.offsetTop, 0) + "px", - left = Math.max(this.video.offsetLeft, 0) + "px"; + const document = this.video.ownerDocument; + const speed = this.video.playbackRate.toFixed(2); + const rect = this.video.getBoundingClientRect(); + const top = Math.max(rect.top, 0) + "px"; + const left = Math.max(rect.left, 0) + "px"; log("Speed variable set to: " + speed, 5); var wrapper = document.createElement("div"); wrapper.classList.add("vsc-controller"); - wrapper.dataset["vscid"] = this.id; if (!this.video.currentSrc) { wrapper.classList.add("vsc-nosource"); @@ -278,35 +302,49 @@ function defineVideoController() { ${speed} - - + + - + `; shadow.innerHTML = shadowTemplate; - shadow.querySelector(".draggable").addEventListener("mousedown", e => { - runAction(e.target.dataset["action"], document, false, e); + shadow.querySelector(".draggable").addEventListener( + "mousedown", + (e) => { + runAction(e.target.dataset["action"], false, e); + e.stopPropagation(); + }, + true + ); + + shadow.querySelectorAll("button").forEach(function (button) { + button.addEventListener( + "click", + (e) => { + runAction( + e.target.dataset["action"], + getKeyBindings(e.target.dataset["action"]), + e + ); + e.stopPropagation(); + }, + true + ); }); - forEach.call(shadow.querySelectorAll("button"), function(button) { - button.onclick = e => { - runAction( - e.target.dataset["action"], - document, - getKeyBindings(e.target.dataset["action"]), - e - ); - }; - }); + shadow + .querySelector("#controller") + .addEventListener("click", (e) => e.stopPropagation(), false); + shadow + .querySelector("#controller") + .addEventListener("mousedown", (e) => e.stopPropagation(), false); this.speedIndicator = shadow.querySelector("span"); var fragment = document.createDocumentFragment(); fragment.appendChild(wrapper); - this.video.dataset["vscid"] = this.id; - switch (true) { case location.hostname == "www.amazon.com": case location.hostname == "www.reddit.com": @@ -314,13 +352,17 @@ function defineVideoController() { // insert before parent to bypass overlay this.parent.parentElement.insertBefore(fragment, this.parent); break; + case location.hostname == "www.facebook.com": + // this is a monstrosity but new FB design does not have *any* + // semantic handles for us to traverse the tree, and deep nesting + // that we need to bubble up from to get controller to stack correctly + let p = this.parent.parentElement.parentElement.parentElement + .parentElement.parentElement.parentElement.parentElement; + p.insertBefore(fragment, p.firstChild); + break; case location.hostname == "tv.apple.com": // insert after parent for correct stacking context - this.parent - .getRootNode() - .querySelector(".scrim") - .prepend(fragment); - + this.parent.getRootNode().querySelector(".scrim").prepend(fragment); default: // Note: when triggered via a MutationRecord, it's possible that the // target is not the immediate parent. This appends the controller as @@ -338,7 +380,7 @@ function escapeStringRegExp(str) { function isBlacklisted() { blacklisted = false; - tc.settings.blacklist.split("\n").forEach(match => { + tc.settings.blacklist.split("\n").forEach((match) => { match = match.replace(regStrip, ""); if (match.length == 0) { return; @@ -368,41 +410,68 @@ function refreshCoolDown() { if (coolDown) { clearTimeout(coolDown); } - coolDown = setTimeout(function() { + coolDown = setTimeout(function () { coolDown = false; }, 1000); log("End refreshCoolDown", 5); } function setupListener() { - document.body.addEventListener( + /** + * This function is run whenever a video speed rate change occurs. + * It is used to update the speed that shows up in the display as well as save + * that latest speed into the local storage. + * + * @param {*} video The video element to update the speed indicators for. + */ + function updateSpeedFromEvent(video) { + // It's possible to get a rate change on a VIDEO/AUDIO that doesn't have + // a video controller attached to it. If we do, ignore it. + if (!video.vsc) + return; + var speedIndicator = video.vsc.speedIndicator; + var src = video.currentSrc; + var speed = Number(video.playbackRate.toFixed(2)); + + log("Playback rate changed to " + speed, 4); + + log("Updating controller with new speed", 5); + speedIndicator.textContent = speed.toFixed(2); + tc.settings.speeds[src] = speed; + log("Storing lastSpeed in settings for the rememberSpeed feature", 5); + tc.settings.lastSpeed = speed; + log("Syncing chrome settings for lastSpeed", 5); + chrome.storage.sync.set({ lastSpeed: speed }, function () { + log("Speed setting saved: " + speed, 5); + }); + // show the controller for 1000ms if it's hidden. + runAction("blink", null, null); + } + + document.addEventListener( "ratechange", - function(event) { + function (event) { if (coolDown) { log("Speed event propagation blocked", 4); event.stopImmediatePropagation(); } - var controller = event.target.parentElement.querySelector( - ".vsc-controller" - ); - var speedIndicator = controller.shadowRoot.querySelector("span"); - var video = controller.parentElement.querySelector("video"); - var src = video.currentSrc; - var speed = video.playbackRate.toFixed(2); + var video = event.target; - log("Playback rate changed to " + speed, 4); - - log("Updating controller with new speed", 5); - speedIndicator.textContent = speed; - tc.settings.speeds[src] = speed; - log("Storing lastSpeed in settings for the rememberSpeed feature", 5); - tc.settings.lastSpeed = speed; - log("Syncing chrome settings for lastSpeed", 5); - chrome.storage.sync.set({ lastSpeed: speed }, function() { - log("Speed setting saved: " + speed, 5); - }); - // show the controller for 1000ms if it's hidden. - runAction("blink", document, null, null); + /** + * If the last speed is forced, only update the speed based on events created by + * video speed instead of all video speed change events. + */ + if (tc.settings.forceLastSavedSpeed) { + if (event.detail && event.detail.origin === "videoSpeed") { + video.playbackRate = event.detail.speed; + updateSpeedFromEvent(video); + } else { + video.playbackRate = tc.settings.lastSpeed; + } + event.stopImmediatePropagation(); + } else { + updateSpeedFromEvent(video); + } }, true ); @@ -436,9 +505,23 @@ function inIframe() { return true; } } - -function getController(id) { - return document.querySelector(`div[data-vscid="${id}"]`); +function getShadow(parent) { + let result = []; + function getChild(parent) { + if (parent.firstElementChild) { + var child = parent.firstElementChild; + do { + result.push(child); + getChild(child); + if (child.shadowRoot) { + result.push(getShadow(child.shadowRoot)); + } + child = child.nextElementSibling; + } while (child); + } + } + getChild(parent); + return result.flat(Infinity); } function initializeNow(document) { @@ -470,10 +553,10 @@ function initializeNow(document) { if (inIframe()) docs.push(window.top.document); } catch (e) {} - docs.forEach(function(doc) { + docs.forEach(function (doc) { doc.addEventListener( "keydown", - function(event) { + function (event) { var keyCode = event.keyCode; log("Processing keydown event: " + keyCode, 6); @@ -501,15 +584,13 @@ function initializeNow(document) { } // Ignore keydown event if typing in a page without vsc - if ( - !document.querySelector(".vsc-controller") - ) { + if (!tc.mediaElements.length) { return false; } - var item = tc.settings.keyBindings.find(item => item.key === keyCode); + var item = tc.settings.keyBindings.find((item) => item.key === keyCode); if (item) { - runAction(item.action, document, item.value); + runAction(item.action, item.value); if (item.force === "true") { // disable websites key bindings event.preventDefault(); @@ -535,8 +616,7 @@ function initializeNow(document) { if (added) { node.vsc = new tc.videoController(node, parent); } else { - let id = node.dataset["vscid"]; - if (id) { + if (node.vsc) { node.vsc.remove(); } } @@ -548,22 +628,38 @@ function initializeNow(document) { } } - var observer = new MutationObserver(function(mutations) { + var observer = new MutationObserver(function (mutations) { // Process the DOM nodes lazily requestIdleCallback( - _ => { - mutations.forEach(function(mutation) { + (_) => { + mutations.forEach(function (mutation) { switch (mutation.type) { case "childList": - forEach.call(mutation.addedNodes, function(node) { + mutation.addedNodes.forEach(function (node) { if (typeof node === "function") return; checkForVideo(node, node.parentNode || mutation.target, true); }); - forEach.call(mutation.removedNodes, function(node) { + mutation.removedNodes.forEach(function (node) { if (typeof node === "function") return; checkForVideo(node, node.parentNode || mutation.target, false); }); break; + case "attributes": + if ( + mutation.target.attributes["aria-hidden"] && + mutation.target.attributes["aria-hidden"].value == "false" + ) { + var flattenedNodes = getShadow(document.body); + var node = flattenedNodes.filter( + (x) => x.tagName == "VIDEO" + )[0]; + if (node) { + if (node.vsc) + node.vsc.remove(); + checkForVideo(node, node.parentNode || mutation.target, true); + } + } + break; } }); }, @@ -582,12 +678,12 @@ function initializeNow(document) { var mediaTags = document.querySelectorAll("video"); } - forEach.call(mediaTags, function(video) { + mediaTags.forEach(function (video) { video.vsc = new tc.videoController(video); }); var frameTags = document.getElementsByTagName("iframe"); - forEach.call(frameTags, function(frame) { + Array.prototype.forEach.call(frameTags, function (frame) { // Ignore frames we don't have permission to access (different origin). try { var childDocument = frame.contentDocument; @@ -599,41 +695,44 @@ function initializeNow(document) { log("End initializeNow", 5); } -function setSpeed(controller, video, speed) { +function setSpeed(video, speed) { log("setSpeed started: " + speed, 5); var speedvalue = speed.toFixed(2); - video.playbackRate = Number(speedvalue); + if (tc.settings.forceLastSavedSpeed) { + video.dispatchEvent( + new CustomEvent("ratechange", { + detail: { origin: "videoSpeed", speed: speedvalue } + }) + ); + } else { + video.playbackRate = Number(speedvalue); + } + var speedIndicator = video.vsc.speedIndicator; + speedIndicator.textContent = speedvalue; + tc.settings.lastSpeed = speed; refreshCoolDown(); log("setSpeed finished: " + speed, 5); } -function runAction(action, document, value, e) { +function runAction(action, value, e) { log("runAction Begin", 5); - if (tc.settings.audioBoolean) { - var mediaTags = document.querySelectorAll("video,audio"); - } else { - var mediaTags = document.querySelectorAll("video"); - } - mediaTags.forEach = Array.prototype.forEach; + var mediaTags = tc.mediaElements; // Get the controller that was used if called from a button press event e if (e) { var targetController = e.target.getRootNode().host; } - mediaTags.forEach(function(v) { - var id = v.dataset["vscid"]; - var controller = getController(id); + mediaTags.forEach(function (v) { + var controller = v.vsc.div; + // Don't change video speed if the video has a different controller if (e && !(targetController == controller)) { return; } - // Controller may have been (force) removed by the site, guard to prevent crashes but run the command - if (controller) { - showController(controller); - } + showController(controller); if (!v.classList.contains("vsc-cancelled")) { if (action === "rewind") { @@ -650,16 +749,16 @@ function runAction(action, document, value, e) { (v.playbackRate < 0.1 ? 0.0 : v.playbackRate) + value, 16 ); - setSpeed(controller, v, s); + setSpeed(v, s); } else if (action === "slower") { log("Decrease speed", 5); // Video min rate is 0.0625: // https://cs.chromium.org/chromium/src/third_party/blink/renderer/core/html/media/html_media_element.cc?gsn=kMinRate&l=165 var s = Math.max(v.playbackRate - value, 0.07); - setSpeed(controller, v, s); + setSpeed(v, s); } else if (action === "reset") { log("Reset speed", 5); - resetSpeed(v, controller, 1.0); + resetSpeed(v, 1.0); } else if (action === "display") { log("Showing controller", 5); controller.classList.add("vsc-manual"); @@ -682,13 +781,13 @@ function runAction(action, document, value, e) { ); } } else if (action === "drag") { - handleDrag(v, controller, e); + handleDrag(v, e); } else if (action === "fast") { - resetSpeed(v, controller, value); + resetSpeed(v, value); } else if (action === "pause") { pause(v); } else if (action === "muted") { - muted(v, value); + muted(v); } else if (action === "mark") { setMark(v); } else if (action === "jump") { @@ -709,28 +808,28 @@ function pause(v) { } } -function resetSpeed(v, controller, target) { +function resetSpeed(v, target) { if (v.playbackRate === target) { if (v.playbackRate === getKeyBindings("reset")) { if (target !== 1.0) { log("Resetting playback speed to 1.0", 4); - setSpeed(controller, v, 1.0); + setSpeed(v, 1.0); } else { log('Toggling playback speed to "fast" speed', 4); - setSpeed(controller, v, getKeyBindings("fast")); + setSpeed(v, getKeyBindings("fast")); } } else { log('Toggling playback speed to "reset" speed', 4); - setSpeed(controller, v, getKeyBindings("reset")); + setSpeed(v, getKeyBindings("reset")); } } else { log('Toggling playback speed to "reset" speed', 4); setKeyBindings("reset", v.playbackRate); - setSpeed(controller, v, target); + setSpeed(v, target); } } -function muted(v, value) { +function muted(v) { v.muted = v.muted !== true; } @@ -746,7 +845,8 @@ function jumpToMark(v) { } } -function handleDrag(video, controller, e) { +function handleDrag(video, e) { + const controller = video.vsc.div; const shadowController = controller.shadowRoot.querySelector("#controller"); // Find nearest parent of same size as video parent. @@ -768,7 +868,7 @@ function handleDrag(video, controller, e) { parseInt(shadowController.style.top) ]; - const startDragging = e => { + const startDragging = (e) => { let style = shadowController.style; let dx = e.clientX - initialMouseXY[0]; let dy = e.clientY - initialMouseXY[1]; @@ -790,18 +890,16 @@ function handleDrag(video, controller, e) { parentElement.addEventListener("mousemove", startDragging); } -var timer; -var animation = false; +var timer = null; function showController(controller) { log("Showing controller", 4); controller.classList.add("vcs-show"); - if (animation) clearTimeout(timer); + if (timer) clearTimeout(timer); - animation = true; - timer = setTimeout(function() { + timer = setTimeout(function () { controller.classList.remove("vcs-show"); - animation = false; + timer = false; log("Hiding controller", 5); }, 2000); } diff --git a/manifest.json b/manifest.json index dd1201f..261be66 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "name": "Video Speed Controller", "short_name": "videospeed", - "version": "0.6.1", + "version": "0.6.3", "manifest_version": 2, "description": "Speed up, slow down, advance and rewind HTML5 audio/video with shortcuts", "homepage_url": "https://github.com/codebicycle/videospeed", diff --git a/options.html b/options.html index 5379669..242c326 100644 --- a/options.html +++ b/options.html @@ -147,6 +147,11 @@ +