Files
videospeed/inject.js
Chad Bailey ff3ea3113b Added logging function and logging entries
Moved all on ratechange logic to document level listener
Removed dead .getSpeed method
Fixed bug causing controller to sometimes initialize with NaN
2020-03-06 22:44:01 -06:00

846 lines
25 KiB
JavaScript

var regStrip = /^[\r\t\f\v ]+|[\r\t\f\v ]+$/gm;
var tc = {
settings: {
lastSpeed: 1.0, // default 1x
enabled: true, // default enabled
speeds: {}, // empty object to hold speed for each source
displayKeyCode: 86, // default: V
rememberSpeed: false, // default: false
audioBoolean: false, // default: false
startHidden: false, // default: false
controllerOpacity: 0.3, // default: 0.3
keyBindings: [],
blacklist: `\
www.instagram.com
twitter.com
vine.co
imgur.com
teams.microsoft.com
`.replace(regStrip, ""),
defaultLogLevel: 4,
logLevel: 3
}
};
/* Log levels (depends on caller specifying the correct level)
1 - none
2 - error
3 - warning
4 - info
5 - debug
6 - debug high verbosity + stack trace on each message
*/
function log(message, level) {
verbosity = tc.settings.logLevel;
if (typeof level === "undefined") {
level = tc.settings.defaultLogLevel;
}
if (verbosity >= level) {
if (level === 2) {
console.log("ERROR:" + message);
} else if (level === 3) {
console.log("WARNING:" + message);
} else if (level === 4) {
console.log("INFO:" + message);
} else if (level === 5) {
console.log("DEBUG:" + message);
} else if (level === 6) {
console.log("DEBUG (VERBOSE):" + message);
console.trace();
}
}
}
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
// UPDATE
tc.settings.keyBindings.push({
action: "slower",
key: Number(storage.slowerKeyCode) || 83,
value: Number(storage.speedStep) || 0.1,
force: false,
predefined: true
}); // default S
tc.settings.keyBindings.push({
action: "faster",
key: Number(storage.fasterKeyCode) || 68,
value: Number(storage.speedStep) || 0.1,
force: false,
predefined: true
}); // default: D
tc.settings.keyBindings.push({
action: "rewind",
key: Number(storage.rewindKeyCode) || 90,
value: Number(storage.rewindTime) || 10,
force: false,
predefined: true
}); // default: Z
tc.settings.keyBindings.push({
action: "advance",
key: Number(storage.advanceKeyCode) || 88,
value: Number(storage.advanceTime) || 10,
force: false,
predefined: true
}); // default: X
tc.settings.keyBindings.push({
action: "reset",
key: Number(storage.resetKeyCode) || 82,
value: 1.0,
force: false,
predefined: true
}); // default: R
tc.settings.keyBindings.push({
action: "fast",
key: Number(storage.fastKeyCode) || 71,
value: Number(storage.fastSpeed) || 1.8,
force: false,
predefined: true
}); // default: G
tc.settings.version = "0.5.3";
chrome.storage.sync.set({
keyBindings: tc.settings.keyBindings,
version: tc.settings.version,
displayKeyCode: tc.settings.displayKeyCode,
rememberSpeed: tc.settings.rememberSpeed,
audioBoolean: tc.settings.audioBoolean,
startHidden: tc.settings.startHidden,
enabled: tc.settings.enabled,
controllerOpacity: tc.settings.controllerOpacity,
blacklist: tc.settings.blacklist.replace(regStrip, "")
});
}
tc.settings.lastSpeed = Number(storage.lastSpeed);
tc.settings.displayKeyCode = Number(storage.displayKeyCode);
tc.settings.rememberSpeed = Boolean(storage.rememberSpeed);
tc.settings.audioBoolean = Boolean(storage.audioBoolean);
tc.settings.enabled = Boolean(storage.enabled);
tc.settings.startHidden = Boolean(storage.startHidden);
tc.settings.controllerOpacity = Number(storage.controllerOpacity);
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) {
tc.settings.keyBindings.push({
action: "display",
key: Number(storage.displayKeyCode) || 86,
value: 0,
force: false,
predefined: true
}); // default V
}
initializeWhenReady(document);
});
var forEach = Array.prototype.forEach;
function getKeyBindings(action, what = "value") {
try {
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;
}
function defineVideoController() {
tc.videoController = function(target, parent) {
if (target.dataset["vscid"]) {
return target.vsc;
}
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) {
log(
"Overwriting stored speed to 1.0 due to rememberSpeed being disabled",
5
);
storedSpeed = 1.0;
}
setKeyBindings("reset", getKeyBindings("fast")); // resetSpeed = fastSpeed
} else {
log("Recalling stored speed due to rememberSpeed being enabled", 5);
storedSpeed = tc.settings.lastSpeed;
}
log("Explicitly setting playbackRate to: " + storedSpeed, 5);
target.playbackRate = storedSpeed;
this.div = this.initializeControls();
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))
);
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;
}
if (!mutation.target.src && !mutation.target.currentSrc) {
controller.classList.add("vsc-nosource");
} else {
controller.classList.remove("vsc-nosource");
}
}
});
});
observer.observe(target, {
attributeFilter: ["src", "currentSrc"]
});
};
tc.videoController.prototype.remove = function() {
this.div.remove();
this.video.removeEventListener("play", this.handlePlay);
delete this.video.dataset["vscid"];
delete this.video.vsc;
};
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";
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");
}
if (tc.settings.startHidden) {
wrapper.classList.add("vsc-hidden");
}
var shadow = wrapper.attachShadow({ mode: "open" });
var shadowTemplate = `
<style>
@import "${chrome.runtime.getURL("shadow.css")}";
</style>
<div id="controller" style="top:${top}; left:${left}; opacity:${
tc.settings.controllerOpacity
}">
<span data-action="drag" class="draggable">${speed}</span>
<span id="controls">
<button data-action="rewind" class="rw">«</button>
<button data-action="slower">-</button>
<button data-action="faster">+</button>
<button data-action="advance" class="rw">»</button>
<button data-action="display" class="hideButton">x</button>
</span>
</div>
`;
shadow.innerHTML = shadowTemplate;
shadow.querySelector(".draggable").addEventListener("mousedown", e => {
runAction(e.target.dataset["action"], document, false, e);
});
forEach.call(shadow.querySelectorAll("button"), function(button) {
button.onclick = e => {
runAction(
e.target.dataset["action"],
document,
getKeyBindings(e.target.dataset["action"]),
e
);
};
});
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":
case /hbogo\./.test(location.hostname):
// insert before parent to bypass overlay
this.parent.parentElement.insertBefore(fragment, this.parent);
break;
case location.hostname == "tv.apple.com":
// insert after parent for correct stacking context
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
// the first element of the target, which may not be the parent.
this.parent.insertBefore(fragment, this.parent.firstChild);
}
return wrapper;
};
}
function escapeStringRegExp(str) {
matchOperatorsRe = /[|\\{}()[\]^$+*?.]/g;
return str.replace(matchOperatorsRe, "\\$&");
}
function isBlacklisted() {
blacklisted = false;
tc.settings.blacklist.split("\n").forEach(match => {
match = match.replace(regStrip, "");
if (match.length == 0) {
return;
}
if (match.startsWith("/")) {
try {
var regexp = new RegExp(match);
} catch (err) {
return;
}
} else {
var regexp = new RegExp(escapeStringRegExp(match));
}
if (regexp.test(location.href)) {
blacklisted = true;
return;
}
});
return blacklisted;
}
var coolDown = false;
function refreshCoolDown() {
log("Begin refreshCoolDown", 5);
if (coolDown) {
clearTimeout(coolDown);
}
coolDown = setTimeout(function() {
coolDown = false;
}, 1000);
log("End refreshCoolDown", 5);
}
function initializeWhenReady(document) {
log("Begin initializeWhenReady", 5);
if (isBlacklisted()) {
return;
}
document.body.addEventListener(
"ratechange",
function(event) {
controller = event.target.parentElement.querySelector(".vsc-controller");
rateChanged(controller);
if (coolDown) {
log("Speed event propagation blocked", 4);
event.stopImmediatePropagation();
}
},
true
);
window.onload = () => {
initializeNow(window.document);
};
if (document) {
if (document.readyState === "complete") {
initializeNow(document);
} else {
document.onreadystatechange = () => {
if (document.readyState === "complete") {
initializeNow(document);
}
};
}
}
log("End initializeWhenReady", 5);
}
function inIframe() {
try {
return window.self !== window.top;
} catch (e) {
return true;
}
}
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 getController(id) {
return getShadow(document.body).filter(x => {
return (
x.attributes["data-vscid"] &&
x.tagName == "DIV" &&
x.attributes["data-vscid"].value == `${id}`
);
})[0];
}
function initializeNow(document) {
log("Begin initializeNow", 5);
if (!tc.settings.enabled) return;
// enforce init-once due to redundant callers
if (!document.body || document.body.classList.contains("vsc-initialized")) {
return;
}
document.body.classList.add("vsc-initialized");
log("initializeNow: vsc-initialized added to document body", 5);
if (document === window.document) {
defineVideoController();
} else {
var link = document.createElement("link");
link.href = chrome.runtime.getURL("inject.css");
link.type = "text/css";
link.rel = "stylesheet";
document.head.appendChild(link);
}
var docs = Array(document);
try {
if (inIframe()) docs.push(window.top.document);
} catch (e) {}
docs.forEach(function(doc) {
doc.addEventListener(
"keydown",
function(event) {
var keyCode = event.keyCode;
log("Processing keydown event: " + keyCode, 6);
// Ignore if following modifier is active.
if (
!event.getModifierState ||
event.getModifierState("Alt") ||
event.getModifierState("Control") ||
event.getModifierState("Fn") ||
event.getModifierState("Meta") ||
event.getModifierState("Hyper") ||
event.getModifierState("OS")
) {
log("Keydown event ignored due to active modifier: " + keyCode, 5);
return;
}
// Ignore keydown event if typing in an input box
if (
event.target.nodeName === "INPUT" ||
event.target.nodeName === "TEXTAREA" ||
event.target.isContentEditable
) {
return false;
}
// Ignore keydown event if typing in a page without vsc
if (
!getShadow(document.body).filter(x => x.tagName == "vsc-controller")
) {
return false;
}
var item = tc.settings.keyBindings.find(item => item.key === keyCode);
if (item) {
runAction(item.action, document, item.value);
if (item.force === "true") {
// disable websites key bindings
event.preventDefault();
event.stopPropagation();
}
}
return false;
},
true
);
});
function checkForVideo(node, parent, added) {
// Only proceed with supposed removal if node is missing from DOM
if (!added && document.body.contains(node)) {
return;
}
if (
node.nodeName === "VIDEO" ||
(node.nodeName === "AUDIO" && tc.settings.audioBoolean)
) {
if (added) {
node.vsc = new tc.videoController(node, parent);
} else {
let id = node.dataset["vscid"];
if (id) {
node.vsc.remove();
}
}
} else if (node.children != undefined) {
for (var i = 0; i < node.children.length; i++) {
const child = node.children[i];
checkForVideo(child, child.parentNode || parent, added);
}
}
}
var observer = new MutationObserver(function(mutations) {
// Process the DOM nodes lazily
requestIdleCallback(
_ => {
mutations.forEach(function(mutation) {
switch (mutation.type) {
case "childList":
forEach.call(mutation.addedNodes, function(node) {
if (typeof node === "function") return;
checkForVideo(node, node.parentNode || mutation.target, true);
});
forEach.call(mutation.removedNodes, 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) {
var oldController = flattenedNodes.filter(x =>
x.classList.contains("vsc-controller")
)[0];
if (oldController) {
oldController.remove();
}
checkForVideo(node, node.parentNode || mutation.target, true);
}
}
break;
}
});
},
{ timeout: 1000 }
);
});
observer.observe(document, {
attributeFilter: ["aria-hidden"],
childList: true,
subtree: true
});
if (tc.settings.audioBoolean) {
var mediaTags = document.querySelectorAll("video,audio");
} else {
var mediaTags = document.querySelectorAll("video");
}
forEach.call(mediaTags, function(video) {
video.vsc = new tc.videoController(video);
});
var frameTags = document.getElementsByTagName("iframe");
forEach.call(frameTags, function(frame) {
// Ignore frames we don't have permission to access (different origin).
try {
var childDocument = frame.contentDocument;
} catch (e) {
return;
}
initializeWhenReady(childDocument);
});
log("End initializeNow", 5);
}
function setSpeed(controller, video, speed) {
log("setSpeed started: " + speed, 5);
var speedvalue = speed.toFixed(2);
video.playbackRate = Number(speedvalue);
refreshCoolDown();
rateChanged(controller);
log("setSpeed finished: " + speed, 5);
}
function rateChanged(controller) {
var speedIndicator = controller.shadowRoot.querySelector("span");
var video = controller.parentElement.querySelector("video");
var src = video.currentSrc;
var speed = video.playbackRate.toFixed(2);
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);
}
function runAction(action, document, value, e) {
log("runAction Begin", 5);
if (tc.settings.audioBoolean) {
var mediaTags = getShadow(document.body).filter(x => {
return x.tagName == "AUDIO" || x.tagName == "VIDEO";
});
} else {
var mediaTags = getShadow(document.body).filter(x => x.tagName == "VIDEO");
}
mediaTags.forEach = Array.prototype.forEach;
// 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);
// 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);
}
if (!v.classList.contains("vsc-cancelled")) {
if (action === "rewind") {
log("Rewind", 5);
v.currentTime -= value;
} else if (action === "advance") {
log("Fast forward", 5);
v.currentTime += value;
} else if (action === "faster") {
log("Increase speed", 5);
// Maximum playback speed in Chrome is set to 16:
// https://cs.chromium.org/chromium/src/third_party/blink/renderer/core/html/media/html_media_element.cc?gsn=kMinRate&l=166
var s = Math.min(
(v.playbackRate < 0.1 ? 0.0 : v.playbackRate) + value,
16
);
setSpeed(controller, 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);
} else if (action === "reset") {
log("Reset speed", 5);
resetSpeed(v, controller, 1.0);
} else if (action === "display") {
log("Showing controller", 5);
controller.classList.add("vsc-manual");
controller.classList.toggle("vsc-hidden");
} else if (action === "blink") {
log("Showing controller momentarily", 5);
// if vsc is hidden, show it briefly to give the use visual feedback that the action is excuted.
if (
controller.classList.contains("vsc-hidden") ||
controller.blinkTimeOut !== undefined
) {
clearTimeout(controller.blinkTimeOut);
controller.classList.remove("vsc-hidden");
controller.blinkTimeOut = setTimeout(
() => {
controller.classList.add("vsc-hidden");
controller.blinkTimeOut = undefined;
},
value ? value : 1000
);
}
} else if (action === "drag") {
handleDrag(v, controller, e);
} else if (action === "fast") {
resetSpeed(v, controller, value);
} else if (action === "pause") {
pause(v);
} else if (action === "muted") {
muted(v, value);
} else if (action === "mark") {
setMark(v);
} else if (action === "jump") {
jumpToMark(v);
}
}
});
log("runAction End", 5);
}
function pause(v) {
if (v.paused) {
log("Resuming video", 5);
v.play();
} else {
log("Pausing video", 5);
v.pause();
}
}
function resetSpeed(v, controller, 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);
} else {
log('Toggling playback speed to "fast" speed', 4);
setSpeed(controller, v, getKeyBindings("fast"));
}
} else {
log('Toggling playback speed to "reset" speed', 4);
setSpeed(controller, v, getKeyBindings("reset"));
}
} else {
log('Toggling playback speed to "reset" speed', 4);
setKeyBindings("reset", v.playbackRate);
setSpeed(controller, v, target);
}
}
function muted(v, value) {
v.muted = v.muted !== true;
}
function setMark(v) {
log("Adding marker", 5);
v.vsc.mark = v.currentTime;
}
function jumpToMark(v) {
log("Recalling marker", 5);
if (v.vsc.mark && typeof v.vsc.mark === "number") {
v.currentTime = v.vsc.mark;
}
}
function handleDrag(video, controller, e) {
const shadowController = controller.shadowRoot.querySelector("#controller");
// Find nearest parent of same size as video parent.
var parentElement = controller.parentElement;
while (
parentElement.parentNode &&
parentElement.parentNode.offsetHeight === parentElement.offsetHeight &&
parentElement.parentNode.offsetWidth === parentElement.offsetWidth
) {
parentElement = parentElement.parentNode;
}
video.classList.add("vcs-dragging");
shadowController.classList.add("dragging");
const initialMouseXY = [e.clientX, e.clientY];
const initialControllerXY = [
parseInt(shadowController.style.left),
parseInt(shadowController.style.top)
];
const startDragging = e => {
let style = shadowController.style;
let dx = e.clientX - initialMouseXY[0];
let dy = e.clientY - initialMouseXY[1];
style.left = initialControllerXY[0] + dx + "px";
style.top = initialControllerXY[1] + dy + "px";
};
const stopDragging = () => {
parentElement.removeEventListener("mousemove", startDragging);
parentElement.removeEventListener("mouseup", stopDragging);
parentElement.removeEventListener("mouseleave", stopDragging);
shadowController.classList.remove("dragging");
video.classList.remove("vcs-dragging");
};
parentElement.addEventListener("mouseup", stopDragging);
parentElement.addEventListener("mouseleave", stopDragging);
parentElement.addEventListener("mousemove", startDragging);
}
var timer;
var animation = false;
function showController(controller) {
log("Showing controller", 4);
controller.classList.add("vcs-show");
if (animation) clearTimeout(timer);
animation = true;
timer = setTimeout(function() {
controller.classList.remove("vcs-show");
animation = false;
log("Hiding controller", 5);
}, 2000);
}