8 Commits

5 changed files with 652 additions and 840 deletions

View File

@@ -1,3 +1,7 @@
[![Add to Firefox](https://img.shields.io/badge/Add%20to-Firefox-orange?logo=firefox&logoColor=white)](https://addons.mozilla.org/en-US/firefox/addon/video-speed-controller-v1/)
# The science of accelerated playback
**TL;DR: faster playback translates to better engagement and retention.**
@@ -74,10 +78,10 @@ You can try manually disabling Flash from the browser.
[`igrigorik/videospeed`](https://github.com/igrigorik/videospeed) repository
is a port of [`igrigorik`](https://github.com/igrigorik)'s videospeed Chrome
add-on for Firefox. This fork modifies the Chrome add-on code so that it works
in Firefox. This repo is the code behind the [Firefox Extension](https://addons.mozilla.org/en-us/firefox/addon/videospeed/)
in Firefox. This repo is the code behind the [Firefox Extension](https://addons.mozilla.org/en-US/firefox/addon/video-speed-controller-v1/)
whereas the [`igrigorik/videospeed`](https://github.com/igrigorik/videospeed)
repository contains the code behind the [Chrome Extension](https://chrome.google.com/webstore/detail/video-speed-controller/nffaoalbilbmmfgbnbgppjihopabppdk).
### License
(MIT License) - Copyright (c) 2014 Ilya Grigorik
(MIT License) - Copyright (c) 2025 Josh Patra

1136
inject.js
View File

@@ -2,16 +2,15 @@ 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
forceLastSavedSpeed: false, //default: false
audioBoolean: false, // default: false
startHidden: false, // default: false
controllerOpacity: 0.3, // default: 0.3
lastSpeed: 1.0,
enabled: true,
speeds: {},
displayKeyCode: 86,
rememberSpeed: false,
forceLastSavedSpeed: false,
audioBoolean: false,
startHidden: false,
controllerOpacity: 0.3,
keyBindings: [],
blacklist: `\
www.instagram.com
@@ -21,36 +20,26 @@ var tc = {
teams.microsoft.com
`.replace(regStrip, ""),
defaultLogLevel: 4,
logLevel: 3,
// --- Nudge settings (ADDED) ---
logLevel: 5, // Set to 5 to see your debug logs
enableSubtitleNudge: true,
subtitleNudgeInterval: 25,
subtitleNudgeAmount: 0.001
},
// Holds a reference to all of the AUDIO/VIDEO DOM elements we've attached to
mediaElements: [],
isNudging: false // Flag for nudge operation (ADDED)
isNudging: false
};
/* Log levels (depends on caller specifying the correct level) */
/* Log levels */
function log(message, level) {
verbosity = tc.settings.logLevel;
if (typeof level === "undefined") {
level = tc.settings.defaultLogLevel;
}
if (typeof level === "undefined") level = tc.settings.defaultLogLevel;
if (verbosity >= level) {
// MODIFIED: Added [VSC] prefix for clarity
let prefix = "[VSC] ";
if (level === 2) {
console.log(prefix + "ERROR: " + message);
} else if (level === 3) {
console.log(prefix + "WARNING: " + message);
} else if (level === 4) {
console.log(prefix + "INFO: " + message);
} else if (level === 5) {
console.log(prefix + "DEBUG: " + message);
} else if (level === 6) {
if (level === 2) console.log(prefix + "ERROR: " + message);
else if (level === 3) console.log(prefix + "WARNING: " + message);
else if (level === 4) console.log(prefix + "INFO: " + message);
else if (level === 5) console.log(prefix + "DEBUG: " + message);
else if (level === 6) {
console.log(prefix + "DEBUG (VERBOSE): " + message);
console.trace();
}
@@ -58,227 +47,146 @@ function log(message, level) {
}
chrome.storage.sync.get(tc.settings, function (storage) {
// MODIFIED: Robust keyBinding initialization from storage or defaults.
tc.settings.keyBindings =
Array.isArray(storage.keyBindings) &&
storage.keyBindings.length > 0 &&
storage.keyBindings[0].hasOwnProperty("predefined")
? storage.keyBindings
: [
{
action: "slower",
key: Number(storage.slowerKeyCode) || 83,
value: Number(storage.speedStep) || 0.1,
force: false,
predefined: true
},
{
action: "faster",
key: Number(storage.fasterKeyCode) || 68,
value: Number(storage.speedStep) || 0.1,
force: false,
predefined: true
},
{
action: "rewind",
key: Number(storage.rewindKeyCode) || 90,
value: Number(storage.rewindTime) || 10,
force: false,
predefined: true
},
{
action: "advance",
key: Number(storage.advanceKeyCode) || 88,
value: Number(storage.advanceTime) || 10,
force: false,
predefined: true
},
{
action: "reset",
key: Number(storage.resetKeyCode) || 82,
value: 1.0,
force: false,
predefined: true
}, // Default value for reset action is 1.0
{
action: "fast",
key: Number(storage.fastKeyCode) || 71,
value: Number(storage.fastSpeed) || 1.8,
force: false,
predefined: true
}
];
if (
!Array.isArray(storage.keyBindings) ||
storage.keyBindings.length === 0 ||
(storage.keyBindings.length > 0 &&
!storage.keyBindings[0].hasOwnProperty("predefined"))
) {
log("Initializing/Updating keybindings in storage.", 4);
// Original initialization from your code
tc.settings.keyBindings = storage.keyBindings;
if (storage.keyBindings.length == 0) {
tc.settings.keyBindings.push({
action: "slower",
key: Number(storage.slowerKeyCode) || 83,
value: Number(storage.speedStep) || 0.1,
force: false,
predefined: true
});
tc.settings.keyBindings.push({
action: "faster",
key: Number(storage.fasterKeyCode) || 68,
value: Number(storage.speedStep) || 0.1,
force: false,
predefined: true
});
tc.settings.keyBindings.push({
action: "rewind",
key: Number(storage.rewindKeyCode) || 90,
value: Number(storage.rewindTime) || 10,
force: false,
predefined: true
});
tc.settings.keyBindings.push({
action: "advance",
key: Number(storage.advanceKeyCode) || 88,
value: Number(storage.advanceTime) || 10,
force: false,
predefined: true
});
tc.settings.keyBindings.push({
action: "reset",
key: Number(storage.resetKeyCode) || 82,
value: 1.0,
force: false,
predefined: true
});
tc.settings.keyBindings.push({
action: "fast",
key: Number(storage.fastKeyCode) || 71,
value: Number(storage.fastSpeed) || 1.8,
force: false,
predefined: true
});
tc.settings.version = "0.5.3";
chrome.storage.sync.set({
keyBindings: tc.settings.keyBindings,
version: "0.6.3.8"
}); // Update version
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,
controllerOpacity: tc.settings.controllerOpacity,
blacklist: tc.settings.blacklist.replace(regStrip, "")
});
}
tc.settings.lastSpeed = Number(storage.lastSpeed) || 1.0;
tc.settings.displayKeyCode = Number(storage.displayKeyCode) || 86;
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 =
typeof storage.enabled !== "undefined" ? Boolean(storage.enabled) : true;
tc.settings.enabled = Boolean(storage.enabled);
tc.settings.startHidden = Boolean(storage.startHidden);
tc.settings.controllerOpacity = Number(storage.controllerOpacity) || 0.3;
tc.settings.blacklist = String(storage.blacklist || tc.settings.blacklist);
if (typeof storage.logLevel !== "undefined") {
tc.settings.logLevel = Number(storage.logLevel);
}
// ADDED: Load nudge settings from storage
tc.settings.controllerOpacity = Number(storage.controllerOpacity);
tc.settings.blacklist = String(storage.blacklist);
tc.settings.enableSubtitleNudge =
typeof storage.enableSubtitleNudge !== "undefined"
? Boolean(storage.enableSubtitleNudge)
: tc.settings.enableSubtitleNudge;
tc.settings.subtitleNudgeInterval =
Number(storage.subtitleNudgeInterval) || 25; // Using 25ms as requested
Number(storage.subtitleNudgeInterval) || 25;
tc.settings.subtitleNudgeAmount =
Number(storage.subtitleNudgeAmount) || tc.settings.subtitleNudgeAmount;
if (
tc.settings.keyBindings.filter((x) => x.action == "display").length == 0
) {
tc.settings.keyBindings.push({
action: "display",
key: tc.settings.displayKeyCode,
key: Number(storage.displayKeyCode) || 86,
value: 0,
force: false,
predefined: true
});
}
initializeWhenReady(document);
});
function getKeyBindings(action, what = "value") {
if (!tc.settings.keyBindings) return false; // ADDED: Guard against undefined keyBindings
try {
const binding = tc.settings.keyBindings.find(
(item) => item.action === action
);
if (binding) return binding[what];
// Fallback defaults for safety
if (what === "value") {
if (action === "slower" || action === "faster") return 0.1;
if (action === "rewind" || action === "advance") return 10;
if (action === "reset") return 1.0;
if (action === "fast") return 1.8;
}
return false;
return tc.settings.keyBindings.find((item) => item.action === action)[what];
} catch (e) {
log(`Error in getKeyBindings for ${action} (${what}): ${e.message}`, 2);
return false;
}
}
// Original setKeyBindings, used by original resetSpeed logic.
function setKeyBindings(action, value) {
if (!tc.settings.keyBindings) return; // ADDED: Guard
const binding = tc.settings.keyBindings.find(
(item) => item.action === action
);
if (binding) {
binding["value"] = value;
log(
`In-memory value for keyBinding '${action}' set to ${value} by original setKeyBindings func`,
6
);
}
tc.settings.keyBindings.find((item) => item.action === action)["value"] =
value;
}
function defineVideoController() {
tc.videoController = function (target, parent) {
if (target.vsc) {
log(`VSC controller already exists for ${target.src || "video"}.`, 6);
return target.vsc;
}
log(`Creating VSC controller for ${target.src || "video"}.`, 4);
if (target.vsc) return target.vsc;
tc.mediaElements.push(target);
target.vsc = this;
this.video = target;
this.parent = parent || target.parentElement;
this.nudgeIntervalId = null; // ADDED: For the subtitle nudge feature
// Original logic for determining initial speed
let storedSpeed;
this.parent = target.parentElement || parent;
this.nudgeIntervalId = null;
let storedSpeed = tc.settings.speeds[target.currentSrc];
if (!tc.settings.rememberSpeed) {
storedSpeed = tc.settings.speeds[target.currentSrc];
if (!storedSpeed) {
log(
"Overwriting stored speed to 1.0 due to rememberSpeed being disabled or no speed for src",
5
);
storedSpeed = 1.0;
}
setKeyBindings("reset", getKeyBindings("fast")); // Original logic for 'R' key toggle state
setKeyBindings("reset", getKeyBindings("fast"));
} else {
log("Recalling stored speed due to rememberSpeed being enabled", 5);
storedSpeed =
tc.settings.speeds[target.currentSrc] || tc.settings.lastSpeed;
storedSpeed = tc.settings.lastSpeed;
}
if (tc.settings.forceLastSavedSpeed) {
storedSpeed = tc.settings.lastSpeed;
}
target.playbackRate = storedSpeed;
this.div = this.initializeControls();
if (Math.abs(target.playbackRate - storedSpeed) > 0.001) {
log(
`Video current rate ${target.playbackRate.toFixed(2)} differs from VSC target ${storedSpeed.toFixed(2)}. Setting speed (initial).`,
4
);
setSpeed(target, storedSpeed, true); // MODIFIED: Pass true for isInitialSet
} else {
if (this.speedIndicator)
this.speedIndicator.textContent = storedSpeed.toFixed(2);
if (
!tc.settings.forceLastSavedSpeed &&
tc.settings.lastSpeed !== storedSpeed
) {
tc.settings.lastSpeed = storedSpeed;
}
}
// Original mediaEventAction
var mediaEventAction = function (event) {
const video = event.target;
if (!video.vsc) return;
let speedToSet = tc.settings.speeds[video.currentSrc]; // Use 'let'
let storedSpeed = tc.settings.speeds[event.target.currentSrc];
if (!tc.settings.rememberSpeed) {
if (!speedToSet) speedToSet = 1.0;
if (!storedSpeed) {
storedSpeed = 1.0;
}
setKeyBindings("reset", getKeyBindings("fast"));
} else {
speedToSet = tc.settings.lastSpeed;
storedSpeed = tc.settings.lastSpeed;
}
if (tc.settings.forceLastSavedSpeed) speedToSet = tc.settings.lastSpeed;
if (Math.abs(video.playbackRate - speedToSet) > 0.001) {
log(
`Media event '${event.type}': rate ${video.playbackRate.toFixed(2)} vs target ${speedToSet.toFixed(2)}. Setting.`,
4
);
setSpeed(video, speedToSet, false); // MODIFIED: isInitialSet is false
}
// ADDED: Manage nudge based on event type
if (event.type === "play") video.vsc.startSubtitleNudge();
if (tc.settings.forceLastSavedSpeed) storedSpeed = tc.settings.lastSpeed;
setSpeed(event.target, storedSpeed);
if (event.type === "play") this.startSubtitleNudge();
else if (event.type === "pause" || event.type === "ended")
video.vsc.stopSubtitleNudge();
this.stopSubtitleNudge();
};
target.addEventListener(
"play",
(this.handlePlay = mediaEventAction.bind(this))
@@ -286,16 +194,15 @@ function defineVideoController() {
target.addEventListener(
"pause",
(this.handlePause = mediaEventAction.bind(this))
); // ADDED for nudge
);
target.addEventListener(
"ended",
(this.handleEnded = mediaEventAction.bind(this))
); // ADDED for nudge
);
target.addEventListener(
"seeked",
(this.handleSeek = mediaEventAction.bind(this))
);
var srcObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (
@@ -303,54 +210,87 @@ function defineVideoController() {
(mutation.attributeName === "src" ||
mutation.attributeName === "currentSrc")
) {
if (!this.div) return; // MODIFIED: Check if div exists
log(`Src changed to: ${mutation.target.currentSrc || "empty"}`, 4);
this.stopSubtitleNudge(); // ADDED: Stop nudge for old src
if (!mutation.target.src && !mutation.target.currentSrc) {
this.div.classList.add("vsc-nosource");
} else {
this.div.classList.remove("vsc-nosource");
let newSrcSpeed = tc.settings.speeds[mutation.target.currentSrc]; // MODIFIED: Follow original logic
if (!tc.settings.rememberSpeed) {
if (!newSrcSpeed) newSrcSpeed = 1.0;
if (this.div) {
this.stopSubtitleNudge();
if (!mutation.target.src && !mutation.target.currentSrc) {
this.div.classList.add("vsc-nosource");
} else {
newSrcSpeed = newSrcSpeed || tc.settings.lastSpeed;
this.div.classList.remove("vsc-nosource");
if (!mutation.target.paused) this.startSubtitleNudge();
}
if (tc.settings.forceLastSavedSpeed)
newSrcSpeed = tc.settings.lastSpeed;
setSpeed(mutation.target, newSrcSpeed, true); // MODIFIED: isInitialSet = true
if (!mutation.target.paused && mutation.target.playbackRate !== 1.0)
this.startSubtitleNudge(); // ADDED
}
}
});
});
srcObserver.observe(target, { attributeFilter: ["src", "currentSrc"] });
// ADDED: Initial nudge check
if (!target.paused && target.playbackRate !== 1.0) {
if (!target.paused && target.playbackRate !== 1.0)
this.startSubtitleNudge();
}
};
// --- Nudge Methods (ADDED) ---
tc.videoController.prototype.remove = function () {
this.stopSubtitleNudge();
if (this.div) this.div.remove();
if (this.video) {
this.video.removeEventListener("play", this.handlePlay);
this.video.removeEventListener("pause", this.handlePause);
this.video.removeEventListener("ended", this.handleEnded);
this.video.removeEventListener("seeked", this.handleSeek);
delete this.video.vsc;
}
let idx = tc.mediaElements.indexOf(this.video);
if (idx != -1) tc.mediaElements.splice(idx, 1);
};
// MODIFIED: Using your debug-enhanced startSubtitleNudge function
tc.videoController.prototype.startSubtitleNudge = function () {
console.log("[VSC DEBUG] startSubtitleNudge called");
console.log("[VSC DEBUG] location.hostname:", location.hostname);
console.log(
"[VSC DEBUG] enableSubtitleNudge:",
tc.settings.enableSubtitleNudge
);
console.log("[VSC DEBUG] video element:", this.video);
console.log(
"[VSC DEBUG] video src:",
this.video ? this.video.src : "no video"
);
console.log(
"[VSC DEBUG] video currentSrc:",
this.video ? this.video.currentSrc : "no video"
);
console.log(
"[VSC DEBUG] video paused:",
this.video ? this.video.paused : "no video"
);
console.log(
"[VSC DEBUG] video playbackRate:",
this.video ? this.video.playbackRate : "no video"
);
const isYouTube =
(this.video &&
this.video.currentSrc &&
this.video.currentSrc.includes("googlevideo.com")) ||
location.hostname.includes("youtube.com");
if (!isYouTube) return;
if (
!tc.settings.enableSubtitleNudge ||
this.nudgeIntervalId !== null ||
!this.video
)
) {
console.log("[VSC DEBUG] Nudge blocked - reasons:", {
enableSubtitleNudge: tc.settings.enableSubtitleNudge,
nudgeIntervalId: this.nudgeIntervalId,
hasVideo: !!this.video
});
return;
}
if (this.video.paused || this.video.playbackRate === 1.0) {
console.log("[VSC DEBUG] Nudge stopped - video paused or 1.0x speed");
this.stopSubtitleNudge();
return;
}
log(
`Nudge: Starting for ${this.video.currentSrc || "video"} (Rate: ${this.video.playbackRate.toFixed(2)}) interval: ${tc.settings.subtitleNudgeInterval}ms.`,
5
);
console.log("[VSC DEBUG] Starting nudge interval");
log(`Nudge: Starting interval: ${tc.settings.subtitleNudgeInterval}ms.`, 5);
this.nudgeIntervalId = setInterval(() => {
if (
!this.video ||
@@ -363,10 +303,8 @@ function defineVideoController() {
}
const currentRate = this.video.playbackRate;
const nudgeAmount = tc.settings.subtitleNudgeAmount;
tc.isNudging = true;
this.video.playbackRate = currentRate + nudgeAmount;
requestAnimationFrame(() => {
if (
this.video &&
@@ -382,61 +320,25 @@ function defineVideoController() {
tc.videoController.prototype.stopSubtitleNudge = function () {
if (this.nudgeIntervalId !== null) {
log(
`Nudge: Stopping for ${this.video ? this.video.currentSrc || "video" : "detached video"}`,
5
);
log(`Nudge: Stopping.`, 5);
clearInterval(this.nudgeIntervalId);
this.nudgeIntervalId = null;
}
};
// --- End Nudge Methods ---
tc.videoController.prototype.remove = function () {
this.stopSubtitleNudge(); // ADDED
// Original remove logic:
if (this.div && this.div.parentNode) this.div.remove();
if (this.video) {
this.video.removeEventListener("play", this.handlePlay);
this.video.removeEventListener("pause", this.handlePause); // ADDED
this.video.removeEventListener("ended", this.handleEnded); // ADDED
this.video.removeEventListener("seeked", this.handleSeek); // MODIFIED: was "seek" in original provided code
delete this.video.vsc;
}
let idx = tc.mediaElements.indexOf(this.video);
if (idx !== -1) tc.mediaElements.splice(idx, 1);
};
// Original initializeControls
tc.videoController.prototype.initializeControls = function () {
log("initializeControls Begin", 5);
const doc = this.video.ownerDocument;
const speedForUI = this.video.playbackRate.toFixed(2);
const speed = this.video.playbackRate.toFixed(2);
var top = Math.max(this.video.offsetTop, 0) + "px",
left = Math.max(this.video.offsetLeft, 0) + "px";
log("Speed variable for UI set to: " + speedForUI, 5);
var wrapper = doc.createElement("div");
wrapper.classList.add("vsc-controller");
if (!this.video.src && !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">${speedForUI}</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">×</button>
</span>
</div>`;
shadow.innerHTML = shadowTemplate;
this.speedIndicator = shadow.querySelector(".draggable"); // MODIFIED: Original was "span"
// MODIFIED: Pass this.video as 4th arg to runAction
shadow.innerHTML = `<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">×</button></span></div>`;
this.speedIndicator = shadow.querySelector(".draggable");
shadow.querySelector(".draggable").addEventListener(
"mousedown",
(e) => {
@@ -478,7 +380,6 @@ function defineVideoController() {
doc.body.appendChild(fragment);
return wrapper;
}
// Original placement logic
switch (true) {
case location.hostname == "www.amazon.com":
case location.hostname == "www.reddit.com":
@@ -490,18 +391,13 @@ function defineVideoController() {
parentEl.parentElement.parentElement.parentElement.parentElement
.parentElement.parentElement.parentElement;
if (p && p.firstChild) p.insertBefore(fragment, p.firstChild);
else if (parentEl.firstChild)
parentEl.insertBefore(fragment, parentEl.firstChild);
else parentEl.appendChild(fragment);
else parentEl.insertBefore(fragment, parentEl.firstChild);
break;
case location.hostname == "tv.apple.com":
const appleRoot = parentEl.getRootNode();
const scrim =
appleRoot && appleRoot.querySelector
? appleRoot.querySelector(".scrim")
: null;
if (scrim) scrim.prepend(fragment);
else parentEl.insertBefore(fragment, parentEl.firstChild);
const r = parentEl.getRootNode();
const s = r && r.querySelector ? r.querySelector(".scrim") : null;
if (s) s.prepend(fragment);
else parentEl.insertBefore(fragment, pEl.firstChild);
break;
default:
parentEl.insertBefore(fragment, parentEl.firstChild);
@@ -511,204 +407,95 @@ function defineVideoController() {
}
function escapeStringRegExp(str) {
const matchOperatorsRe = /[|\\{}()[\]^$+*?.]/g;
return str.replace(matchOperatorsRe, "\\$&");
const m = /[|\\{}()[\]^$+*?.]/g;
return str.replace(m, "\\$&");
}
function isBlacklisted() {
let blacklisted = false;
const blacklistLines = tc.settings.blacklist
? tc.settings.blacklist.split("\n")
: [];
blacklistLines.forEach((match) => {
if (blacklisted) return;
match = match.replace(regStrip, "");
if (match.length == 0) return;
let regexp;
if (match.startsWith("/") && match.lastIndexOf("/") > 0) {
let b = false;
const l = tc.settings.blacklist ? tc.settings.blacklist.split("\n") : [];
l.forEach((m) => {
if (b) return;
m = m.replace(regStrip, "");
if (m.length == 0) return;
let r;
if (m.startsWith("/") && m.lastIndexOf("/") > 0) {
try {
const ls = match.lastIndexOf("/");
regexp = new RegExp(match.substring(1, ls), match.substring(ls + 1));
} catch (err) {
log(`Invalid regex in blacklist: ${match}. Error: ${err.message}`, 2);
const ls = m.lastIndexOf("/");
r = new RegExp(m.substring(1, ls), m.substring(ls + 1));
} catch (e) {
log(`Invalid regex: ${m}. ${e.message}`, 2);
return;
}
} else regexp = new RegExp(escapeStringRegExp(match));
if (regexp && regexp.test(location.href)) blacklisted = true;
} else r = new RegExp(escapeStringRegExp(m));
if (r && r.test(location.href)) b = true;
});
if (blacklisted) log(`Page ${location.href} is blacklisted.`, 4);
return blacklisted;
if (b) log(`Page ${location.href} blacklisted.`, 4);
return b;
}
var coolDown = false;
function refreshCoolDown() {
log("Begin refreshCoolDown", 5);
if (coolDown) clearTimeout(coolDown);
coolDown = setTimeout(function () {
coolDown = false;
}, 1000);
// log("End refreshCoolDown", 6); // Original log level was 5
}
function setupListener() {
if (document.vscRateListenerAttached) return; // MODIFIED: Ensure flag check
// MODIFIED: fromUserInput parameter added
if (document.vscRateListenerAttached) return;
function updateSpeedFromEvent(video, fromUserInput = false) {
if (!video.vsc || !video.vsc.speedIndicator) return;
var speed = Number(video.playbackRate.toFixed(2));
log(
`updateSpeedFromEvent: Rate is ${speed}. FromUserInput: ${fromUserInput}`,
4
);
video.vsc.speedIndicator.textContent = speed.toFixed(2);
tc.settings.speeds[video.currentSrc || "unknown_src"] = speed;
tc.settings.lastSpeed = speed;
chrome.storage.sync.set({ lastSpeed: speed }, () => {
if (chrome.runtime.lastError)
log(`Error saving lastSpeed: ${chrome.runtime.lastError.message}`, 2);
});
// MODIFIED: Only "blink" (show controller) if change was from user input
chrome.storage.sync.set({ lastSpeed: speed }, () => {});
if (fromUserInput) {
runAction("blink", getKeyBindings("blink", "value") || 1000, null, video);
}
if (video.vsc) {
// MODIFIED: Manage nudge based on new speed
if (speed === 1.0 || video.paused) video.vsc.stopSubtitleNudge();
else video.vsc.startSubtitleNudge();
}
}
document.addEventListener(
"ratechange",
function (event) {
// ADDED: Check tc.isNudging at the very start
if (tc.isNudging) {
// log("Ratechange event during nudge, VSC UI/state update skipped. Allowing propagation for YT.", 6);
return;
}
// Original coolDown logic
if (tc.isNudging) return;
if (coolDown) {
log("Speed event propagation blocked by coolDown", 4);
event.stopImmediatePropagation();
return;
}
var video = event.target;
if (!video || typeof video.playbackRate === "undefined" || !video.vsc)
return;
const eventOrigin = event.detail && event.detail.origin;
let isFromUserInputForBlink = false; // MODIFIED: Flag to control blink
if (tc.settings.forceLastSavedSpeed) {
if (eventOrigin === "videoSpeed") {
if (event.detail.speed) {
const detailSpeedNum = Number(event.detail.speed);
if (
!isNaN(detailSpeedNum) &&
Math.abs(video.playbackRate - detailSpeedNum) > 0.001
) {
video.playbackRate = detailSpeedNum;
}
}
isFromUserInputForBlink = event.detail.fromUserInput !== false; // Respect passed flag
updateSpeedFromEvent(video, isFromUserInputForBlink);
event.stopImmediatePropagation();
if (event.detail && event.detail.origin === "videoSpeed") {
video.playbackRate = event.detail.speed;
updateSpeedFromEvent(video, event.detail.fromUserInput === true);
} else {
if (Math.abs(video.playbackRate - tc.settings.lastSpeed) > 0.001) {
log(
`Ratechange (Force ON): Discrepancy. Video rate: ${video.playbackRate.toFixed(2)}, VSC wants: ${tc.settings.lastSpeed.toFixed(2)}. Forcing.`,
3
);
video.playbackRate = tc.settings.lastSpeed;
event.stopImmediatePropagation();
// The next ratechange will be from VSC forcing, consider that not direct user input for blink
// updateSpeedFromEvent will be called by that next event.
} else {
updateSpeedFromEvent(video, false); // Not user input, just confirming forced speed
}
video.playbackRate = tc.settings.lastSpeed;
}
event.stopImmediatePropagation();
} else {
// forceLastSavedSpeed is OFF
// Determine if it was a VSC-initiated user action (like S/D keys)
// The `setSpeed` function, when called by `runAction`, doesn't add a special origin detail
// when forceLastSavedSpeed is off. So, a native ratechange event fires.
// We assume if forceLastSavedSpeed is off, any rate change processed here
// that isn't a nudge IS significant enough to update UI state.
// The "blink" should happen if tc.settings.lastSpeed *changed* due to this event,
// implying it wasn't just a confirmation of existing speed.
const oldLastSpeed = tc.settings.lastSpeed;
updateSpeedFromEvent(video, false); // Initially assume not user-driven for blink
if (
Math.abs(oldLastSpeed - tc.settings.lastSpeed) > 0.001 &&
oldLastSpeed !== 1.0 &&
tc.settings.lastSpeed !== 1.0
) {
// If lastSpeed actually changed due to this event, it was likely a user action via VSC
// or a significant external change. Trigger blink.
// Exception: don't blink if going to/from 1.0x as that's often a reset.
// This logic is imperfect for determining "user input" when not forcing.
// A cleaner way would be if setSpeed could flag the *next* native event.
// For now, this is a heuristic.
if (!tc.isNudging) {
// Double check not a nudge, though already filtered
runAction(
"blink",
getKeyBindings("blink", "value") || 1000,
null,
video
);
}
}
updateSpeedFromEvent(video, video.vscIsDirectlySettingRate === true);
if (video.vscIsDirectlySettingRate)
delete video.vscIsDirectlySettingRate;
}
},
true
);
document.vscRateChangeListenerAttached = true; // MODIFIED: Ensure flag is set
document.vscRateListenerAttached = true;
}
// MODIFIED: More robust initialization flow with unique flags
var vscInitializedDocuments = new Set();
function initializeWhenReady(doc) {
if (doc.vscInitWhenReadyCalledFullUniqueFlag && doc.readyState !== "loading")
return;
doc.vscInitWhenReadyCalledFullUniqueFlag = true;
if (isBlacklisted()) {
return;
}
log(
`initializeWhenReady for: ${doc.location ? doc.location.href : "iframe"}. ReadyState: ${doc.readyState}`,
5
);
if (doc === window.document && !window.vscPageLoadListenerFullUniqueFlag) {
window.addEventListener("load", () => initializeNow(window.document), {
once: true
});
window.vscPageLoadListenerFullUniqueFlag = true;
}
if (vscInitializedDocuments.has(doc) || !doc.body) return;
if (doc.readyState === "complete") {
initializeNow(doc);
} else {
if (!doc.vscReadyStateListenerFullUniqueFlag) {
doc.addEventListener(
"readystatechange",
function onRSChange_VSC_Final_Unique_CB() {
if (doc.readyState === "complete") {
doc.removeEventListener(
"readystatechange",
onRSChange_VSC_Final_Unique_CB
);
initializeNow(doc);
}
}
);
doc.vscReadyStateListenerFullUniqueFlag = true;
}
doc.addEventListener("DOMContentLoaded", () => initializeNow(doc), {
once: true
});
}
}
function inIframe() {
@@ -719,195 +506,224 @@ function inIframe() {
}
}
function getShadow(parent) {
let result = [];
function getChild(p) {
let r = [];
function gC(p) {
if (p.firstElementChild) {
var c = p.firstElementChild;
do {
result.push(c);
getChild(c);
if (c.shadowRoot) result.push(...getShadow(c.shadowRoot));
r.push(c);
gC(c);
if (c.shadowRoot) r.push(...getShadow(c.shadowRoot));
c = c.nextElementSibling;
} while (c);
}
}
getChild(parent);
return result;
gC(parent);
return r;
}
// MODIFIED: Replaced with your debug-enhanced initializeNow
function initializeNow(doc) {
if (vscInitializedDocuments.has(doc) || !doc.body) return;
log(
`initializeNow for doc: ${doc.location ? doc.location.href : "iframe"}`,
4
console.log(
"[VSC DEBUG] initializeNow called for:",
doc.location ? doc.location.hostname : "unknown doc"
);
if (!tc.settings.enabled) {
log("VSC is disabled.", 4);
return;
}
if (vscInitializedDocuments.has(doc) || !doc.body) return;
if (!tc.settings.enabled) return;
if (!doc.body.classList.contains("vsc-initialized"))
doc.body.classList.add("vsc-initialized");
if (typeof tc.videoController === "undefined") defineVideoController();
setupListener();
if (
inIframe() &&
doc !== window.top.document &&
!doc.head.querySelector('link[href*="inject.css"]')
) {
var link = doc.createElement("link");
link.href = chrome.runtime.getURL("inject.css");
link.type = "text/css";
link.rel = "stylesheet";
doc.head.appendChild(link);
}
const docsForKeydown = new Set([doc]);
// Re-inserting original keydown listener logic from your codebase
var docs = Array(doc);
try {
if (inIframe() && window.top.document)
docsForKeydown.add(window.top.document);
if (inIframe()) docs.push(window.top.document);
} catch (e) {}
docsForKeydown.forEach((lDoc) => {
if (!lDoc.vscKeydownListenerUniqueFlagB) {
// Different flag
lDoc.addEventListener(
"keydown",
function (event) {
if (!tc.settings.enabled) return;
const target = event.target;
if (
target.nodeName === "INPUT" ||
target.nodeName === "TEXTAREA" ||
target.isContentEditable
)
return;
if (
event.getModifierState &&
(event.getModifierState("Alt") ||
event.getModifierState("Control") ||
event.getModifierState("Meta") ||
event.getModifierState("Fn") ||
event.getModifierState("Hyper") ||
event.getModifierState("OS"))
)
return;
if (
tc.mediaElements.length === 0 &&
!lDoc.querySelector("video,audio")
)
return;
var item = tc.settings.keyBindings.find(
(kb) => kb.key === event.keyCode
);
if (item) {
runAction(item.action, item.value, event);
if (item.force === "true" || item.force === true) {
event.preventDefault();
event.stopPropagation();
}
docs.forEach(function (d) {
if (d.vscKeydownListenerAttached) return; // Prevent duplicate listeners
d.addEventListener(
"keydown",
function (event) {
var keyCode = event.keyCode;
if (
!event.getModifierState ||
event.getModifierState("Alt") ||
event.getModifierState("Control") ||
event.getModifierState("Fn") ||
event.getModifierState("Meta") ||
event.getModifierState("Hyper") ||
event.getModifierState("OS")
)
return;
if (
event.target.nodeName === "INPUT" ||
event.target.nodeName === "TEXTAREA" ||
event.target.isContentEditable
)
return;
if (!tc.mediaElements.length) return;
var item = tc.settings.keyBindings.find((item) => item.key === keyCode);
if (item) {
runAction(item.action, item.value, event);
if (item.force === "true") {
event.preventDefault();
event.stopPropagation();
}
}
return false;
},
true
);
d.vscKeydownListenerAttached = true;
});
// Original MutationObserver logic
if (!doc.vscMutationObserverAttached) {
const observer = new MutationObserver(function (mutations) {
requestIdleCallback(
(_) => {
mutations.forEach(function (mutation) {
switch (mutation.type) {
case "childList":
mutation.addedNodes.forEach(function (node) {
if (typeof node === "function") return;
checkForVideo(node, node.parentNode || mutation.target, true);
});
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;
}
});
},
true
{ timeout: 1000 }
);
lDoc.vscKeydownListenerUniqueFlagB = true;
}
});
if (!doc.vscMutationObserverUniqueFlagB) {
// Different flag
const obs = new MutationObserver((muts) => {
if (typeof requestIdleCallback === "function")
requestIdleCallback(() => processMutations(muts), { timeout: 1000 });
else setTimeout(() => processMutations(muts), 200);
});
function processMutations(mList) {
for (const m of mList) {
if (m.type === "childList") {
m.addedNodes.forEach((n) => {
if (n instanceof Element) chkVid(n, n.parentNode || m.target, true);
});
m.removedNodes.forEach((n) => {
if (n instanceof Element)
chkVid(n, n.parentNode || m.target, false);
});
} else if (
m.type === "attributes" &&
m.attributeName === "aria-hidden" &&
m.target instanceof Element &&
m.target.getAttribute("aria-hidden") === "false"
) {
const vidsInTgt = Array.from(getShadow(m.target)).filter(
(el) => el.tagName === "VIDEO"
);
vidsInTgt.forEach((vEl) => {
if (!vEl.vsc) chkVid(vEl, vEl.parentNode || m.target, true);
});
}
}
}
function chkVid(n, p, add) {
if (!add && !n.isConnected) {
} else if (!add && n.isConnected) return;
function checkForVideo(node, parent, added) {
if (!added && document.body.contains(node)) return;
if (
n.nodeName === "VIDEO" ||
(n.nodeName === "AUDIO" && tc.settings.audioBoolean)
node.nodeName === "VIDEO" ||
(node.nodeName === "AUDIO" && tc.settings.audioBoolean)
) {
if (add) {
if (!n.vsc) new tc.videoController(n, p);
if (added) {
if (!node.vsc) node.vsc = new tc.videoController(node, parent);
} else {
if (n.vsc) n.vsc.remove();
if (node.vsc) 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);
}
} else if (n.children && n.children.length > 0) {
for (let i = 0; i < n.children.length; i++)
chkVid(n.children[i], n.children[i].parentNode || p, add);
}
}
obs.observe(doc, {
observer.observe(doc, {
attributeFilter: ["aria-hidden"],
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["aria-hidden"]
subtree: true
});
doc.vscMutationObserverUniqueFlagB = true;
doc.vscMutationObserverAttached = true;
}
const q = tc.settings.audioBoolean ? "video,audio" : "video";
doc.querySelectorAll(q).forEach((vid) => {
if (!vid.vsc) new tc.videoController(vid, vid.parentElement);
const foundVideos = doc.querySelectorAll(q);
console.log(
"[VSC DEBUG] Found videos:",
foundVideos.length,
"in doc:",
doc.location ? doc.location.hostname : "unknown"
);
foundVideos.forEach((v) => {
if (!v.vsc) new tc.videoController(v, v.parentElement);
});
Array.from(doc.getElementsByTagName("iframe")).forEach((fr) => {
// Your enhanced iframe handling
Array.from(doc.getElementsByTagName("iframe")).forEach((f) => {
console.log("[VSC DEBUG] Found iframe:", f.src);
if (f.vscLoadListenerAttached) return; // Prevent attaching multiple load listeners
f.addEventListener("load", () => {
console.log("[VSC DEBUG] Iframe loaded, attempting to access");
try {
if (f.contentDocument) {
initializeWhenReady(f.contentDocument);
}
} catch (e) {
console.log(
"[VSC DEBUG] Still cannot access iframe after load:",
e.message
);
}
});
f.vscLoadListenerAttached = true;
try {
if (fr.contentDocument) initializeWhenReady(fr.contentDocument);
} catch (e) {}
if (f.contentDocument) {
initializeWhenReady(f.contentDocument);
}
} catch (e) {
console.log("[VSC DEBUG] Error accessing iframe immediately:", e.message);
}
});
vscInitializedDocuments.add(doc);
}
// MODIFIED setSpeed to accept `isInitialCall` and use it for `fromUserInput`
function setSpeed(video, speed, isInitialCall = false) {
// MODIFIED: setSpeed now takes isUserKeyPress for blink logic
function setSpeed(video, speed, isInitialCall = false, isUserKeyPress = false) {
const numericSpeed = Number(speed);
if (isNaN(numericSpeed) || numericSpeed <= 0 || numericSpeed > 16) return;
if (!video || !video.vsc || !video.vsc.speedIndicator) return;
log(
`setSpeed: Target ${numericSpeed.toFixed(2)}. Initial: ${isInitialCall}`,
`setSpeed: Target ${numericSpeed.toFixed(2)}. Initial: ${isInitialCall}. UserKeyPress: ${isUserKeyPress}`,
4
);
tc.settings.lastSpeed = numericSpeed;
video.vsc.speedIndicator.textContent = numericSpeed.toFixed(2);
if (isUserKeyPress && !isInitialCall && video.vsc && video.vsc.div) {
runAction("blink", null, null, video); // Pass video to blink
}
if (tc.settings.forceLastSavedSpeed) {
video.dispatchEvent(
new CustomEvent("ratechange", {
detail: {
origin: "videoSpeed",
speed: numericSpeed.toFixed(2),
fromUserInput: !isInitialCall
fromUserInput: isUserKeyPress
}
})
);
} else {
if (Math.abs(video.playbackRate - numericSpeed) > 0.001) {
// Before changing rate, set a flag that this is VSC initiated for non-forced mode
video.vscIsSettingRate = !isInitialCall; // True if user action, false if initial
if (isUserKeyPress && !isInitialCall) {
video.vscIsDirectlySettingRate = true; // Set flag for ratechange listener
}
video.playbackRate = numericSpeed;
// Flag will be cleared by ratechange listener after processing
}
}
if (!isInitialCall) refreshCoolDown();
@@ -917,58 +733,34 @@ function setSpeed(video, speed, isInitialCall = false) {
}
}
// MODIFIED runAction to pass 4th arg `specificVideo` to some internal calls if needed.
// And pass `isInitialCall=false` to setSpeed calls.
function runAction(action, value, e, specificVideo = null) {
// ... (robust mediaTagsToProcess logic from previous correct version) ...
var mediaTagsToProcess = [];
if (specificVideo) mediaTagsToProcess = [specificVideo];
else if (e && e.target) {
// MODIFIED: runAction is now context-aware and calls the new simpler resetSpeed
function runAction(action, value, e) {
log("runAction Begin", 5);
var mediaTagsToProcess;
if (e && e.target && e.target.getRootNode) {
// Event-driven action
const docContext = e.target.ownerDocument || document;
let activeVideo = tc.mediaElements.find(
(v) =>
v.ownerDocument === docContext &&
(docContext.activeElement === v || v.contains(docContext.activeElement))
mediaTagsToProcess = tc.mediaElements.filter(
(v) => v.ownerDocument === docContext
);
if (activeVideo) mediaTagsToProcess = [activeVideo];
else {
activeVideo = tc.mediaElements.find(
(v) =>
v.ownerDocument === docContext &&
v.offsetParent !== null &&
(!v.paused || v.readyState > 0)
const targetController = e.target.getRootNode().host;
if (targetController) {
// If it's a click on a controller, only use that one video
const specificVideo = tc.mediaElements.find(
(v) => v.vsc && v.vsc.div === targetController
);
if (activeVideo) mediaTagsToProcess = [activeVideo];
else {
mediaTagsToProcess = tc.mediaElements.filter(
(v) => v.ownerDocument === docContext
);
if (mediaTagsToProcess.length === 0 && tc.mediaElements.length > 0)
mediaTagsToProcess = [tc.mediaElements[0]];
else if (mediaTagsToProcess.length === 0) mediaTagsToProcess = [];
}
if (specificVideo) mediaTagsToProcess = [specificVideo];
}
} else mediaTagsToProcess = tc.mediaElements;
} else {
// No event context (e.g., internal blink call) or a passed specificVideo
const specificVideo = arguments[3] || null; // The optional 4th argument
if (specificVideo) mediaTagsToProcess = [specificVideo];
else mediaTagsToProcess = tc.mediaElements;
}
if (mediaTagsToProcess.length === 0 && action !== "display") return;
var targetControllerFromEvent =
e && e.target && e.target.getRootNode && e.target.getRootNode().host
? e.target.getRootNode().host
: null;
var originalActionForResetContext = actionBeingProcessedForReset_ctx; // Use a local context var
actionBeingProcessedForReset_ctx = action;
mediaTagsToProcess.forEach(function (v) {
if (!v || !v.vsc || !v.vsc.div || !v.vsc.speedIndicator) return;
var controllerDiv = v.vsc.div;
if (
targetControllerFromEvent &&
targetControllerFromEvent !== controllerDiv &&
action !== "blink"
)
return;
if (action === "blink" && specificVideo && v !== specificVideo) return;
var controller = v.vsc.div;
const userDrivenActionsThatShowController = [
"rewind",
"advance",
@@ -983,9 +775,8 @@ function runAction(action, value, e, specificVideo = null) {
"drag"
];
if (userDrivenActionsThatShowController.includes(action)) {
showController(controllerDiv);
showController(controller);
}
if (v.classList.contains("vsc-cancelled")) return;
const numValue = parseFloat(value);
switch (action) {
@@ -1002,43 +793,41 @@ function runAction(action, value, e, specificVideo = null) {
(v.playbackRate < 0.07 ? 0.07 : v.playbackRate) + numValue,
16
),
false
false,
true
);
break;
case "slower":
setSpeed(v, Math.max(v.playbackRate - numValue, 0.07), false);
setSpeed(v, Math.max(v.playbackRate - numValue, 0.07), false, true);
break;
// MODIFIED: Calls new resetSpeed directly
case "reset":
resetSpeedSimple(v, 1.0);
break;
resetSpeed(v, 1.0);
break; // Use new simpler resetSpeed
case "fast":
resetSpeedSimple(v, numValue);
break;
resetSpeed(v, numValue, true);
break; // Use new simpler resetSpeed
case "display":
controllerDiv.classList.add("vsc-manual");
controllerDiv.classList.toggle("vsc-hidden");
controller.classList.add("vsc-manual");
controller.classList.toggle("vsc-hidden");
break;
case "blink":
if (
controllerDiv.classList.contains("vsc-hidden") ||
controllerDiv.blinkTimeOut !== undefined
controller.classList.contains("vsc-hidden") ||
controller.blinkTimeOut !== undefined
) {
clearTimeout(controllerDiv.blinkTimeOut);
controllerDiv.classList.remove("vsc-hidden");
controllerDiv.blinkTimeOut = setTimeout(
() => {
if (
controllerDiv.classList.contains("vsc-manual") &&
!controllerDiv.classList.contains("vsc-hidden")
) {
} else {
controllerDiv.classList.add("vsc-hidden");
}
controllerDiv.blinkTimeOut = undefined;
},
typeof value === "number" && !isNaN(value) ? value : 1000
);
clearTimeout(controller.blinkTimeOut);
controller.classList.remove("vsc-hidden");
controller.blinkTimeOut = setTimeout(() => {
if (
!(
controller.classList.contains("vsc-manual") &&
!controller.classList.contains("vsc-hidden")
)
) {
controller.classList.add("vsc-hidden");
}
controller.blinkTimeOut = undefined;
}, value || 1000);
}
break;
case "drag":
@@ -1056,113 +845,84 @@ function runAction(action, value, e, specificVideo = null) {
case "jump":
jumpToMark(v);
break;
default:
log(`Unknown action: ${action}`, 3);
}
});
actionBeingProcessedForReset_ctx = originalActionForResetContext;
log("runAction End", 5);
}
var actionBeingProcessedForReset_ctx = null; // Context for original resetSpeed
function pause(v) {
if (v.paused) v.play().catch((e) => log(`Play err:${e.message}`, 2));
else v.pause();
}
// MODIFIED: New simpler resetSpeed function
function resetSpeedSimple(v, targetActionSpeed) {
log(
`resetSpeedSimple: Video current: ${v.playbackRate.toFixed(2)}, Target for this action: ${targetActionSpeed.toFixed(2)}`,
4
);
const fastPreferredSpeed = getKeyBindings("fast", "value") || 1.8;
if (targetActionSpeed === 1.0) {
// Action was "reset" (R key)
if (Math.abs(v.playbackRate - 1.0) < 0.01) {
setSpeed(v, fastPreferredSpeed, false);
// MODIFIED: Replaced with new, simpler resetSpeed function
function resetSpeed(v, target, isFastKey = false) {
const fastSpeed = getKeyBindings("fast", "value") || 1.8;
if (isFastKey) {
// Called by 'fast' action
if (Math.abs(v.playbackRate - target) < 0.01) {
setSpeed(v, 1.0, false, true); // Toggle to 1.0
} else {
setSpeed(v, 1.0, false);
setSpeed(v, target, false, true); // Set to preferred speed
}
} else {
// Action was "fast" (G key), targetActionSpeed is the preferred speed
if (Math.abs(v.playbackRate - targetActionSpeed) < 0.01) {
setSpeed(v, 1.0, false);
// Called by 'reset' action
if (Math.abs(v.playbackRate - 1.0) < 0.01) {
setSpeed(v, fastSpeed, false, true); // Toggle to fast speed
} else {
setSpeed(v, targetActionSpeed, false);
setSpeed(v, 1.0, false, true); // Set to 1.0
}
}
}
// Remove or comment out the old `resetSpeed` function that uses setKeyBindings and actionBeingProcessedForReset_global
/*
function resetSpeed(v, target) { // THIS IS THE OLD ONE TO BE REPLACED by resetSpeedSimple
// ... original complex logic ...
}
*/
function muted(v) {
v.muted = !v.muted;
log(`Mute: ${v.muted}`, 5);
}
function setMark(v) {
if (!v.vsc) v.vsc = {};
v.vsc.mark = v.currentTime;
log(`Mark: ${v.vsc.mark.toFixed(2)}`, 5);
}
function jumpToMark(v) {
if (v.vsc && typeof v.vsc.mark === "number") v.currentTime = v.vsc.mark;
else log("No mark.", 4);
}
function handleDrag(video, e) {
/* ... same original ... */
if (!video || !video.vsc || !video.vsc.div || !video.vsc.div.shadowRoot)
return;
const controller = video.vsc.div;
const shadowController = controller.shadowRoot.querySelector("#controller");
if (!shadowController) return;
var parentElement = controller.parentElement;
/* ... Same original logic ... */
const c = video.vsc.div;
const sC = c.shadowRoot.querySelector("#controller");
var pE = c.parentElement;
while (
parentElement &&
parentElement.parentNode &&
parentElement.parentNode !== document &&
parentElement.parentNode.offsetHeight === parentElement.offsetHeight &&
parentElement.parentNode.offsetWidth === parentElement.offsetWidth
pE.parentNode &&
pE.parentNode.offsetHeight === pE.offsetHeight &&
pE.parentNode.offsetWidth === pE.offsetWidth
)
parentElement = parentElement.parentNode;
const dragBoundary = parentElement || video.ownerDocument.body;
pE = pE.parentNode;
video.classList.add("vcs-dragging");
shadowController.classList.add("dragging");
sC.classList.add("dragging");
const iXY = [e.clientX, e.clientY],
iCtrlXY = [
parseInt(shadowController.style.left, 10) || 0,
parseInt(shadowController.style.top, 10) || 0
];
const sD = (mvE) => {
let s = shadowController.style;
s.left = iCtrlXY[0] + mvE.clientX - iXY[0] + "px";
s.top = iCtrlXY[1] + mvE.clientY - iXY[1] + "px";
mvE.preventDefault();
iCXY = [parseInt(sC.style.left), parseInt(sC.style.top)];
const sD = (e) => {
let s = sC.style;
s.left = iCXY[0] + e.clientX - iXY[0] + "px";
s.top = iCXY[1] + e.clientY - iXY[1] + "px";
};
const eD = () => {
dragBoundary.removeEventListener("mousemove", sD);
dragBoundary.removeEventListener("mouseup", eD);
dragBoundary.removeEventListener("mouseleave", eD);
shadowController.classList.remove("dragging");
pE.removeEventListener("mousemove", sD);
pE.removeEventListener("mouseup", eD);
pE.removeEventListener("mouseleave", eD);
sC.classList.remove("dragging");
video.classList.remove("vcs-dragging");
};
dragBoundary.addEventListener("mousemove", sD);
dragBoundary.addEventListener("mouseup", eD);
dragBoundary.addEventListener("mouseleave", eD);
pE.addEventListener("mouseup", eD);
pE.addEventListener("mouseleave", eD);
pE.addEventListener("mousemove", sD);
}
var timer = null;
function showController(controller) {
/* ... same original ... */
/* ... Same original logic ... */
if (!controller || typeof controller.classList === "undefined") return;
controller.classList.add("vcs-show");
if (timer) clearTimeout(timer);
timer = setTimeout(function () {
if (controller && controller.classList)
controller.classList.remove("vsc-show");
controller.classList.remove("vsc-show");
timer = false;
}, 2000);
}

View File

@@ -1,13 +1,13 @@
{
"name": "Video Speed Controller",
"short_name": "videospeed",
"version": "0.6.3.3",
"version": "1.1.3",
"manifest_version": 2,
"description": "Speed up, slow down, advance and rewind HTML5 audio/video with shortcuts",
"homepage_url": "https://github.com/codebicycle/videospeed",
"homepage_url": "https://github.com/SoPat712/videospeed",
"browser_specific_settings": {
"gecko": {
"id": "{7be2ba16-0f1e-4d93-9ebc-5164397477a9}"
"id": "{ed860648-f54f-4dc9-9a0d-501aec4313f5}"
}
},
"icons": {

View File

@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html>
<head>
<title>Video Speed Controller: Options</title>
@@ -148,8 +148,13 @@
<input id="rememberSpeed" type="checkbox" />
</div>
<div class="row">
<label for="forceLastSavedSpeed">Force last saved speed<br />
<em>Useful for video players that override the speeds set by VideoSpeed</em></label>
<label for="forceLastSavedSpeed"
>Force last saved speed<br />
<em
>Useful for video players that override the speeds set by
VideoSpeed</em
></label
>
<input id="forceLastSavedSpeed" type="checkbox" />
</div>
<div class="row">
@@ -175,6 +180,47 @@
</div>
</section>
<section id="nudgeSettings">
<h3>Subtitle Nudge Settings (Experimental - YouTube Only)</h3>
<div class="row">
<label for="enableSubtitleNudge"
>Enable Subtitle Nudge <br /><em
>Periodically 'nudges' video speed by a tiny amount to help keep
subtitles in sync on some sites (e.g. YouTube).</em
>
</label>
<input id="enableSubtitleNudge" type="checkbox" />
</div>
<div class="row">
<label for="subtitleNudgeInterval"
>Nudge Interval (milliseconds) <br /><em
>How often to nudge (e.g., 25-1000). Smaller values are more
frequent. Default: 25.</em
>
</label>
<input
id="subtitleNudgeInterval"
type="text"
value=""
placeholder="25"
/>
</div>
<div class="row">
<label for="subtitleNudgeAmount"
>Nudge Amount (decimal) <br /><em
>How much to change speed by (e.g., 0.001). Very small values
recommended. Default: 0.001.</em
>
</label>
<input
id="subtitleNudgeAmount"
type="text"
value=""
placeholder="0.001"
/>
</div>
</section>
<button id="save">Save</button>
<button id="restore">Restore Defaults</button>
<button id="experimental">Show Experimental Features</button>
@@ -186,12 +232,12 @@
<h4>Extension controls not appearing?</h4>
<p>
This extension is only compatible with HTML5 audio and video. If you don't
see the controls showing up, chances are you are viewing a Flash content.
If you want to confirm, try right-clicking on the content and inspect the
menu: if it mentions flash, then that's the issue. That said, <b>most sites
will fallback to HTML5</b> if they detect that Flash is not available. You
can try manually disabling Flash from the browser.
This extension is only compatible with HTML5 audio and video. If you
don't see the controls showing up, chances are you are viewing a Flash
content. If you want to confirm, try right-clicking on the content and
inspect the menu: if it mentions flash, then that's the issue. That
said, <b>most sites will fallback to HTML5</b> if they detect that Flash
is not available. You can try manually disabling Flash from the browser.
</p>
</div>
</body>

View File

@@ -22,13 +22,17 @@ var tcDefaults = {
twitter.com
imgur.com
teams.microsoft.com
`.replace(regStrip, "")
`.replace(regStrip, ""),
// ADDED: Nudge defaults
enableSubtitleNudge: true,
subtitleNudgeInterval: 25,
subtitleNudgeAmount: 0.001
};
var keyBindings = [];
var keyBindings = []; // This is populated during save/restore
var keyCodeAliases = {
0: "null",
/* ... same as your original ... */ 0: "null",
null: "null",
undefined: "null",
32: "Space",
@@ -74,85 +78,55 @@ var keyCodeAliases = {
220: "\\",
221: "]",
222: "'",
59: ";",
61: "+",
173: "-",
59: ";",
61: "+",
173: "-"
};
function recordKeyPress(e) {
/* ... same as your original ... */
if (
(e.keyCode >= 48 && e.keyCode <= 57) || // Numbers 0-9
(e.keyCode >= 65 && e.keyCode <= 90) || // Letters A-Z
keyCodeAliases[e.keyCode] // Other character keys
(e.keyCode >= 48 && e.keyCode <= 57) ||
(e.keyCode >= 65 && e.keyCode <= 90) ||
keyCodeAliases[e.keyCode]
) {
e.target.value =
keyCodeAliases[e.keyCode] || String.fromCharCode(e.keyCode);
e.target.keyCode = e.keyCode;
e.preventDefault();
e.stopPropagation();
} else if (e.keyCode === 8) {
// Clear input when backspace pressed
e.target.value = "";
} else if (e.keyCode === 27) {
// When esc clicked, clear input
e.target.value = "null";
e.target.keyCode = null;
}
}
function inputFilterNumbersOnly(e) {
/* ... same as your original ... */
var char = String.fromCharCode(e.keyCode);
if (!/[\d\.]$/.test(char) || !/^\d+(\.\d*)?$/.test(e.target.value + char)) {
e.preventDefault();
e.stopPropagation();
}
}
function inputFocus(e) {
e.target.value = "";
/* ... same as your original ... */ e.target.value = "";
}
function inputBlur(e) {
e.target.value =
/* ... same as your original ... */ e.target.value =
keyCodeAliases[e.target.keyCode] || String.fromCharCode(e.target.keyCode);
}
function updateShortcutInputText(inputId, keyCode) {
document.getElementById(inputId).value =
keyCodeAliases[keyCode] || String.fromCharCode(keyCode);
document.getElementById(inputId).keyCode = keyCode;
}
// function updateShortcutInputText(inputId, keyCode) { /* ... same as your original ... */ } // Not directly used in provided options.js logic flow
function updateCustomShortcutInputText(inputItem, keyCode) {
inputItem.value = keyCodeAliases[keyCode] || String.fromCharCode(keyCode);
/* ... same as your original ... */ inputItem.value =
keyCodeAliases[keyCode] || String.fromCharCode(keyCode);
inputItem.keyCode = keyCode;
}
// List of custom actions for which customValue should be disabled
var customActionsNoValues = ["pause", "muted", "mark", "jump", "display"];
var customActionsNoValues = ["pause", "muted", "mark", "jump", "display"]; // Original
function add_shortcut() {
var html = `<select class="customDo">
<option value="slower">Decrease speed</option>
<option value="faster">Increase speed</option>
<option value="rewind">Rewind</option>
<option value="advance">Advance</option>
<option value="reset">Reset speed</option>
<option value="fast">Preferred speed</option>
<option value="muted">Mute</option>
<option value="pause">Pause</option>
<option value="mark">Set marker</option>
<option value="jump">Jump to marker</option>
<option value="display">Show/hide controller</option>
</select>
<input class="customKey" type="text" placeholder="press a key"/>
<input class="customValue" type="text" placeholder="value (0.10)"/>
<select class="customForce">
<option value="false">Do not disable website key bindings</option>
<option value="true">Disable website key bindings</option>
</select>
<button class="removeParent">X</button>`;
/* ... same as your original ... */
var html = `<select class="customDo"><option value="slower">Decrease speed</option><option value="faster">Increase speed</option><option value="rewind">Rewind</option><option value="advance">Advance</option><option value="reset">Reset speed</option><option value="fast">Preferred speed</option><option value="muted">Mute</option><option value="pause">Pause</option><option value="mark">Set marker</option><option value="jump">Jump to marker</option><option value="display">Show/hide controller</option></select><input class="customKey" type="text" placeholder="press a key"/><input class="customValue" type="text" placeholder="value (0.10)"/><select class="customForce"><option value="false">Do not disable website key bindings</option><option value="true">Disable website key bindings</option></select><button class="removeParent">X</button>`;
var div = document.createElement("div");
div.setAttribute("class", "row customs");
div.innerHTML = html;
@@ -162,14 +136,13 @@ function add_shortcut() {
customs_element.children[customs_element.childElementCount - 1]
);
}
function createKeyBindings(item) {
/* ... same as your original ... */
const action = item.querySelector(".customDo").value;
const key = item.querySelector(".customKey").keyCode;
const value = Number(item.querySelector(".customValue").value);
const force = item.querySelector(".customForce").value;
const predefined = !!item.id; //item.id ? true : false;
const predefined = !!item.id;
keyBindings.push({
action: action,
key: key,
@@ -178,9 +151,8 @@ function createKeyBindings(item) {
predefined: predefined
});
}
// Validates settings before saving
function validate() {
/* ... same as your original ... */
var valid = true;
var status = document.getElementById("status");
document
@@ -190,7 +162,7 @@ function validate() {
match = match.replace(regStrip, "");
if (match.startsWith("/")) {
try {
var regexp = new RegExp(match);
new RegExp(match);
} catch (err) {
status.textContent =
"Error: Invalid blacklist regex: " + match + ". Unable to save";
@@ -202,24 +174,45 @@ function validate() {
return valid;
}
// Saves options to chrome.storage
// MODIFIED: save_options to include nudge settings
function save_options() {
if (validate() === false) {
return;
}
keyBindings = [];
if (validate() === false) return;
keyBindings = []; // Reset global keyBindings before populating from DOM
Array.from(document.querySelectorAll(".customs")).forEach((item) =>
createKeyBindings(item)
); // Remove added shortcuts
);
var rememberSpeed = document.getElementById("rememberSpeed").checked;
var forceLastSavedSpeed = document.getElementById("forceLastSavedSpeed").checked;
var audioBoolean = document.getElementById("audioBoolean").checked;
var enabled = document.getElementById("enabled").checked;
var startHidden = document.getElementById("startHidden").checked;
var controllerOpacity = document.getElementById("controllerOpacity").value;
var blacklist = document.getElementById("blacklist").value;
var s = {}; // Object to hold all settings to be saved
s.rememberSpeed = document.getElementById("rememberSpeed").checked;
s.forceLastSavedSpeed = document.getElementById(
"forceLastSavedSpeed"
).checked;
s.audioBoolean = document.getElementById("audioBoolean").checked;
s.enabled = document.getElementById("enabled").checked;
s.startHidden = document.getElementById("startHidden").checked;
s.controllerOpacity = document.getElementById("controllerOpacity").value;
s.blacklist = document
.getElementById("blacklist")
.value.replace(regStrip, "");
s.keyBindings = keyBindings; // Use the populated global keyBindings
// ADDED: Save nudge settings
s.enableSubtitleNudge = document.getElementById(
"enableSubtitleNudge"
).checked;
s.subtitleNudgeInterval =
parseInt(document.getElementById("subtitleNudgeInterval").value, 10) ||
tcDefaults.subtitleNudgeInterval;
s.subtitleNudgeAmount =
parseFloat(document.getElementById("subtitleNudgeAmount").value) ||
tcDefaults.subtitleNudgeAmount;
// Basic validation for nudge interval and amount
if (s.subtitleNudgeInterval < 10) s.subtitleNudgeInterval = 10; // Min 10ms
if (s.subtitleNudgeAmount <= 0 || s.subtitleNudgeAmount > 0.1)
s.subtitleNudgeAmount = tcDefaults.subtitleNudgeAmount;
// Remove old flat settings (original logic)
chrome.storage.sync.remove([
"resetSpeed",
"speedStep",
@@ -233,33 +226,22 @@ function save_options() {
"advanceKeyCode",
"fastKeyCode"
]);
chrome.storage.sync.set(
{
rememberSpeed: rememberSpeed,
forceLastSavedSpeed: forceLastSavedSpeed,
audioBoolean: audioBoolean,
enabled: enabled,
startHidden: startHidden,
controllerOpacity: controllerOpacity,
keyBindings: keyBindings,
blacklist: blacklist.replace(regStrip, "")
},
function () {
// Update status to let user know options were saved.
var status = document.getElementById("status");
status.textContent = "Options saved";
setTimeout(function () {
status.textContent = "";
}, 1000);
}
);
chrome.storage.sync.set(s, function () {
var status = document.getElementById("status");
status.textContent = "Options saved";
setTimeout(function () {
status.textContent = "";
}, 1000);
});
}
// Restores options from chrome.storage
// MODIFIED: restore_options to include nudge settings
function restore_options() {
chrome.storage.sync.get(tcDefaults, function (storage) {
document.getElementById("rememberSpeed").checked = storage.rememberSpeed;
document.getElementById("forceLastSavedSpeed").checked = storage.forceLastSavedSpeed;
document.getElementById("forceLastSavedSpeed").checked =
storage.forceLastSavedSpeed;
document.getElementById("audioBoolean").checked = storage.audioBoolean;
document.getElementById("enabled").checked = storage.enabled;
document.getElementById("startHidden").checked = storage.startHidden;
@@ -267,65 +249,86 @@ function restore_options() {
storage.controllerOpacity;
document.getElementById("blacklist").value = storage.blacklist;
// ensure that there is a "display" binding for upgrades from versions that had it as a separate binding
// ADDED: Restore nudge settings
document.getElementById("enableSubtitleNudge").checked =
storage.enableSubtitleNudge;
document.getElementById("subtitleNudgeInterval").value =
storage.subtitleNudgeInterval;
document.getElementById("subtitleNudgeAmount").value =
storage.subtitleNudgeAmount;
// Original key binding restoration logic
if (
!Array.isArray(storage.keyBindings) ||
storage.keyBindings.length === 0
) {
// If keyBindings missing or not an array, use defaults from tcDefaults
storage.keyBindings = tcDefaults.keyBindings;
}
if (storage.keyBindings.filter((x) => x.action == "display").length == 0) {
storage.keyBindings.push({
action: "display",
value: 0,
force: false,
predefined: true
predefined: true,
key: storage.displayKeyCode || tcDefaults.displayKeyCode
});
}
// Clear existing dynamic shortcuts before restoring (if any were added by mistake)
const dynamicShortcuts = document.querySelectorAll(".customs:not([id])");
dynamicShortcuts.forEach((sc) => sc.remove());
for (let i in storage.keyBindings) {
var item = storage.keyBindings[i];
if (item.predefined) {
//do predefined ones because their value needed for overlay
// document.querySelector("#" + item["action"] + " .customDo").value = item["action"];
if (item["action"] == "display" && typeof item["key"] === "undefined") {
item["key"] = storage.displayKeyCode || tcDefaults.displayKeyCode; // V
item["key"] = storage.displayKeyCode || tcDefaults.displayKeyCode;
}
if (customActionsNoValues.includes(item["action"]))
document.querySelector(
if (customActionsNoValues.includes(item["action"])) {
const el = document.querySelector(
"#" + item["action"] + " .customValue"
).disabled = true;
updateCustomShortcutInputText(
document.querySelector("#" + item["action"] + " .customKey"),
item["key"]
);
if (el) el.disabled = true;
}
const keyEl = document.querySelector(
"#" + item["action"] + " .customKey"
);
document.querySelector("#" + item["action"] + " .customValue").value =
item["value"];
document.querySelector("#" + item["action"] + " .customForce").value =
item["force"];
const valEl = document.querySelector(
"#" + item["action"] + " .customValue"
);
const forceEl = document.querySelector(
"#" + item["action"] + " .customForce"
);
if (keyEl) updateCustomShortcutInputText(keyEl, item["key"]);
if (valEl) valEl.value = item["value"];
if (forceEl) forceEl.value = String(item["force"]); // Ensure string for select value
} else {
// new ones
// Non-predefined, dynamically added shortcuts
add_shortcut();
const dom = document.querySelector(".customs:last-of-type");
const dom = document.querySelector(".customs:last-of-type"); // Gets the newly added one
dom.querySelector(".customDo").value = item["action"];
if (customActionsNoValues.includes(item["action"]))
if (customActionsNoValues.includes(item["action"])) {
dom.querySelector(".customValue").disabled = true;
}
updateCustomShortcutInputText(
dom.querySelector(".customKey"),
item["key"]
);
dom.querySelector(".customValue").value = item["value"];
dom.querySelector(".customForce").value = item["force"];
dom.querySelector(".customForce").value = String(item["force"]);
}
}
});
}
function restore_defaults() {
/* ... same as your original, tcDefaults now includes nudge defaults ... */
// Remove all dynamically added shortcuts first
document.querySelectorAll(".customs:not([id])").forEach((el) => el.remove());
// Then set defaults and restore options, which will re-add predefined ones correctly
chrome.storage.sync.set(tcDefaults, function () {
restore_options();
document
.querySelectorAll(".removeParent")
.forEach((button) => button.click()); // Remove added shortcuts
// Update status to let user know options were saved.
restore_options(); // This will populate based on tcDefaults
var status = document.getElementById("status");
status.textContent = "Default options restored";
setTimeout(function () {
@@ -335,14 +338,15 @@ function restore_defaults() {
}
function show_experimental() {
/* ... same as your original ... */
document
.querySelectorAll(".customForce")
.forEach((item) => (item.style.display = "inline-block"));
}
document.addEventListener("DOMContentLoaded", function () {
/* ... same as your original event listeners setup ... */
restore_options();
document.getElementById("save").addEventListener("click", save_options);
document.getElementById("add").addEventListener("click", add_shortcut);
document
@@ -353,34 +357,32 @@ document.addEventListener("DOMContentLoaded", function () {
.addEventListener("click", show_experimental);
function eventCaller(event, className, funcName) {
if (!event.target.classList || !event.target.classList.contains(className)) {
if (!event.target.classList || !event.target.classList.contains(className))
return;
}
funcName(event);
}
document.addEventListener("keypress", (event) => {
eventCaller(event, "customValue", inputFilterNumbersOnly);
});
document.addEventListener("focus", (event) => {
eventCaller(event, "customKey", inputFocus);
});
document.addEventListener("blur", (event) => {
eventCaller(event, "customKey", inputBlur);
});
document.addEventListener("keydown", (event) => {
eventCaller(event, "customKey", recordKeyPress);
});
document.addEventListener("click", (event) => {
document.addEventListener("keypress", (event) =>
eventCaller(event, "customValue", inputFilterNumbersOnly)
);
document.addEventListener("focus", (event) =>
eventCaller(event, "customKey", inputFocus)
);
document.addEventListener("blur", (event) =>
eventCaller(event, "customKey", inputBlur)
);
document.addEventListener("keydown", (event) =>
eventCaller(event, "customKey", recordKeyPress)
);
document.addEventListener("click", (event) =>
eventCaller(event, "removeParent", function () {
event.target.parentNode.remove();
});
});
})
);
document.addEventListener("change", (event) => {
eventCaller(event, "customDo", function () {
if (customActionsNoValues.includes(event.target.value)) {
event.target.nextElementSibling.nextElementSibling.disabled = true;
event.target.nextElementSibling.nextElementSibling.value = 0;
event.target.nextElementSibling.nextElementSibling.value = 0; // Or "" if placeholder is preferred
} else {
event.target.nextElementSibling.nextElementSibling.disabled = false;
}