mirror of
https://github.com/SoPat712/videospeed.git
synced 2025-12-26 11:37:21 -05:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef86a70ca5
|
||
|
|
5009e83f62
|
||
|
|
1f8cb4411e
|
||
|
|
05a8adef80
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,5 +2,6 @@
|
||||
local
|
||||
|
||||
# IntelliJ IDEA
|
||||
*.xpi
|
||||
.idea/
|
||||
node_modules
|
||||
|
||||
16
build.py
16
build.py
@@ -63,14 +63,26 @@ def main():
|
||||
except Exception as e:
|
||||
print(f"⚠️ Failed to remove {f}: {e}")
|
||||
|
||||
# Read current version from manifest.json
|
||||
current_dir = os.getcwd()
|
||||
manifest_path = os.path.join(current_dir, TARGET_FILE)
|
||||
current_version = "unknown"
|
||||
|
||||
if os.path.exists(manifest_path):
|
||||
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
match = re.match(r'\s*"version":\s*"([^"]+)"', line)
|
||||
if match:
|
||||
current_version = match.group(1)
|
||||
break
|
||||
|
||||
print(f"📦 Current version: {current_version}")
|
||||
base_version = input("Enter the new base version (e.g., 2.0.1): ").strip()
|
||||
if not base_version:
|
||||
print("❌ No version entered. Exiting.")
|
||||
return
|
||||
|
||||
firefox_version = f"{base_version}.0"
|
||||
current_dir = os.getcwd()
|
||||
manifest_path = os.path.join(current_dir, TARGET_FILE)
|
||||
|
||||
# Step 1: Update manifest.json on disk to base_version
|
||||
if os.path.exists(manifest_path):
|
||||
|
||||
570
inject.js
570
inject.js
@@ -24,8 +24,8 @@ var tc = {
|
||||
`.replace(regStrip, ""),
|
||||
defaultLogLevel: 4,
|
||||
logLevel: 5, // Set to 5 to see your debug logs
|
||||
enableSubtitleNudge: true,
|
||||
subtitleNudgeInterval: 25,
|
||||
enableSubtitleNudge: true, // Enabled by default, but only activates on YouTube
|
||||
subtitleNudgeInterval: 100, // Reduced from 25ms to 100ms (10x/sec instead of 40x/sec)
|
||||
subtitleNudgeAmount: 0.001
|
||||
},
|
||||
mediaElements: [],
|
||||
@@ -123,7 +123,7 @@ chrome.storage.sync.get(tc.settings, function (storage) {
|
||||
? Boolean(storage.enableSubtitleNudge)
|
||||
: tc.settings.enableSubtitleNudge;
|
||||
tc.settings.subtitleNudgeInterval =
|
||||
Number(storage.subtitleNudgeInterval) || 25;
|
||||
Number(storage.subtitleNudgeInterval) || 100; // Default 100ms for better performance
|
||||
tc.settings.subtitleNudgeAmount =
|
||||
Number(storage.subtitleNudgeAmount) || tc.settings.subtitleNudgeAmount;
|
||||
if (
|
||||
@@ -184,7 +184,9 @@ function defineVideoController() {
|
||||
target.vsc = this;
|
||||
this.video = target;
|
||||
this.parent = target.parentElement || parent;
|
||||
this.nudgeIntervalId = null;
|
||||
this.nudgeAnimationId = null;
|
||||
|
||||
log(`Creating video controller for ${target.tagName} with src: ${target.src || target.currentSrc || 'none'}`, 4);
|
||||
|
||||
// Determine what speed to use
|
||||
let storedSpeed = tc.settings.speeds[target.currentSrc];
|
||||
@@ -209,6 +211,13 @@ function defineVideoController() {
|
||||
|
||||
this.div = this.initializeControls();
|
||||
|
||||
if (!this.div) {
|
||||
log("ERROR: Failed to create controller div!", 2);
|
||||
return;
|
||||
}
|
||||
|
||||
log(`Controller created and attached to DOM. Hidden: ${this.div.classList.contains('vsc-hidden')}`, 4);
|
||||
|
||||
// Make the controller visible for 5 seconds on startup
|
||||
runAction("blink", 5000, null, this.video);
|
||||
|
||||
@@ -319,7 +328,7 @@ function defineVideoController() {
|
||||
const expectedSpeed = tc.settings.forceLastSavedSpeed
|
||||
? tc.settings.lastSpeed
|
||||
: tc.settings.speeds[mutation.target.currentSrc] ||
|
||||
tc.settings.lastSpeed;
|
||||
tc.settings.lastSpeed;
|
||||
|
||||
setTimeout(() => {
|
||||
if (mutation.target.vsc) {
|
||||
@@ -358,62 +367,103 @@ function defineVideoController() {
|
||||
this.video.currentSrc &&
|
||||
this.video.currentSrc.includes("googlevideo.com")) ||
|
||||
location.hostname.includes("youtube.com");
|
||||
if (!isYouTube) return;
|
||||
if (
|
||||
!isYouTube ||
|
||||
!tc.settings.enableSubtitleNudge ||
|
||||
this.nudgeIntervalId !== null ||
|
||||
!this.video
|
||||
this.nudgeAnimationId !== null ||
|
||||
!this.video ||
|
||||
this.video.paused ||
|
||||
this.video.playbackRate === 1.0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (this.video.paused || this.video.playbackRate === 1.0) {
|
||||
this.stopSubtitleNudge();
|
||||
return;
|
||||
}
|
||||
// Additional check to not start if paused
|
||||
if (this.video.paused) {
|
||||
return;
|
||||
}
|
||||
log(`Nudge: Starting interval: ${tc.settings.subtitleNudgeInterval}ms.`, 5);
|
||||
this.nudgeIntervalId = setInterval(() => {
|
||||
if (
|
||||
!this.video ||
|
||||
this.video.paused ||
|
||||
this.video.ended ||
|
||||
this.video.playbackRate === 1.0 ||
|
||||
tc.isNudging
|
||||
) {
|
||||
|
||||
// Store the target speed so we can always revert to it
|
||||
this.targetSpeed = this.video.playbackRate;
|
||||
|
||||
const performNudge = () => {
|
||||
// Check if we should stop
|
||||
if (!this.video || this.video.paused || this.video.playbackRate === 1.0) {
|
||||
this.stopSubtitleNudge();
|
||||
return;
|
||||
}
|
||||
// Double-check pause state before nudging
|
||||
if (this.video.paused) {
|
||||
this.stopSubtitleNudge();
|
||||
|
||||
// CRITICAL: Don't nudge if tab is hidden - prevents speed drift
|
||||
if (document.hidden) {
|
||||
this.nudgeAnimationId = setTimeout(performNudge, tc.settings.subtitleNudgeInterval);
|
||||
return;
|
||||
}
|
||||
const currentRate = this.video.playbackRate;
|
||||
const nudgeAmount = tc.settings.subtitleNudgeAmount;
|
||||
|
||||
// Set flag to prevent ratechange listener from interfering
|
||||
tc.isNudging = true;
|
||||
this.video.playbackRate = currentRate + nudgeAmount;
|
||||
requestAnimationFrame(() => {
|
||||
if (
|
||||
this.video &&
|
||||
Math.abs(this.video.playbackRate - (currentRate + nudgeAmount)) <
|
||||
nudgeAmount * 1.5
|
||||
) {
|
||||
this.video.playbackRate = currentRate;
|
||||
|
||||
// Cache values to avoid repeated property access
|
||||
const targetSpeed = this.targetSpeed;
|
||||
const nudgeAmount = tc.settings.subtitleNudgeAmount;
|
||||
|
||||
// Apply nudge from the stored target speed (not current rate)
|
||||
this.video.playbackRate = targetSpeed + nudgeAmount;
|
||||
|
||||
// Revert synchronously after a microtask to ensure it happens immediately
|
||||
Promise.resolve().then(() => {
|
||||
if (this.video && targetSpeed) {
|
||||
this.video.playbackRate = targetSpeed;
|
||||
}
|
||||
tc.isNudging = false;
|
||||
});
|
||||
}, tc.settings.subtitleNudgeInterval);
|
||||
|
||||
// Schedule next nudge
|
||||
this.nudgeAnimationId = setTimeout(performNudge, tc.settings.subtitleNudgeInterval);
|
||||
};
|
||||
|
||||
// Start the first nudge
|
||||
this.nudgeAnimationId = setTimeout(performNudge, tc.settings.subtitleNudgeInterval);
|
||||
log(`Nudge: Starting with interval ${tc.settings.subtitleNudgeInterval}ms.`, 5);
|
||||
};
|
||||
|
||||
tc.videoController.prototype.stopSubtitleNudge = function () {
|
||||
if (this.nudgeIntervalId !== null) {
|
||||
if (this.nudgeAnimationId !== null) {
|
||||
clearTimeout(this.nudgeAnimationId);
|
||||
this.nudgeAnimationId = null;
|
||||
log(`Nudge: Stopping.`, 5);
|
||||
clearInterval(this.nudgeIntervalId);
|
||||
this.nudgeIntervalId = null;
|
||||
}
|
||||
// Clear the target speed when stopping
|
||||
this.targetSpeed = null;
|
||||
};
|
||||
|
||||
tc.videoController.prototype.performImmediateNudge = function () {
|
||||
const isYouTube =
|
||||
(this.video &&
|
||||
this.video.currentSrc &&
|
||||
this.video.currentSrc.includes("googlevideo.com")) ||
|
||||
location.hostname.includes("youtube.com");
|
||||
|
||||
if (
|
||||
!isYouTube ||
|
||||
!tc.settings.enableSubtitleNudge ||
|
||||
!this.video ||
|
||||
this.video.paused ||
|
||||
this.video.playbackRate === 1.0 ||
|
||||
document.hidden
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetRate = this.targetSpeed || this.video.playbackRate;
|
||||
const nudgeAmount = tc.settings.subtitleNudgeAmount;
|
||||
|
||||
tc.isNudging = true;
|
||||
this.video.playbackRate = targetRate + nudgeAmount;
|
||||
|
||||
// Revert synchronously via microtask
|
||||
Promise.resolve().then(() => {
|
||||
if (this.video) {
|
||||
this.video.playbackRate = targetRate;
|
||||
}
|
||||
tc.isNudging = false;
|
||||
});
|
||||
|
||||
log(`Immediate nudge performed at rate ${targetRate.toFixed(2)}`, 5);
|
||||
};
|
||||
|
||||
tc.videoController.prototype.initializeControls = function () {
|
||||
@@ -471,32 +521,49 @@ function defineVideoController() {
|
||||
var fragment = doc.createDocumentFragment();
|
||||
fragment.appendChild(wrapper);
|
||||
const parentEl = this.parent || this.video.parentElement;
|
||||
|
||||
log(`Inserting controller: parentEl=${!!parentEl}, parentNode=${!!parentEl?.parentNode}, hostname=${location.hostname}`, 4);
|
||||
|
||||
if (!parentEl || !parentEl.parentNode) {
|
||||
log("No suitable parent found, appending to body", 4);
|
||||
doc.body.appendChild(fragment);
|
||||
return wrapper;
|
||||
}
|
||||
switch (true) {
|
||||
case location.hostname == "www.amazon.com":
|
||||
case location.hostname == "www.reddit.com":
|
||||
case /hbogo\./.test(location.hostname):
|
||||
parentEl.parentElement.insertBefore(fragment, parentEl);
|
||||
break;
|
||||
case location.hostname == "www.facebook.com":
|
||||
let p =
|
||||
parentEl.parentElement.parentElement.parentElement.parentElement
|
||||
.parentElement.parentElement.parentElement;
|
||||
if (p && p.firstChild) p.insertBefore(fragment, p.firstChild);
|
||||
else parentEl.insertBefore(fragment, parentEl.firstChild);
|
||||
break;
|
||||
case location.hostname == "tv.apple.com":
|
||||
const r = parentEl.getRootNode();
|
||||
const s = r && r.querySelector ? r.querySelector(".scrim") : null;
|
||||
if (s) s.prepend(fragment);
|
||||
else parentEl.insertBefore(fragment, parentEl.firstChild);
|
||||
break;
|
||||
default:
|
||||
parentEl.insertBefore(fragment, parentEl.firstChild);
|
||||
|
||||
try {
|
||||
switch (true) {
|
||||
case location.hostname == "www.amazon.com":
|
||||
case location.hostname == "www.reddit.com":
|
||||
case /hbogo\./.test(location.hostname):
|
||||
log("Using parentElement.parentElement insertion", 5);
|
||||
parentEl.parentElement.insertBefore(fragment, parentEl);
|
||||
break;
|
||||
case location.hostname == "www.facebook.com":
|
||||
log("Using Facebook-specific insertion", 5);
|
||||
let p =
|
||||
parentEl.parentElement.parentElement.parentElement.parentElement
|
||||
.parentElement.parentElement.parentElement;
|
||||
if (p && p.firstChild) p.insertBefore(fragment, p.firstChild);
|
||||
else parentEl.insertBefore(fragment, parentEl.firstChild);
|
||||
break;
|
||||
case location.hostname == "tv.apple.com":
|
||||
log("Using Apple TV-specific insertion", 5);
|
||||
const r = parentEl.getRootNode();
|
||||
const s = r && r.querySelector ? r.querySelector(".scrim") : null;
|
||||
if (s) s.prepend(fragment);
|
||||
else parentEl.insertBefore(fragment, parentEl.firstChild);
|
||||
break;
|
||||
default:
|
||||
log("Using default insertion method", 5);
|
||||
parentEl.insertBefore(fragment, parentEl.firstChild);
|
||||
}
|
||||
log("Controller successfully inserted into DOM", 4);
|
||||
} catch (error) {
|
||||
log(`Error inserting controller: ${error.message}`, 2);
|
||||
// Fallback to body insertion
|
||||
doc.body.appendChild(fragment);
|
||||
}
|
||||
|
||||
return wrapper;
|
||||
};
|
||||
}
|
||||
@@ -543,7 +610,7 @@ function setupListener() {
|
||||
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 }, () => {});
|
||||
chrome.storage.sync.set({ lastSpeed: speed }, () => { });
|
||||
if (fromUserInput) {
|
||||
runAction("blink", 1000, null, video);
|
||||
}
|
||||
@@ -583,12 +650,18 @@ function setupListener() {
|
||||
}
|
||||
|
||||
var vscInitializedDocuments = new Set();
|
||||
function initializeWhenReady(doc) {
|
||||
if (vscInitializedDocuments.has(doc) || !doc.body) return;
|
||||
function initializeWhenReady(doc, forceReinit = false) {
|
||||
if (!forceReinit && vscInitializedDocuments.has(doc) || !doc.body) return;
|
||||
|
||||
// For navigation changes, we want to re-scan even if already initialized
|
||||
if (forceReinit) {
|
||||
log("Force re-initialization requested", 4);
|
||||
}
|
||||
|
||||
if (doc.readyState === "complete") {
|
||||
initializeNow(doc);
|
||||
initializeNow(doc, forceReinit);
|
||||
} else {
|
||||
doc.addEventListener("DOMContentLoaded", () => initializeNow(doc), {
|
||||
doc.addEventListener("DOMContentLoaded", () => initializeNow(doc, forceReinit), {
|
||||
once: true
|
||||
});
|
||||
}
|
||||
@@ -608,7 +681,17 @@ function getShadow(parent) {
|
||||
do {
|
||||
r.push(c);
|
||||
gC(c);
|
||||
if (c.shadowRoot) r.push(...getShadow(c.shadowRoot));
|
||||
if (c.shadowRoot) {
|
||||
r.push(...getShadow(c.shadowRoot));
|
||||
// Also check for videos in shadow DOM
|
||||
const shadowVideos = c.shadowRoot.querySelectorAll(tc.settings.audioBoolean ? "video,audio" : "video");
|
||||
shadowVideos.forEach(video => {
|
||||
if (!video.vsc) {
|
||||
log(`Found video in shadow DOM`, 5);
|
||||
checkForVideo(video, video.parentElement, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
c = c.nextElementSibling;
|
||||
} while (c);
|
||||
}
|
||||
@@ -617,9 +700,10 @@ function getShadow(parent) {
|
||||
return r;
|
||||
}
|
||||
|
||||
function initializeNow(doc) {
|
||||
if (vscInitializedDocuments.has(doc) || !doc.body) return;
|
||||
function initializeNow(doc, forceReinit = false) {
|
||||
if (!forceReinit && (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();
|
||||
@@ -628,7 +712,7 @@ function initializeNow(doc) {
|
||||
var docs = Array(doc);
|
||||
try {
|
||||
if (inIframe()) docs.push(window.top.document);
|
||||
} catch (e) {}
|
||||
} catch (e) { }
|
||||
docs.forEach(function (d) {
|
||||
if (d.vscKeydownListenerAttached) return; // Prevent duplicate listeners
|
||||
d.addEventListener(
|
||||
@@ -668,49 +752,69 @@ function initializeNow(doc) {
|
||||
});
|
||||
|
||||
if (!doc.vscMutationObserverAttached) {
|
||||
// Throttle mutation processing to reduce CPU usage
|
||||
let mutationProcessingScheduled = false;
|
||||
let pendingMutations = [];
|
||||
|
||||
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();
|
||||
pendingMutations.push(...mutations);
|
||||
|
||||
if (!mutationProcessingScheduled) {
|
||||
mutationProcessingScheduled = true;
|
||||
requestIdleCallback(
|
||||
(_) => {
|
||||
const mutationsToProcess = pendingMutations.splice(0);
|
||||
mutationProcessingScheduled = false;
|
||||
|
||||
mutationsToProcess.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,
|
||||
true
|
||||
false
|
||||
);
|
||||
});
|
||||
break;
|
||||
case "attributes":
|
||||
// Enhanced attribute monitoring for video detection
|
||||
const target = mutation.target;
|
||||
if (target.tagName === "VIDEO" || target.tagName === "AUDIO") {
|
||||
// Video/audio element had attributes changed - check if it needs controller
|
||||
if (!target.vsc && (target.src || target.currentSrc)) {
|
||||
checkForVideo(target, target.parentNode, true);
|
||||
}
|
||||
} else if (
|
||||
target.attributes["aria-hidden"] &&
|
||||
target.attributes["aria-hidden"].value == "false"
|
||||
) {
|
||||
// Only scan shadow DOM if absolutely necessary (expensive operation)
|
||||
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;
|
||||
}
|
||||
});
|
||||
},
|
||||
{ timeout: 1000 }
|
||||
);
|
||||
break;
|
||||
}
|
||||
});
|
||||
},
|
||||
{ timeout: 1000 }
|
||||
);
|
||||
}
|
||||
});
|
||||
function checkForVideo(node, parent, added) {
|
||||
if (!added && document.body.contains(node)) return;
|
||||
@@ -719,9 +823,30 @@ function initializeNow(doc) {
|
||||
(node.nodeName === "AUDIO" && tc.settings.audioBoolean)
|
||||
) {
|
||||
if (added) {
|
||||
if (!node.vsc) node.vsc = new tc.videoController(node, parent);
|
||||
if (!node.vsc) {
|
||||
log(`Creating controller for ${node.tagName}: ${node.src || node.currentSrc || 'no src'}`, 4);
|
||||
node.vsc = new tc.videoController(node, parent);
|
||||
|
||||
// Verify controller was created successfully
|
||||
if (!node.vsc || !node.vsc.div) {
|
||||
log(`ERROR: Controller creation failed for ${node.tagName}`, 2);
|
||||
} else {
|
||||
log(`Controller created successfully, div in DOM: ${document.contains(node.vsc.div)}`, 4);
|
||||
}
|
||||
|
||||
// Add to intersection observer if available
|
||||
if (doc.vscVideoIntersectionObserver) {
|
||||
doc.vscVideoIntersectionObserver.observe(node);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (node.vsc) node.vsc.remove();
|
||||
if (node.vsc) {
|
||||
node.vsc.remove();
|
||||
// Remove from intersection observer if available
|
||||
if (doc.vscVideoIntersectionObserver) {
|
||||
doc.vscVideoIntersectionObserver.unobserve(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (node.children != undefined) {
|
||||
for (var i = 0; i < node.children.length; i++) {
|
||||
@@ -731,9 +856,10 @@ function initializeNow(doc) {
|
||||
}
|
||||
}
|
||||
observer.observe(doc, {
|
||||
attributeFilter: ["aria-hidden"],
|
||||
attributeFilter: ["aria-hidden", "src", "currentSrc"], // Removed "style" and "class" for better performance
|
||||
childList: true,
|
||||
subtree: true
|
||||
subtree: true,
|
||||
attributes: true
|
||||
});
|
||||
doc.vscMutationObserverAttached = true;
|
||||
}
|
||||
@@ -744,6 +870,45 @@ function initializeNow(doc) {
|
||||
if (!v.vsc) new tc.videoController(v, v.parentElement);
|
||||
});
|
||||
|
||||
// Enhanced video detection via media events
|
||||
if (!doc.vscMediaEventListenersAttached) {
|
||||
const mediaEvents = ['loadstart', 'loadedmetadata', 'canplay', 'play'];
|
||||
mediaEvents.forEach(eventType => {
|
||||
doc.addEventListener(eventType, function (event) {
|
||||
const target = event.target;
|
||||
if ((target.tagName === 'VIDEO' || (target.tagName === 'AUDIO' && tc.settings.audioBoolean)) && !target.vsc) {
|
||||
log(`Media event ${eventType} detected new ${target.tagName.toLowerCase()}`, 5);
|
||||
checkForVideo(target, target.parentElement, true);
|
||||
}
|
||||
}, true);
|
||||
});
|
||||
doc.vscMediaEventListenersAttached = true;
|
||||
}
|
||||
|
||||
// Intersection Observer for lazy-loaded videos
|
||||
if (!doc.vscIntersectionObserverAttached && 'IntersectionObserver' in window) {
|
||||
const videoIntersectionObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const target = entry.target;
|
||||
if ((target.tagName === 'VIDEO' || (target.tagName === 'AUDIO' && tc.settings.audioBoolean)) && !target.vsc) {
|
||||
log(`Intersection observer detected visible ${target.tagName.toLowerCase()}`, 5);
|
||||
checkForVideo(target, target.parentElement, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.1 });
|
||||
|
||||
// Observe existing videos that might not have been processed
|
||||
doc.querySelectorAll(q).forEach(video => {
|
||||
videoIntersectionObserver.observe(video);
|
||||
});
|
||||
|
||||
// Store observer to add new videos to it
|
||||
doc.vscVideoIntersectionObserver = videoIntersectionObserver;
|
||||
doc.vscIntersectionObserverAttached = true;
|
||||
}
|
||||
|
||||
Array.from(doc.getElementsByTagName("iframe")).forEach((f) => {
|
||||
if (f.vscLoadListenerAttached) return;
|
||||
f.addEventListener("load", () => {
|
||||
@@ -764,6 +929,122 @@ function initializeNow(doc) {
|
||||
// Silently ignore CORS errors
|
||||
}
|
||||
});
|
||||
// Navigation change detection for SPAs
|
||||
if (!doc.vscNavigationListenersAttached) {
|
||||
let currentUrl = location.href;
|
||||
|
||||
const handleNavigation = (source) => {
|
||||
if (location.href !== currentUrl) {
|
||||
const oldUrl = currentUrl;
|
||||
currentUrl = location.href;
|
||||
log(`Navigation detected via ${source}: ${oldUrl} -> ${currentUrl}`, 4);
|
||||
|
||||
// Wait a bit for the new content to load, then force re-scan
|
||||
setTimeout(() => {
|
||||
const q = tc.settings.audioBoolean ? "video,audio" : "video";
|
||||
const videos = document.querySelectorAll(q);
|
||||
log(`Post-navigation scan found ${videos.length} videos`, 4);
|
||||
|
||||
videos.forEach(video => {
|
||||
if (!video.vsc) {
|
||||
log(`Adding controller to post-navigation video`, 4);
|
||||
checkForVideo(video, video.parentElement, true);
|
||||
}
|
||||
});
|
||||
}, 500); // Increased delay for content to load
|
||||
|
||||
// Also do a quicker scan
|
||||
setTimeout(() => {
|
||||
const q = tc.settings.audioBoolean ? "video,audio" : "video";
|
||||
const videos = document.querySelectorAll(q);
|
||||
videos.forEach(video => {
|
||||
if (!video.vsc && (video.src || video.currentSrc)) {
|
||||
checkForVideo(video, video.parentElement, true);
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for popstate (back/forward navigation)
|
||||
window.addEventListener('popstate', () => handleNavigation('popstate'));
|
||||
|
||||
// Override pushState and replaceState to catch programmatic navigation
|
||||
const originalPushState = history.pushState;
|
||||
const originalReplaceState = history.replaceState;
|
||||
|
||||
history.pushState = function (...args) {
|
||||
originalPushState.apply(this, args);
|
||||
handleNavigation('pushState');
|
||||
};
|
||||
|
||||
history.replaceState = function (...args) {
|
||||
originalReplaceState.apply(this, args);
|
||||
handleNavigation('replaceState');
|
||||
};
|
||||
|
||||
// Listen for hashchange as well
|
||||
window.addEventListener('hashchange', () => handleNavigation('hashchange'));
|
||||
|
||||
// Throttle fetch-based video scanning to reduce CPU usage
|
||||
let lastFetchScanTime = 0;
|
||||
const FETCH_SCAN_THROTTLE = 2000; // Only scan once every 2 seconds max
|
||||
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = function (...args) {
|
||||
return originalFetch.apply(this, args).then(response => {
|
||||
// Throttle video scanning after fetch to avoid excessive CPU usage
|
||||
const now = Date.now();
|
||||
if (now - lastFetchScanTime > FETCH_SCAN_THROTTLE) {
|
||||
lastFetchScanTime = now;
|
||||
setTimeout(() => {
|
||||
const q = tc.settings.audioBoolean ? "video,audio" : "video";
|
||||
const videos = document.querySelectorAll(q);
|
||||
videos.forEach(video => {
|
||||
if (!video.vsc && (video.src || video.currentSrc || video.readyState > 0)) {
|
||||
log(`Post-fetch scan found video`, 5);
|
||||
checkForVideo(video, video.parentElement, true);
|
||||
}
|
||||
});
|
||||
}, 200);
|
||||
}
|
||||
return response;
|
||||
});
|
||||
};
|
||||
|
||||
doc.vscNavigationListenersAttached = true;
|
||||
}
|
||||
|
||||
// Periodic fallback scan for missed videos
|
||||
if (!doc.vscPeriodicScanAttached) {
|
||||
const periodicScan = () => {
|
||||
const q = tc.settings.audioBoolean ? "video,audio" : "video";
|
||||
const allVideos = doc.querySelectorAll(q);
|
||||
let foundNewCount = 0;
|
||||
|
||||
allVideos.forEach(video => {
|
||||
if (!video.vsc && (video.src || video.currentSrc || video.readyState > 0)) {
|
||||
log(`Periodic scan found missed ${video.tagName.toLowerCase()}`, 4);
|
||||
checkForVideo(video, video.parentElement, true);
|
||||
foundNewCount++;
|
||||
}
|
||||
});
|
||||
|
||||
if (foundNewCount > 0) {
|
||||
log(`Periodic scan found ${foundNewCount} new videos`, 4);
|
||||
}
|
||||
};
|
||||
|
||||
// Run periodic scan every 5 seconds (reduced frequency), only if we have videos
|
||||
setInterval(() => {
|
||||
if (tc.mediaElements.length > 0 || doc.querySelector(tc.settings.audioBoolean ? "video,audio" : "video")) {
|
||||
periodicScan();
|
||||
}
|
||||
}, 5000); // Increased from 3s to 5s for better performance
|
||||
|
||||
doc.vscPeriodicScanAttached = true;
|
||||
}
|
||||
|
||||
vscInitializedDocuments.add(doc);
|
||||
}
|
||||
|
||||
@@ -779,6 +1060,9 @@ function setSpeed(video, speed, isInitialCall = false, isUserKeyPress = false) {
|
||||
tc.settings.lastSpeed = numericSpeed;
|
||||
video.vsc.speedIndicator.textContent = numericSpeed.toFixed(2);
|
||||
|
||||
// Update the target speed for nudge so it knows what to revert to
|
||||
video.vsc.targetSpeed = numericSpeed;
|
||||
|
||||
if (isUserKeyPress && !isInitialCall && video.vsc && video.vsc.div) {
|
||||
runAction("blink", 1000, null, video); // Pass video to blink
|
||||
}
|
||||
@@ -865,18 +1149,19 @@ function runAction(action, value, e) {
|
||||
v.currentTime += numValue;
|
||||
break;
|
||||
case "faster":
|
||||
setSpeed(
|
||||
v,
|
||||
Math.min(
|
||||
(v.playbackRate < 0.07 ? 0.07 : v.playbackRate) + numValue,
|
||||
16
|
||||
),
|
||||
false,
|
||||
true
|
||||
);
|
||||
// Round to the step precision to avoid floating-point issues (e.g., 1.80 + 0.1 = 1.9000000000000001)
|
||||
var fasterStep = numValue;
|
||||
var fasterPrecision = Math.round(1 / fasterStep); // e.g., 0.1 -> 10, 0.05 -> 20, 0.25 -> 4
|
||||
var newFasterSpeed = (v.playbackRate < 0.07 ? 0.07 : v.playbackRate) + fasterStep;
|
||||
newFasterSpeed = Math.round(newFasterSpeed * fasterPrecision) / fasterPrecision;
|
||||
setSpeed(v, Math.min(newFasterSpeed, 16), false, true);
|
||||
break;
|
||||
case "slower":
|
||||
setSpeed(v, Math.max(v.playbackRate - numValue, 0.07), false, true);
|
||||
var slowerStep = numValue;
|
||||
var slowerPrecision = Math.round(1 / slowerStep);
|
||||
var newSlowerSpeed = v.playbackRate - slowerStep;
|
||||
newSlowerSpeed = Math.round(newSlowerSpeed * slowerPrecision) / slowerPrecision;
|
||||
setSpeed(v, Math.max(newSlowerSpeed, 0.07), false, true);
|
||||
break;
|
||||
case "reset":
|
||||
resetSpeed(v, 1.0, false); // Use enhanced resetSpeed
|
||||
@@ -889,24 +1174,31 @@ function runAction(action, value, e) {
|
||||
controller.classList.toggle("vsc-hidden");
|
||||
break;
|
||||
case "blink":
|
||||
if (
|
||||
controller.classList.contains("vsc-hidden") ||
|
||||
controller.blinkTimeOut !== undefined
|
||||
) {
|
||||
log(`Blink action: controller hidden=${controller.classList.contains("vsc-hidden")}, timeout=${controller.blinkTimeOut !== undefined}, duration=${numValue}`, 5);
|
||||
|
||||
// Always clear existing timeout and show controller
|
||||
if (controller.blinkTimeOut !== undefined) {
|
||||
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;
|
||||
}, numValue || 1000); // FIXED: Use numValue for consistency
|
||||
}
|
||||
|
||||
// Always show the controller
|
||||
controller.classList.remove("vsc-hidden");
|
||||
log(`Controller shown, setting timeout for ${numValue || 1000}ms`, 5);
|
||||
|
||||
controller.blinkTimeOut = setTimeout(() => {
|
||||
if (
|
||||
!(
|
||||
controller.classList.contains("vsc-manual") &&
|
||||
!controller.classList.contains("vsc-hidden")
|
||||
)
|
||||
) {
|
||||
controller.classList.add("vsc-hidden");
|
||||
log("Controller auto-hidden after blink timeout", 5);
|
||||
} else {
|
||||
log("Controller kept visible (manual mode)", 5);
|
||||
}
|
||||
controller.blinkTimeOut = undefined;
|
||||
}, numValue || 1000);
|
||||
break;
|
||||
case "drag":
|
||||
if (e) handleDrag(v, e);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "Video Speed Controller",
|
||||
"short_name": "videospeed",
|
||||
"version": "1.6.1",
|
||||
"version": "2.1.0",
|
||||
"manifest_version": 2,
|
||||
"description": "Speed up, slow down, advance and rewind HTML5 audio/video with shortcuts",
|
||||
"homepage_url": "https://github.com/SoPat712/videospeed",
|
||||
|
||||
Reference in New Issue
Block a user