mirror of
https://github.com/SoPat712/Speeder.git
synced 2026-10-08 19:05:02 -04:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5d697b7ea
|
||
|
|
8073014bce
|
||
|
|
7e3054f6fa
|
||
|
|
80aa00670c
|
||
|
|
5af0200008
|
||
|
|
13350e5cea
|
||
|
|
461c8c448f
|
||
|
|
39c1c76db1
|
||
|
|
f8cbcd4368
|
||
|
|
e5ae1ed4ba
|
||
|
|
1ae7bbdee3
|
||
|
|
f1e8f44f42
|
||
|
|
7fd8e5d92d
|
||
|
|
fd9911855a
|
||
|
|
5b8f2422ab
|
||
|
|
c429215626
|
||
|
|
432ed37f45
|
||
|
|
51caae3b7c
|
||
|
|
5df0bbc551
|
No files matched your search
@@ -11,7 +11,6 @@
|
||||
/* Native media controls can fullscreen the replaced <video> element without
|
||||
calling the page's requestFullscreen method. A manual popover keeps the
|
||||
controller in the browser top layer without nesting it inside <video>. */
|
||||
.vsc-controller.vsc-media-document,
|
||||
.vsc-controller.vsc-fullscreen-popover {
|
||||
position: fixed !important;
|
||||
margin: 0 !important;
|
||||
|
||||
+238
-1118
@@ -1,40 +1,3 @@
|
||||
// Keep in sync with the page-world bridge guard. These checks run before any
|
||||
// startup hooks because verification frames inspect native DOM/history APIs.
|
||||
function isChallengeUrl(value) {
|
||||
try {
|
||||
var url = new URL(value);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
||||
var hostname = url.hostname.toLowerCase().replace(/\.$/, "");
|
||||
return (
|
||||
hostname === "challenges.cloudflare.com" ||
|
||||
hostname.endsWith(".challenges.cloudflare.com") ||
|
||||
url.pathname === "/cdn-cgi/challenge-platform" ||
|
||||
url.pathname.indexOf("/cdn-cgi/challenge-platform/") === 0
|
||||
);
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isChallengeDocument(win) {
|
||||
try {
|
||||
if (isChallengeUrl(win.location.href)) return true;
|
||||
if (!/^about:(blank|srcdoc)(?:[?#]|$)/i.test(win.location.href)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
isChallengeUrl(win.document.baseURI) ||
|
||||
isChallengeUrl(win.document.referrer)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return win.parent !== win && isChallengeDocument(win.parent);
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var vscIsChallengeDocument = isChallengeDocument(window);
|
||||
var lastToggleSpeed = {}; // Store last toggle speeds per video
|
||||
var speederShared =
|
||||
typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {};
|
||||
@@ -64,63 +27,9 @@ function getSharedDefault(key, fallback) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function getViewportScrollEpoch(win) {
|
||||
return Number(win && win.vscViewportScrollEpoch) || 0;
|
||||
}
|
||||
|
||||
function getViewportScrollX(win) {
|
||||
var scrollX = Number(win && win.scrollX);
|
||||
if (Number.isFinite(scrollX)) return scrollX;
|
||||
return Number(win && win.pageXOffset) || 0;
|
||||
}
|
||||
|
||||
function getViewportScrollY(win) {
|
||||
var scrollY = Number(win && win.scrollY);
|
||||
if (Number.isFinite(scrollY)) return scrollY;
|
||||
return Number(win && win.pageYOffset) || 0;
|
||||
}
|
||||
|
||||
function cacheVideoRect(wrapper, video, rect) {
|
||||
if (!wrapper || !rect) return null;
|
||||
var win =
|
||||
video && video.ownerDocument ? video.ownerDocument.defaultView : window;
|
||||
wrapper.vscVideoRect = {
|
||||
left: Number(rect.left) || 0,
|
||||
top: Number(rect.top) || 0,
|
||||
right: Number(rect.right) || 0,
|
||||
bottom: Number(rect.bottom) || 0,
|
||||
width: Number(rect.width) || 0,
|
||||
height: Number(rect.height) || 0,
|
||||
viewportScrollEpoch: getViewportScrollEpoch(win),
|
||||
viewportScrollX: getViewportScrollX(win),
|
||||
viewportScrollY: getViewportScrollY(win)
|
||||
};
|
||||
return wrapper.vscVideoRect;
|
||||
}
|
||||
|
||||
function getCachedVideoRect(video) {
|
||||
var wrapper = video && video.vsc && video.vsc.div;
|
||||
var rect = (wrapper && wrapper.vscVideoRect) || null;
|
||||
if (!rect || !video) return rect;
|
||||
|
||||
var win = video.ownerDocument && video.ownerDocument.defaultView;
|
||||
var currentScrollEpoch = getViewportScrollEpoch(win);
|
||||
if (
|
||||
rect.viewportScrollEpoch === currentScrollEpoch &&
|
||||
rect.viewportScrollX === getViewportScrollX(win) &&
|
||||
rect.viewportScrollY === getViewportScrollY(win)
|
||||
) {
|
||||
return rect;
|
||||
}
|
||||
|
||||
// Scrolling changes getBoundingClientRect() without changing player layout.
|
||||
// Refresh lazily on the next action so targeting stays exact for normal,
|
||||
// fixed, sticky, and nested-scroll players without layout work per scroll.
|
||||
try {
|
||||
return cacheVideoRect(wrapper, video, video.getBoundingClientRect());
|
||||
} catch (_error) {
|
||||
return rect;
|
||||
}
|
||||
return (wrapper && wrapper.vscVideoRect) || null;
|
||||
}
|
||||
|
||||
function getPrimaryVideoElement(mediaElements) {
|
||||
@@ -333,11 +242,8 @@ var tc = {
|
||||
activeSiteRule: null,
|
||||
tabPaused: false,
|
||||
siteRuleBase: null,
|
||||
siteRuleAppliedHref: null,
|
||||
runtimeSettingsHydrated: false,
|
||||
rawSyncSettingsSnapshot: null,
|
||||
pendingMediaCandidates: [],
|
||||
pendingMediaCandidateNodes: new WeakSet(),
|
||||
settingsReloadRetries: 0,
|
||||
lastPointerPosition: null,
|
||||
lastInteractedMedia: null,
|
||||
@@ -362,7 +268,6 @@ function applyTabPausedState(paused) {
|
||||
if (!tc.runtimeSettingsHydrated) return;
|
||||
if (tc.tabPaused) {
|
||||
clearAllSpeedRestoreEnforcement();
|
||||
clearDeferredMediaCandidates();
|
||||
tc.mediaElements.slice().forEach(function(media) {
|
||||
removeController(media);
|
||||
});
|
||||
@@ -378,15 +283,10 @@ var YT_NATIVE_MAX = 2.0;
|
||||
var YT_NATIVE_STEP = 0.05;
|
||||
var vscObservedRoots = new WeakSet();
|
||||
var vscObservedRootList = [];
|
||||
var vscSupportsWeakRootReferences = typeof WeakRef === "function";
|
||||
var vscSuspendedObservedRoots = new WeakSet();
|
||||
var vscSuspendedObservedRootList = [];
|
||||
var vscConnectedScannedRoots = new WeakSet();
|
||||
var vscInitializedDocuments = new WeakSet();
|
||||
var vscSourceObjectIds = new WeakMap();
|
||||
var vscNextSourceObjectId = 1;
|
||||
var vscControllersByMount = new WeakMap();
|
||||
var vscDeferredMediaCandidates = new Set();
|
||||
var requestIdle =
|
||||
typeof window.requestIdleCallback === "function"
|
||||
? window.requestIdleCallback.bind(window)
|
||||
@@ -705,7 +605,6 @@ function applyControllerLocation(videoController, location) {
|
||||
}
|
||||
|
||||
function captureSiteRuleBase() {
|
||||
tc.siteRuleAppliedHref = null;
|
||||
tc.siteRuleBase = {
|
||||
startHidden: tc.settings.startHidden,
|
||||
hideWithControls: tc.settings.hideWithControls,
|
||||
@@ -1195,11 +1094,6 @@ function schedulePersistLastSpeed(speed) {
|
||||
|
||||
var MAX_PERSISTED_SOURCE_SPEEDS = 200;
|
||||
|
||||
function rememberToggleSpeed(sourceKey, speed) {
|
||||
if (!sourceKey || !isValidSpeed(speed)) return;
|
||||
lastToggleSpeed[sourceKey] = Number(speed);
|
||||
}
|
||||
|
||||
function isPersistableSourceKey(sourceKey) {
|
||||
return Boolean(
|
||||
sourceKey &&
|
||||
@@ -1606,63 +1500,6 @@ function isMediaElement(node) {
|
||||
);
|
||||
}
|
||||
|
||||
function isMediaSourceAttributeMutation(mutation, media) {
|
||||
if (!mutation || mutation.type !== "attributes" || !media) return false;
|
||||
if (mutation.target === media) {
|
||||
return (
|
||||
mutation.attributeName === "src" ||
|
||||
mutation.attributeName === "currentSrc"
|
||||
);
|
||||
}
|
||||
return Boolean(
|
||||
mutation.attributeName === "src" &&
|
||||
mutation.target &&
|
||||
mutation.target.nodeName === "SOURCE" &&
|
||||
mutation.target.parentElement === media
|
||||
);
|
||||
}
|
||||
|
||||
function observeMediaSourceNodes(media) {
|
||||
if (!media || media.vscBootstrapSourceObserver) return;
|
||||
|
||||
var observer = new MutationObserver(function(mutations) {
|
||||
if (!media.isConnected) {
|
||||
observer.disconnect();
|
||||
delete media.vscBootstrapSourceObserver;
|
||||
return;
|
||||
}
|
||||
var sourceChanged = false;
|
||||
for (var i = 0; i < mutations.length; i += 1) {
|
||||
if (isMediaSourceAttributeMutation(mutations[i], media)) {
|
||||
sourceChanged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (sourceChanged) {
|
||||
ensureController(media, media.parentElement || media.parentNode);
|
||||
}
|
||||
});
|
||||
media.vscBootstrapSourceObserver = observer;
|
||||
observer.observe(media, {
|
||||
attributes: true,
|
||||
subtree: true,
|
||||
attributeFilter: ["src", "currentSrc"]
|
||||
});
|
||||
}
|
||||
|
||||
function disconnectBootstrapMediaSourceObserver(media) {
|
||||
if (!media || !media.vscBootstrapSourceObserver) return;
|
||||
media.vscBootstrapSourceObserver.disconnect();
|
||||
delete media.vscBootstrapSourceObserver;
|
||||
}
|
||||
|
||||
function clearDeferredMediaCandidates() {
|
||||
vscDeferredMediaCandidates.forEach(function(media) {
|
||||
disconnectBootstrapMediaSourceObserver(media);
|
||||
});
|
||||
vscDeferredMediaCandidates.clear();
|
||||
}
|
||||
|
||||
function hasInteractivePlayerChrome(node) {
|
||||
if (!node || node.nodeName !== "VIDEO") return false;
|
||||
if (node.controls === true) return true;
|
||||
@@ -1753,12 +1590,12 @@ function hasUsableMediaSource(node) {
|
||||
}
|
||||
|
||||
if (node.querySelectorAll) {
|
||||
var sources = node.querySelectorAll("source[src]");
|
||||
for (var i = 0; i < sources.length; i += 1) {
|
||||
var source = sources[i];
|
||||
return Array.from(node.querySelectorAll("source[src]")).some(function(
|
||||
source
|
||||
) {
|
||||
var src = source.getAttribute("src");
|
||||
if (typeof src === "string" && src.trim().length > 0) return true;
|
||||
}
|
||||
return typeof src === "string" && src.trim().length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -1778,9 +1615,10 @@ function ensureController(node, parent) {
|
||||
if (
|
||||
node &&
|
||||
(node.nodeName === "VIDEO" || node.nodeName === "AUDIO") &&
|
||||
!tc.pendingMediaCandidateNodes.has(node)
|
||||
!tc.pendingMediaCandidates.some(function(candidate) {
|
||||
return candidate.node === node;
|
||||
})
|
||||
) {
|
||||
tc.pendingMediaCandidateNodes.add(node);
|
||||
tc.pendingMediaCandidates.push({ node: node, parent: parent });
|
||||
}
|
||||
return null;
|
||||
@@ -1788,18 +1626,15 @@ function ensureController(node, parent) {
|
||||
if (!isMediaElement(node)) return null;
|
||||
if (typeof tc.videoController === "undefined") defineVideoController();
|
||||
|
||||
// The URL is the only page input to site-rule matching. Reapply once when
|
||||
// it changes, while keeping every media discovery path synchronous.
|
||||
if (!reapplySiteRulesAndControllerGeometry()) {
|
||||
disconnectBootstrapMediaSourceObserver(node);
|
||||
vscDeferredMediaCandidates.delete(node);
|
||||
// href selects site rules; re-run on every new/usable media so all runtime
|
||||
// paths agree on activation and effective settings.
|
||||
applySiteRuleOverrides();
|
||||
if (!isSpeederActiveForCurrentPage()) {
|
||||
if (node.vsc) removeController(node);
|
||||
return null;
|
||||
}
|
||||
if (isAmbientLoopMedia(node)) {
|
||||
if (node.vsc) removeController(node);
|
||||
disconnectBootstrapMediaSourceObserver(node);
|
||||
vscDeferredMediaCandidates.add(node);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1815,8 +1650,6 @@ function ensureController(node, parent) {
|
||||
}
|
||||
|
||||
if (node.vsc) {
|
||||
vscDeferredMediaCandidates.delete(node);
|
||||
disconnectBootstrapMediaSourceObserver(node);
|
||||
var existingController = node.vsc;
|
||||
var hasSource = hasUsableMediaSource(node);
|
||||
if (existingController.div) {
|
||||
@@ -1825,7 +1658,7 @@ function ensureController(node, parent) {
|
||||
|
||||
var fullscreenElement = getFullscreenElement(node.ownerDocument);
|
||||
var normalMount = getControllerMount(node) || parent || node.parentNode;
|
||||
if (normalMount) setNormalControllerMount(existingController, normalMount);
|
||||
if (normalMount) existingController.normalControllerMount = normalMount;
|
||||
if (
|
||||
fullscreenElement &&
|
||||
(fullscreenElement === node ||
|
||||
@@ -1843,8 +1676,6 @@ function ensureController(node, parent) {
|
||||
}
|
||||
|
||||
if (!hasUsableMediaSource(node)) {
|
||||
vscDeferredMediaCandidates.add(node);
|
||||
observeMediaSourceNodes(node);
|
||||
log(
|
||||
`Deferring controller creation for ${node.tagName}: no usable source yet`,
|
||||
5
|
||||
@@ -1852,13 +1683,13 @@ function ensureController(node, parent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
refreshAllControllerGeometry();
|
||||
|
||||
log(
|
||||
`Creating controller for ${node.tagName}: ${node.src || node.currentSrc || "no src"}`,
|
||||
4
|
||||
);
|
||||
try {
|
||||
vscDeferredMediaCandidates.delete(node);
|
||||
disconnectBootstrapMediaSourceObserver(node);
|
||||
node.vsc = new tc.videoController(
|
||||
node,
|
||||
parent || node.parentElement || node.parentNode
|
||||
@@ -1866,15 +1697,11 @@ function ensureController(node, parent) {
|
||||
} catch (error) {
|
||||
log(`Unable to create media controller: ${error.message}`, 2);
|
||||
removeController(node);
|
||||
vscDeferredMediaCandidates.add(node);
|
||||
observeMediaSourceNodes(node);
|
||||
return null;
|
||||
}
|
||||
if (!node.vsc || !node.vsc.div) {
|
||||
log("Controller initialization returned without a control host", 3);
|
||||
removeController(node);
|
||||
vscDeferredMediaCandidates.add(node);
|
||||
observeMediaSourceNodes(node);
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
@@ -1890,7 +1717,6 @@ function ensureController(node, parent) {
|
||||
function flushPendingMediaCandidates() {
|
||||
var pending = tc.pendingMediaCandidates.splice(0);
|
||||
pending.forEach(function(candidate) {
|
||||
if (candidate.node) tc.pendingMediaCandidateNodes.delete(candidate.node);
|
||||
if (!candidate.node || !candidate.node.isConnected) return;
|
||||
ensureController(
|
||||
candidate.node,
|
||||
@@ -1909,10 +1735,7 @@ function ensureControllerForMediaChild(node) {
|
||||
}
|
||||
|
||||
function removeController(node) {
|
||||
if (!node) return;
|
||||
vscDeferredMediaCandidates.delete(node);
|
||||
disconnectBootstrapMediaSourceObserver(node);
|
||||
if (!node.vsc) return;
|
||||
if (!node || !node.vsc) return;
|
||||
var controller = node.vsc;
|
||||
if (typeof controller.remove === "function") {
|
||||
try {
|
||||
@@ -1922,14 +1745,6 @@ function removeController(node) {
|
||||
log(`Partial controller cleanup failed: ${error.message}`, 3);
|
||||
}
|
||||
}
|
||||
if (controller.controllerHostCleanup) {
|
||||
try {
|
||||
controller.controllerHostCleanup(true);
|
||||
} catch (_error) {}
|
||||
controller.controllerHostCleanup = null;
|
||||
}
|
||||
unregisterControllerMount(controller, controller.controllerHostMount);
|
||||
controller.controllerHostSchedule = null;
|
||||
if (controller.div && typeof controller.div.remove === "function") {
|
||||
controller.div.remove();
|
||||
}
|
||||
@@ -1953,9 +1768,8 @@ function scanNodeForMedia(node, parent, added) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A removed-node record can represent a move. isConnected works across
|
||||
// shadow boundaries; document.body.contains() does not.
|
||||
if (!added && node.isConnected) return;
|
||||
var ownerDocument = node.ownerDocument || document;
|
||||
if (!added && ownerDocument.body && ownerDocument.body.contains(node)) return;
|
||||
|
||||
// Check if the node itself is a media element
|
||||
if (isMediaElement(node)) {
|
||||
@@ -1995,39 +1809,6 @@ function scanNodeForMedia(node, parent, added) {
|
||||
observeRoot(node.shadowRoot);
|
||||
}
|
||||
|
||||
if (
|
||||
added &&
|
||||
!vscSupportsWeakRootReferences &&
|
||||
typeof node.querySelectorAll === "function"
|
||||
) {
|
||||
// Engines without WeakRef cannot retain detached roots safely. Rediscover
|
||||
// nested open roots only when their containing subtree is reinserted.
|
||||
rescanOpenShadowRoots(node, undefined, false);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function compactMutationScanCandidates(candidates) {
|
||||
var candidateByNode = new Map();
|
||||
candidates.forEach(function(candidate) {
|
||||
if (!candidate || !candidate.node) return;
|
||||
// Keep the latest mutation target for a node that moved more than once.
|
||||
candidateByNode.set(candidate.node, candidate);
|
||||
});
|
||||
|
||||
var candidateNodes = new Set(candidateByNode.keys());
|
||||
return Array.from(candidateByNode.values()).filter(function(candidate) {
|
||||
// A light-DOM ancestor scan cannot cross into this node's shadow root.
|
||||
// Keep the host as its own scan root so shadow-media detection is unchanged.
|
||||
if (candidate.node.shadowRoot) return true;
|
||||
|
||||
var ancestor = candidate.node.parentNode;
|
||||
while (ancestor) {
|
||||
if (candidateNodes.has(ancestor)) return false;
|
||||
ancestor = ancestor.parentNode;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function getScanNodeForRoot(root) {
|
||||
@@ -2079,208 +1860,52 @@ function rescanOpenShadowRoots(root, visited, rescanObserved) {
|
||||
|
||||
hosts.forEach(function(host) {
|
||||
var shadowRoot = host.shadowRoot;
|
||||
if (
|
||||
!shadowRoot ||
|
||||
seen.has(shadowRoot) ||
|
||||
isSpeederControllerShadowRoot(shadowRoot)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!shadowRoot || seen.has(shadowRoot)) return;
|
||||
seen.add(shadowRoot);
|
||||
if (
|
||||
rescanObserved !== false ||
|
||||
!vscObservedRoots.has(shadowRoot) ||
|
||||
!shadowRoot.vscMutationObserverAttached
|
||||
) {
|
||||
if (rescanObserved !== false || !vscObservedRoots.has(shadowRoot)) {
|
||||
observeRoot(shadowRoot);
|
||||
}
|
||||
rescanOpenShadowRoots(shadowRoot, seen, rescanObserved);
|
||||
});
|
||||
}
|
||||
|
||||
function isSpeederControllerHost(node) {
|
||||
return Boolean(node && node.vscControllerHost === true);
|
||||
}
|
||||
|
||||
function isSpeederControllerShadowRoot(root) {
|
||||
return Boolean(
|
||||
root &&
|
||||
root.host &&
|
||||
isSpeederControllerHost(root.host)
|
||||
);
|
||||
}
|
||||
|
||||
function isObservedRootConnected(root) {
|
||||
if (!root) return false;
|
||||
try {
|
||||
return Boolean(
|
||||
root.nodeType === Node.DOCUMENT_NODE ||
|
||||
root.isConnected ||
|
||||
(root.host &&
|
||||
(root.host.isConnected ||
|
||||
(root.host.ownerDocument &&
|
||||
root.host.ownerDocument.contains(root.host)))) ||
|
||||
(root.ownerDocument && root.ownerDocument.contains(root))
|
||||
);
|
||||
} catch (_error) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function trackObservedRoot(root) {
|
||||
if (
|
||||
!root ||
|
||||
root.nodeType === Node.DOCUMENT_NODE ||
|
||||
root.vscObservedRootTracked
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
root.vscObservedRootTracked = true;
|
||||
vscObservedRootList.push(createObservedRootReference(root));
|
||||
}
|
||||
|
||||
function forEachTrackedObservedRoot(callback) {
|
||||
var retainedRootReferences = [];
|
||||
vscObservedRootList.forEach(function(rootReference) {
|
||||
var root = dereferenceObservedRoot(rootReference);
|
||||
if (!root) return;
|
||||
if (isSpeederControllerShadowRoot(root)) {
|
||||
root.vscObservedRootTracked = false;
|
||||
return;
|
||||
}
|
||||
if (callback(root) !== false) {
|
||||
retainedRootReferences.push(rootReference);
|
||||
}
|
||||
});
|
||||
vscObservedRootList = retainedRootReferences;
|
||||
}
|
||||
|
||||
function createObservedRootReference(root) {
|
||||
return vscSupportsWeakRootReferences ? new WeakRef(root) : root;
|
||||
}
|
||||
|
||||
function dereferenceObservedRoot(rootReference) {
|
||||
return vscSupportsWeakRootReferences ? rootReference.deref() : rootReference;
|
||||
}
|
||||
|
||||
function trackSuspendedObservedRoot(root) {
|
||||
if (!root || vscSuspendedObservedRoots.has(root)) return;
|
||||
vscSuspendedObservedRoots.add(root);
|
||||
vscSuspendedObservedRootList.push(createObservedRootReference(root));
|
||||
}
|
||||
|
||||
function forgetSuspendedObservedRoot(root) {
|
||||
if (!root) return;
|
||||
vscSuspendedObservedRoots.delete(root);
|
||||
vscSuspendedObservedRootList = vscSuspendedObservedRootList.filter(
|
||||
function(rootReference) {
|
||||
var suspendedRoot = dereferenceObservedRoot(rootReference);
|
||||
return suspendedRoot && suspendedRoot !== root;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function suspendRootMutationObserver(root, retainForReconnect) {
|
||||
if (retainForReconnect !== false) trackSuspendedObservedRoot(root);
|
||||
var observer = root && root.vscMutationObserver;
|
||||
if (!observer) return;
|
||||
observer.disconnect();
|
||||
if (typeof observer.takeRecords === "function") observer.takeRecords();
|
||||
root.vscMutationObserver = null;
|
||||
root.vscMutationObserverAttached = false;
|
||||
}
|
||||
|
||||
function reconcileObservedRootConnections(doc, scanAllConnected) {
|
||||
forEachTrackedObservedRoot(function(root) {
|
||||
var connected = isObservedRootConnected(root);
|
||||
if (!connected) {
|
||||
// Weak references preserve detached/reinserted custom elements without
|
||||
// retaining them. On older engines, release the strong fallback entry;
|
||||
// an insertion scan will rediscover the open root if it returns.
|
||||
suspendRootMutationObserver(root, vscSupportsWeakRootReferences);
|
||||
if (!vscSupportsWeakRootReferences) {
|
||||
forgetSuspendedObservedRoot(root);
|
||||
root.vscObservedRootTracked = false;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function rescanObservedMediaRoots(doc) {
|
||||
var retainedRoots = [];
|
||||
vscObservedRootList.forEach(function(root) {
|
||||
if (!root || root.nodeType === Node.DOCUMENT_NODE) return;
|
||||
var connected = Boolean(root.host ? root.host.isConnected : root.isConnected);
|
||||
if (!connected) return;
|
||||
retainedRoots.push(root);
|
||||
if (
|
||||
doc &&
|
||||
root.ownerDocument !== doc &&
|
||||
(!root.host || root.host.ownerDocument !== doc)
|
||||
!doc ||
|
||||
root.ownerDocument === doc ||
|
||||
(root.host && root.host.ownerDocument === doc)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setupListener(root);
|
||||
attachMediaDetectionListeners(root);
|
||||
vscSuspendedObservedRoots.delete(root);
|
||||
var wasObservingMutations = root.vscMutationObserverAttached === true;
|
||||
if (!wasObservingMutations) attachMutationObserver(root);
|
||||
if (scanAllConnected || !wasObservingMutations) {
|
||||
scanRootForMedia(root);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function resumeSuspendedObservedRoots(doc) {
|
||||
var retainedSuspendedRootReferences = [];
|
||||
vscSuspendedObservedRootList.forEach(function(rootReference) {
|
||||
var root = dereferenceObservedRoot(rootReference);
|
||||
if (!root || isSpeederControllerShadowRoot(root)) return;
|
||||
if (!vscSuspendedObservedRoots.has(root)) return;
|
||||
if (!isObservedRootConnected(root)) {
|
||||
retainedSuspendedRootReferences.push(rootReference);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
doc &&
|
||||
root.ownerDocument !== doc &&
|
||||
(!root.host || root.host.ownerDocument !== doc)
|
||||
) {
|
||||
retainedSuspendedRootReferences.push(rootReference);
|
||||
return;
|
||||
}
|
||||
|
||||
vscSuspendedObservedRoots.delete(root);
|
||||
setupListener(root);
|
||||
attachMediaDetectionListeners(root);
|
||||
if (!root.vscMutationObserverAttached) attachMutationObserver(root);
|
||||
scanRootForMedia(root);
|
||||
});
|
||||
vscSuspendedObservedRootList = retainedSuspendedRootReferences;
|
||||
}
|
||||
|
||||
function getObservedRootDocument(root) {
|
||||
if (!root) return null;
|
||||
if (root.nodeType === Node.DOCUMENT_NODE) return root;
|
||||
return root.ownerDocument || (root.host && root.host.ownerDocument) || null;
|
||||
}
|
||||
|
||||
function rescanObservedMediaRoots(doc) {
|
||||
reconcileObservedRootConnections(doc, true);
|
||||
vscObservedRootList = retainedRoots;
|
||||
}
|
||||
|
||||
function observeRoot(root) {
|
||||
if (!root || isSpeederControllerShadowRoot(root)) return;
|
||||
if (!root) return;
|
||||
|
||||
var isConnected = isObservedRootConnected(root);
|
||||
var isConnected = false;
|
||||
try {
|
||||
isConnected = root.nodeType === Node.DOCUMENT_NODE ||
|
||||
root.isConnected ||
|
||||
(root.host && (root.host.isConnected || (root.host.ownerDocument && root.host.ownerDocument.contains(root.host)))) ||
|
||||
(root.ownerDocument && root.ownerDocument.contains(root));
|
||||
} catch (e) {
|
||||
isConnected = true;
|
||||
}
|
||||
|
||||
if (!vscObservedRoots.has(root)) {
|
||||
vscObservedRoots.add(root);
|
||||
}
|
||||
trackObservedRoot(root);
|
||||
|
||||
if (isConnected) {
|
||||
vscObservedRootList.push(root);
|
||||
setupListener(root);
|
||||
attachMutationObserver(root);
|
||||
attachMediaDetectionListeners(root);
|
||||
if (!root.vscMutationObserverAttached) attachMutationObserver(root);
|
||||
vscSuspendedObservedRoots.delete(root);
|
||||
} else {
|
||||
trackSuspendedObservedRoot(root);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -2318,23 +1943,6 @@ function patchAttachShadow() {
|
||||
window.vscAttachShadowPatched = true;
|
||||
}
|
||||
|
||||
function markPageShadowBridgeReady() {
|
||||
// Close the small interval between the pre-injection discovery pass and the
|
||||
// page-world attachShadow patch becoming active. This runs at document_start
|
||||
// while the tree is still small and replaces the old delayed whole-page scan.
|
||||
rescanOpenShadowRoots(document, undefined, false);
|
||||
window.vscPageShadowBridgeLoaded = true;
|
||||
window.vscPageShadowBridgeRetries = 0;
|
||||
clearTimeout(window.vscPageShadowBridgeRetryTimer);
|
||||
if (window.vscPageNavigationApiBridgeLoaded) stopLocationWatch();
|
||||
if (Array.isArray(window.vscBoundedShadowFallbackTimers)) {
|
||||
window.vscBoundedShadowFallbackTimers.forEach(function(timer) {
|
||||
clearTimeout(timer);
|
||||
});
|
||||
window.vscBoundedShadowFallbackTimers.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function installPageShadowBridge() {
|
||||
if (window.vscPageShadowBridgeRequested) return;
|
||||
|
||||
@@ -2355,24 +1963,11 @@ function installPageShadowBridge() {
|
||||
);
|
||||
document.addEventListener(
|
||||
"speeder-location-changed",
|
||||
function(event) {
|
||||
scheduleNavigationRescan(event);
|
||||
},
|
||||
true
|
||||
);
|
||||
document.addEventListener(
|
||||
"speeder-page-navigation-api-ready",
|
||||
function() {
|
||||
window.vscPageNavigationApiBridgeLoaded = true;
|
||||
if (window.vscPageShadowBridgeLoaded) stopLocationWatch();
|
||||
scheduleNavigationRescan();
|
||||
},
|
||||
true
|
||||
);
|
||||
document.addEventListener(
|
||||
"speeder-page-bridge-ready",
|
||||
markPageShadowBridgeReady,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
function startBoundedShadowFallback() {
|
||||
@@ -2385,7 +1980,7 @@ function installPageShadowBridge() {
|
||||
function() {
|
||||
if (
|
||||
!document.body ||
|
||||
window.vscPageShadowBridgeLoaded
|
||||
(delay !== 3000 && window.vscPageShadowBridgeLoaded)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -2427,19 +2022,14 @@ function installPageShadowBridge() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Discover roots that genuinely predate document_start now, while the DOM
|
||||
// is still small. Once the page-world bridge loads it reports every future
|
||||
// root, so a delayed full-document traversal is unnecessary.
|
||||
if (!window.vscInitialShadowDiscoveryComplete) {
|
||||
window.vscInitialShadowDiscoveryComplete = true;
|
||||
rescanOpenShadowRoots(document, undefined, false);
|
||||
}
|
||||
var bridge = document.createElement("script");
|
||||
bridge.src = chrome.runtime.getURL("content/shadow-bridge.js");
|
||||
bridge.async = false;
|
||||
bridge.addEventListener("load", function() {
|
||||
bridge.remove();
|
||||
if (!window.vscPageShadowBridgeLoaded) handleBridgeFailure();
|
||||
window.vscPageShadowBridgeLoaded = true;
|
||||
window.vscPageShadowBridgeRetries = 0;
|
||||
clearTimeout(window.vscPageShadowBridgeRetryTimer);
|
||||
});
|
||||
bridge.addEventListener("error", function() {
|
||||
bridge.remove();
|
||||
@@ -2473,31 +2063,8 @@ function log(message, level) {
|
||||
}
|
||||
}
|
||||
|
||||
function cloneRuntimeStorageSnapshot(storage) {
|
||||
if (typeof vscClonePlainData === "function") {
|
||||
return vscClonePlainData(storage || {});
|
||||
}
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(storage || {}));
|
||||
} catch (_error) {
|
||||
return Object.assign({}, storage || {});
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeStorageSnapshotsEqual(left, right) {
|
||||
if (typeof vscAreComparableValuesEqual === "function") {
|
||||
return vscAreComparableValuesEqual(left || {}, right || {});
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(left || {}) === JSON.stringify(right || {});
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateRuntimeSettings(rawStorage, options) {
|
||||
var config = options || {};
|
||||
tc.rawSyncSettingsSnapshot = cloneRuntimeStorageSnapshot(rawStorage || {});
|
||||
var storage =
|
||||
typeof vscExpandStoredSettings === "function"
|
||||
? vscExpandStoredSettings(rawStorage || {})
|
||||
@@ -2807,7 +2374,6 @@ function scheduleRuntimeSettingsReload() {
|
||||
clearTimeout(window.vscSettingsReloadTimer);
|
||||
clearTimeout(window.vscSettingsReloadRetryTimer);
|
||||
window.vscSettingsReloadTimer = setTimeout(function() {
|
||||
window.vscSettingsReloadTimer = null;
|
||||
chrome.storage.sync.get(null, function(rawStorage) {
|
||||
if (reloadGeneration !== window.vscSettingsReloadGeneration) return;
|
||||
if (chrome.runtime.lastError) {
|
||||
@@ -2822,14 +2388,6 @@ function scheduleRuntimeSettingsReload() {
|
||||
return;
|
||||
}
|
||||
tc.settingsReloadRetries = 0;
|
||||
if (
|
||||
runtimeStorageSnapshotsEqual(
|
||||
rawStorage || {},
|
||||
tc.rawSyncSettingsSnapshot || {}
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
var wasForceEnabled = tc.settings.forceLastSavedSpeed === true;
|
||||
var wasRememberEnabled = tc.settings.rememberSpeed === true;
|
||||
hydrateRuntimeSettings(rawStorage || {}, {
|
||||
@@ -2852,70 +2410,27 @@ function loadInitialLocalSettings(attempt, callback) {
|
||||
chrome.storage.local.get(
|
||||
["customButtonIcons", "rememberedSpeeds", "rememberedSpeedsResetAt"],
|
||||
function(localStorage) {
|
||||
var readFailed = Boolean(chrome.runtime.lastError);
|
||||
if (readFailed && attempt < 3) {
|
||||
if (chrome.runtime.lastError && attempt < 3) {
|
||||
setTimeout(function() {
|
||||
loadInitialLocalSettings(attempt + 1, callback);
|
||||
}, 100 * (attempt + 1));
|
||||
return;
|
||||
}
|
||||
callback(readFailed ? {} : localStorage || {}, readFailed);
|
||||
callback(chrome.runtime.lastError ? {} : localStorage || {});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function beginStartupStorageWatch() {
|
||||
if (
|
||||
window.vscStartupStorageListener ||
|
||||
!chrome.storage ||
|
||||
!chrome.storage.onChanged
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.vscStartupStorageChanges = { sync: false, local: false };
|
||||
window.vscStartupStorageListener = function(_changes, area) {
|
||||
if (
|
||||
window.vscStartupStorageChanges &&
|
||||
(area === "sync" || area === "local")
|
||||
) {
|
||||
window.vscStartupStorageChanges[area] = true;
|
||||
}
|
||||
};
|
||||
chrome.storage.onChanged.addListener(window.vscStartupStorageListener);
|
||||
}
|
||||
|
||||
function finishStartupStorageWatch() {
|
||||
var changes = window.vscStartupStorageChanges || {
|
||||
sync: true,
|
||||
local: true
|
||||
};
|
||||
if (
|
||||
window.vscStartupStorageListener &&
|
||||
chrome.storage &&
|
||||
chrome.storage.onChanged &&
|
||||
typeof chrome.storage.onChanged.removeListener === "function"
|
||||
) {
|
||||
chrome.storage.onChanged.removeListener(window.vscStartupStorageListener);
|
||||
}
|
||||
window.vscStartupStorageListener = null;
|
||||
window.vscStartupStorageChanges = null;
|
||||
return changes;
|
||||
}
|
||||
|
||||
// Patch attachShadow immediately — before any async operations — so we
|
||||
// catch shadow roots created while chrome.storage.sync.get is pending.
|
||||
// Sites like archive.org create Lit/LitElement shadow DOMs during page load;
|
||||
// waiting for the storage callback would miss them entirely.
|
||||
if (!vscIsChallengeDocument) {
|
||||
patchAttachShadow();
|
||||
installPageShadowBridge();
|
||||
}
|
||||
patchAttachShadow();
|
||||
installPageShadowBridge();
|
||||
|
||||
function loadInitialRuntimeSettings(attempt) {
|
||||
chrome.storage.sync.get(null, function(rawStorage) {
|
||||
var initialSyncReadFailed = Boolean(chrome.runtime.lastError);
|
||||
if (initialSyncReadFailed && attempt < 3) {
|
||||
if (chrome.runtime.lastError && attempt < 3) {
|
||||
setTimeout(function() {
|
||||
loadInitialRuntimeSettings(attempt + 1);
|
||||
}, 100 * (attempt + 1));
|
||||
@@ -3021,7 +2536,7 @@ function loadInitialRuntimeSettings(attempt) {
|
||||
// Set the flag to prevent adding the listener again.
|
||||
window.vscMessageListener = true;
|
||||
}
|
||||
loadInitialLocalSettings(0, function(loc, initialLocalReadFailed) {
|
||||
loadInitialLocalSettings(0, function(loc) {
|
||||
applyCustomButtonIcons(loc && loc.customButtonIcons);
|
||||
|
||||
applyRememberedSpeedsResetMarker(
|
||||
@@ -3039,9 +2554,6 @@ function loadInitialRuntimeSettings(attempt) {
|
||||
if (isLastSpeedOnly) {
|
||||
var changedLastSpeed = Number(changes.lastSpeed.newValue);
|
||||
if (isValidSpeed(changedLastSpeed)) {
|
||||
if (tc.rawSyncSettingsSnapshot) {
|
||||
tc.rawSyncSettingsSnapshot.lastSpeed = changedLastSpeed;
|
||||
}
|
||||
tc.persistedLastSpeed = changedLastSpeed;
|
||||
var newerLocalLastSpeed = isValidSpeed(tc.pendingLastSpeedValue)
|
||||
? Number(tc.pendingLastSpeedValue)
|
||||
@@ -3144,65 +2656,48 @@ function loadInitialRuntimeSettings(attempt) {
|
||||
applyCustomButtonIcons(nv);
|
||||
});
|
||||
}
|
||||
// A temporary listener covered the gap before the durable listener above
|
||||
// was registered. Verify storage only when it actually changed during
|
||||
// startup instead of issuing two extra reads in every frame.
|
||||
var startupStorageChanges = finishStartupStorageWatch();
|
||||
if (initialSyncReadFailed) startupStorageChanges.sync = true;
|
||||
if (initialLocalReadFailed) startupStorageChanges.local = true;
|
||||
if (startupStorageChanges.sync) scheduleRuntimeSettingsReload();
|
||||
if (startupStorageChanges.local) {
|
||||
var customIconGeneration =
|
||||
Number(window.vscCustomIconGeneration) || 0;
|
||||
var rememberedSpeedsGeneration =
|
||||
Number(window.vscRememberedSpeedsGeneration) || 0;
|
||||
loadInitialLocalSettings(
|
||||
0,
|
||||
function(latestLocal, latestLocalReadFailed) {
|
||||
if (latestLocalReadFailed) return;
|
||||
// Close the small gap between the initial sync/local reads and listener
|
||||
// registration. A final read also converges if Options saved while this
|
||||
// content script was starting.
|
||||
scheduleRuntimeSettingsReload();
|
||||
var customIconGeneration =
|
||||
Number(window.vscCustomIconGeneration) || 0;
|
||||
var rememberedSpeedsGeneration =
|
||||
Number(window.vscRememberedSpeedsGeneration) || 0;
|
||||
chrome.storage.local.get(
|
||||
["customButtonIcons", "rememberedSpeeds", "rememberedSpeedsResetAt"],
|
||||
function(latestLocal) {
|
||||
if (chrome.runtime.lastError) return;
|
||||
if (
|
||||
customIconGeneration ===
|
||||
(Number(window.vscCustomIconGeneration) || 0)
|
||||
) {
|
||||
applyCustomButtonIcons(latestLocal && latestLocal.customButtonIcons);
|
||||
}
|
||||
if (
|
||||
rememberedSpeedsGeneration ===
|
||||
(Number(window.vscRememberedSpeedsGeneration) || 0)
|
||||
) {
|
||||
applyRememberedSpeedsResetMarker(
|
||||
latestLocal && latestLocal.rememberedSpeedsResetAt
|
||||
);
|
||||
mergeRememberedSpeeds(latestLocal && latestLocal.rememberedSpeeds);
|
||||
var latestRememberedSpeeds =
|
||||
latestLocal && latestLocal.rememberedSpeeds;
|
||||
if (
|
||||
customIconGeneration ===
|
||||
(Number(window.vscCustomIconGeneration) || 0)
|
||||
) {
|
||||
var latestCustomButtonIcons =
|
||||
(latestLocal && latestLocal.customButtonIcons) || {};
|
||||
if (
|
||||
!runtimeStorageSnapshotsEqual(
|
||||
latestCustomButtonIcons,
|
||||
tc.settings.customButtonIcons || {}
|
||||
)
|
||||
) {
|
||||
applyCustomButtonIcons(latestCustomButtonIcons);
|
||||
}
|
||||
}
|
||||
if (
|
||||
rememberedSpeedsGeneration ===
|
||||
(Number(window.vscRememberedSpeedsGeneration) || 0)
|
||||
) {
|
||||
var resetMarkerChanged = applyRememberedSpeedsResetMarker(
|
||||
latestLocal && latestLocal.rememberedSpeedsResetAt
|
||||
);
|
||||
var latestRememberedSpeeds =
|
||||
latestLocal && latestLocal.rememberedSpeeds;
|
||||
var currentRememberedSpeeds = buildRememberedSpeedsPayload();
|
||||
var rememberedSpeedsChanged = !rememberedSpeedsPayloadMatches(
|
||||
!rememberedSpeedsPayloadMatches(
|
||||
latestRememberedSpeeds,
|
||||
currentRememberedSpeeds
|
||||
);
|
||||
if (rememberedSpeedsChanged) {
|
||||
mergeRememberedSpeeds(latestRememberedSpeeds);
|
||||
schedulePersistRememberedSpeeds(true);
|
||||
}
|
||||
if (
|
||||
(resetMarkerChanged || rememberedSpeedsChanged) &&
|
||||
(tc.settings.rememberSpeed || tc.settings.forceLastSavedSpeed)
|
||||
) {
|
||||
tc.mediaElements.slice().forEach(applyRememberedSpeedPolicy);
|
||||
}
|
||||
buildRememberedSpeedsPayload()
|
||||
)
|
||||
) {
|
||||
schedulePersistRememberedSpeeds(true);
|
||||
}
|
||||
if (tc.settings.rememberSpeed || tc.settings.forceLastSavedSpeed) {
|
||||
tc.mediaElements.slice().forEach(applyRememberedSpeedPolicy);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
tc.runtimeSettingsHydrated = true;
|
||||
initializeWhenReady(document);
|
||||
});
|
||||
@@ -3211,11 +2706,8 @@ function loadInitialRuntimeSettings(attempt) {
|
||||
|
||||
// Install before async settings hydration so SPA-owned window capture handlers
|
||||
// cannot hide later key events from Speeder.
|
||||
if (!vscIsChallengeDocument) {
|
||||
attachKeydownListeners(document);
|
||||
beginStartupStorageWatch();
|
||||
loadInitialRuntimeSettings(0);
|
||||
}
|
||||
attachKeydownListeners(document);
|
||||
loadInitialRuntimeSettings(0);
|
||||
|
||||
function getKeyBindings(action, what = "value") {
|
||||
try {
|
||||
@@ -3370,24 +2862,6 @@ function getControllerGeometryMount(mount) {
|
||||
return isShadowRootNode(mount) ? mount.host : mount;
|
||||
}
|
||||
|
||||
function setNormalControllerMount(videoController, mount) {
|
||||
videoController.normalControllerMount = mount;
|
||||
// Some site integrations deliberately use a sibling overlay (e.g. Apple
|
||||
// TV's scrim). Only invalidate containment for mounts that owned the media.
|
||||
videoController.normalMountContainedMedia = isComposedDescendant(
|
||||
videoController.video,
|
||||
getControllerGeometryMount(mount)
|
||||
);
|
||||
}
|
||||
|
||||
function isNativeMediaDocument(doc) {
|
||||
return Boolean(
|
||||
doc &&
|
||||
(doc.mozSyntheticDocument === true ||
|
||||
/^(?:video\/|audio\/|application\/ogg(?:;|$))/i.test(doc.contentType || ""))
|
||||
);
|
||||
}
|
||||
|
||||
function getControllerMount(video, boundary) {
|
||||
if (!video) return null;
|
||||
|
||||
@@ -3416,10 +2890,6 @@ function getControllerMount(video, boundary) {
|
||||
return boundary;
|
||||
}
|
||||
|
||||
if (isNativeMediaDocument(video.ownerDocument)) {
|
||||
return video.ownerDocument.body;
|
||||
}
|
||||
|
||||
// YouTube's .html5-video-container is often absolutely laid out and can
|
||||
// report a zero-height box even while its video is visibly playing. The
|
||||
// surrounding .html5-video-player is the real player/overlay boundary for
|
||||
@@ -3506,11 +2976,15 @@ function positionControllerHost(wrapper, video, mount) {
|
||||
return;
|
||||
}
|
||||
var videoRect = video.getBoundingClientRect();
|
||||
cacheVideoRect(wrapper, video, videoRect);
|
||||
if (
|
||||
wrapper.classList.contains("vsc-fullscreen-popover") ||
|
||||
wrapper.classList.contains("vsc-media-document")
|
||||
) {
|
||||
wrapper.vscVideoRect = {
|
||||
left: Number(videoRect.left) || 0,
|
||||
top: Number(videoRect.top) || 0,
|
||||
right: Number(videoRect.right) || 0,
|
||||
bottom: Number(videoRect.bottom) || 0,
|
||||
width: Number(videoRect.width) || 0,
|
||||
height: Number(videoRect.height) || 0
|
||||
};
|
||||
if (wrapper.classList.contains("vsc-fullscreen-popover")) {
|
||||
if (videoRect.width <= 0 || videoRect.height <= 0) {
|
||||
wrapper.classList.add("vsc-geometry-hidden");
|
||||
wrapper.style.setProperty("display", "none", "important");
|
||||
@@ -3604,57 +3078,6 @@ function configureControllerAutoHide(videoController, wrapper, force) {
|
||||
}
|
||||
}
|
||||
|
||||
function registerControllerMount(videoController, mount) {
|
||||
if (!videoController || !mount) return;
|
||||
var controllers = vscControllersByMount.get(mount);
|
||||
if (!controllers) {
|
||||
controllers = new Set();
|
||||
vscControllersByMount.set(mount, controllers);
|
||||
}
|
||||
controllers.add(videoController);
|
||||
}
|
||||
|
||||
function unregisterControllerMount(videoController, mount) {
|
||||
if (!videoController || !mount) return;
|
||||
var controllers = vscControllersByMount.get(mount);
|
||||
if (!controllers) return;
|
||||
controllers.delete(videoController);
|
||||
if (controllers.size === 0) vscControllersByMount.delete(mount);
|
||||
}
|
||||
|
||||
function attachSharedViewportScrollTracking(win) {
|
||||
if (!win || win.vscViewportScrollTrackingAttached) return;
|
||||
win.vscViewportScrollEpoch = getViewportScrollEpoch(win);
|
||||
win.addEventListener(
|
||||
"scroll",
|
||||
function() {
|
||||
win.vscViewportScrollEpoch = getViewportScrollEpoch(win) + 1;
|
||||
},
|
||||
{ capture: true, passive: true }
|
||||
);
|
||||
win.vscViewportScrollTrackingAttached = true;
|
||||
}
|
||||
|
||||
function attachSharedControllerResizeListener(win) {
|
||||
if (!win || win.vscControllerResizeListenerAttached) return;
|
||||
win.addEventListener(
|
||||
"resize",
|
||||
function() {
|
||||
tc.mediaElements.forEach(function(media) {
|
||||
var controller = media && media.vsc;
|
||||
if (
|
||||
controller &&
|
||||
typeof controller.controllerHostSchedule === "function"
|
||||
) {
|
||||
controller.controllerHostSchedule();
|
||||
}
|
||||
});
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
win.vscControllerResizeListenerAttached = true;
|
||||
}
|
||||
|
||||
function setupControllerHostTracking(videoController, wrapper, mount) {
|
||||
if (!videoController || !wrapper || !mount) return;
|
||||
|
||||
@@ -3662,11 +3085,6 @@ function setupControllerHostTracking(videoController, wrapper, mount) {
|
||||
var win = doc.defaultView || window;
|
||||
var geometryMount = getControllerGeometryMount(mount);
|
||||
if (!geometryMount || !geometryMount.style) return;
|
||||
// Firefox's standalone media body can be 0px tall. Making it positioned
|
||||
// replaces the viewport containing block of its absolutely positioned video
|
||||
// and collapses playback. Anchor our own host to the viewport instead.
|
||||
var nativeMediaDocument = isNativeMediaDocument(doc);
|
||||
wrapper.classList.toggle("vsc-media-document", nativeMediaDocument);
|
||||
var frameId = null;
|
||||
var geometryRetryTimer = null;
|
||||
var geometryRetryAttempts = 0;
|
||||
@@ -3707,7 +3125,7 @@ function setupControllerHostTracking(videoController, wrapper, mount) {
|
||||
frameId = win.requestAnimationFrame(update);
|
||||
};
|
||||
|
||||
if (!nativeMediaDocument && win.getComputedStyle(geometryMount).position === "static") {
|
||||
if (win.getComputedStyle(geometryMount).position === "static") {
|
||||
geometryMount.dataset.vscPositionOwner = "true";
|
||||
geometryMount.dataset.vscOriginalPosition =
|
||||
geometryMount.style.getPropertyValue("position");
|
||||
@@ -3717,7 +3135,7 @@ function setupControllerHostTracking(videoController, wrapper, mount) {
|
||||
geometryMount.style.setProperty("position", "relative");
|
||||
}
|
||||
|
||||
if (!nativeMediaDocument && !createsControllerStackingContext(geometryMount)) {
|
||||
if (!createsControllerStackingContext(geometryMount)) {
|
||||
geometryMount.dataset.vscIsolationOwner = "true";
|
||||
geometryMount.dataset.vscOriginalIsolation =
|
||||
geometryMount.style.getPropertyValue("isolation");
|
||||
@@ -3740,35 +3158,28 @@ function setupControllerHostTracking(videoController, wrapper, mount) {
|
||||
passive: true
|
||||
});
|
||||
});
|
||||
attachSharedViewportScrollTracking(win);
|
||||
attachSharedControllerResizeListener(win);
|
||||
win.addEventListener("resize", schedule, { passive: true });
|
||||
doc.addEventListener("fullscreenchange", schedule, { passive: true });
|
||||
geometryMount.addEventListener("scroll", schedule, { passive: true });
|
||||
update();
|
||||
|
||||
videoController.controllerHostMount = mount;
|
||||
registerControllerMount(videoController, mount);
|
||||
videoController.controllerHostSchedule = schedule;
|
||||
videoController.controllerHostCleanup = function(forceRestore) {
|
||||
unregisterControllerMount(videoController, mount);
|
||||
if (resizeObserver) resizeObserver.disconnect();
|
||||
if (frameId !== null) win.cancelAnimationFrame(frameId);
|
||||
if (geometryRetryTimer !== null) win.clearTimeout(geometryRetryTimer);
|
||||
mediaGeometryEvents.forEach(function(eventName) {
|
||||
videoController.video.removeEventListener(eventName, schedule);
|
||||
});
|
||||
win.removeEventListener("resize", schedule);
|
||||
doc.removeEventListener("fullscreenchange", schedule);
|
||||
geometryMount.removeEventListener("scroll", schedule);
|
||||
if (videoController.controllerHostSchedule === schedule) {
|
||||
videoController.controllerHostSchedule = null;
|
||||
}
|
||||
var hasOtherController = false;
|
||||
try {
|
||||
hasOtherController = Array.from(
|
||||
mount.querySelectorAll(".vsc-controller")
|
||||
).some(function(controllerHost) {
|
||||
return (
|
||||
controllerHost !== wrapper &&
|
||||
isSpeederControllerHost(controllerHost)
|
||||
);
|
||||
return controllerHost !== wrapper;
|
||||
});
|
||||
} catch (e) {}
|
||||
if (
|
||||
@@ -3841,33 +3252,16 @@ function remountControllerHost(videoController, mount) {
|
||||
videoController.div.isConnected &&
|
||||
videoController.div.parentNode === mount
|
||||
) {
|
||||
// Keep controller hosts after page-owned overlays, but do not make multiple
|
||||
// Speeder controllers in one mount endlessly append past one another.
|
||||
var followingSibling = videoController.div.nextSibling;
|
||||
var hasPageOwnedFollowingSibling = false;
|
||||
while (followingSibling) {
|
||||
if (
|
||||
followingSibling.nodeType === Node.ELEMENT_NODE &&
|
||||
!isSpeederControllerHost(followingSibling)
|
||||
) {
|
||||
hasPageOwnedFollowingSibling = true;
|
||||
break;
|
||||
}
|
||||
followingSibling = followingSibling.nextSibling;
|
||||
}
|
||||
if (hasPageOwnedFollowingSibling) {
|
||||
// Keep the max-z-index host last among equal-z-index player overlays.
|
||||
if (videoController.div.nextSibling) {
|
||||
mount.appendChild(videoController.div);
|
||||
return true;
|
||||
}
|
||||
if (typeof videoController.controllerHostSchedule === "function") {
|
||||
videoController.controllerHostSchedule();
|
||||
} else {
|
||||
positionControllerHost(
|
||||
videoController.div,
|
||||
videoController.video,
|
||||
mount
|
||||
);
|
||||
}
|
||||
positionControllerHost(
|
||||
videoController.div,
|
||||
videoController.video,
|
||||
mount
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -3915,7 +3309,7 @@ function enableDirectFullscreenPopover(videoController) {
|
||||
);
|
||||
if (!normalMountIsConnected) {
|
||||
normalMount = getControllerMount(videoController.video);
|
||||
setNormalControllerMount(videoController, normalMount);
|
||||
videoController.normalControllerMount = normalMount;
|
||||
}
|
||||
if (!normalMount) return false;
|
||||
if (wrapper.parentNode !== normalMount) {
|
||||
@@ -3947,22 +3341,12 @@ function syncControllerFullscreenMount(videoController) {
|
||||
var doc = video.ownerDocument;
|
||||
var fullscreenElement = getFullscreenElement(doc);
|
||||
var targetMount = videoController.normalControllerMount;
|
||||
var normalGeometryMount = getControllerGeometryMount(targetMount);
|
||||
if (
|
||||
!normalGeometryMount ||
|
||||
!normalGeometryMount.isConnected ||
|
||||
(videoController.normalMountContainedMedia === true &&
|
||||
!isComposedDescendant(video, normalGeometryMount))
|
||||
) {
|
||||
targetMount = getControllerMount(video);
|
||||
setNormalControllerMount(videoController, targetMount);
|
||||
normalGeometryMount = getControllerGeometryMount(targetMount);
|
||||
}
|
||||
var ownsFullscreen = Boolean(
|
||||
fullscreenElement &&
|
||||
(fullscreenElement === video ||
|
||||
isComposedDescendant(video, fullscreenElement))
|
||||
);
|
||||
var normalGeometryMount = getControllerGeometryMount(targetMount);
|
||||
var normalMountIsAlreadyFullscreenVisible = Boolean(
|
||||
fullscreenElement &&
|
||||
fullscreenElement !== video &&
|
||||
@@ -3972,6 +3356,9 @@ function syncControllerFullscreenMount(videoController) {
|
||||
|
||||
if (ownsFullscreen && !normalMountIsAlreadyFullscreenVisible) {
|
||||
targetMount = getControllerMount(video, fullscreenElement);
|
||||
} else if (!fullscreenElement && (!targetMount || !targetMount.isConnected)) {
|
||||
targetMount = getControllerMount(video);
|
||||
videoController.normalControllerMount = targetMount;
|
||||
}
|
||||
|
||||
if (!targetMount) return false;
|
||||
@@ -4005,15 +3392,12 @@ function defineVideoController() {
|
||||
this.suppressedRateChangeCount = 0;
|
||||
this.suppressedRateChangeUntil = 0;
|
||||
this.visibilityResumeHandler = null;
|
||||
this.dragCleanup = null;
|
||||
this.lifecycleReconcileFrame = null;
|
||||
this.resetToggleArmed = false;
|
||||
this.resetButtonEl = null;
|
||||
this.controllerLocation = normalizeControllerLocation(
|
||||
tc.settings.controllerLocation
|
||||
);
|
||||
this.structureSignature = getControllerStructureSignature();
|
||||
attachFullscreenListeners(target.ownerDocument);
|
||||
|
||||
log(`Creating video controller for ${target.tagName} with src: ${target.src || target.currentSrc || "none"}`, 4);
|
||||
|
||||
@@ -4170,21 +3554,23 @@ function defineVideoController() {
|
||||
|
||||
var srcObserver = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (isMediaSourceAttributeMutation(mutation, this.video)) {
|
||||
if (
|
||||
mutation.type === "attributes" &&
|
||||
(mutation.attributeName === "src" ||
|
||||
mutation.attributeName === "currentSrc")
|
||||
) {
|
||||
log("mutation of A/V element", 5);
|
||||
if (this.div) {
|
||||
this.stopSubtitleNudge();
|
||||
var sourceAvailable = hasUsableMediaSource(this.video);
|
||||
this.div.classList.toggle("vsc-nosource", !sourceAvailable);
|
||||
if (!sourceAvailable) {
|
||||
this.mediaSourceKey = "unknown_src";
|
||||
this.targetSpeedSourceKey = "unknown_src";
|
||||
} else {
|
||||
applySourceTransitionPolicy(
|
||||
this.video,
|
||||
mutation.target === this.video
|
||||
if (!mutation.target.src && !mutation.target.currentSrc) {
|
||||
this.div.classList.toggle(
|
||||
"vsc-nosource",
|
||||
!hasUsableMediaSource(mutation.target)
|
||||
);
|
||||
if (!this.video.paused) this.startSubtitleNudge();
|
||||
} else {
|
||||
this.div.classList.remove("vsc-nosource");
|
||||
applySourceTransitionPolicy(this.video, true);
|
||||
if (!mutation.target.paused) this.startSubtitleNudge();
|
||||
}
|
||||
updateSubtitleNudgeIndicator(this.video);
|
||||
}
|
||||
@@ -4192,11 +3578,7 @@ function defineVideoController() {
|
||||
});
|
||||
});
|
||||
this.srcObserver = srcObserver;
|
||||
srcObserver.observe(target, {
|
||||
attributes: true,
|
||||
subtree: true,
|
||||
attributeFilter: ["src", "currentSrc"]
|
||||
});
|
||||
srcObserver.observe(target, { attributeFilter: ["src", "currentSrc"] });
|
||||
if (!target.paused && target.playbackRate !== 1.0)
|
||||
this.startSubtitleNudge();
|
||||
};
|
||||
@@ -4204,18 +3586,6 @@ function defineVideoController() {
|
||||
tc.videoController.prototype.remove = function() {
|
||||
this.stopSubtitleNudge();
|
||||
disableDirectFullscreenPopover(this);
|
||||
if (this.lifecycleReconcileFrame !== null) {
|
||||
var lifecycleWindow =
|
||||
this.video && this.video.ownerDocument
|
||||
? this.video.ownerDocument.defaultView || window
|
||||
: window;
|
||||
lifecycleWindow.cancelAnimationFrame(this.lifecycleReconcileFrame);
|
||||
this.lifecycleReconcileFrame = null;
|
||||
}
|
||||
if (this.dragCleanup) {
|
||||
this.dragCleanup();
|
||||
this.dragCleanup = null;
|
||||
}
|
||||
if (
|
||||
this.visibilityResumeHandler &&
|
||||
this.video &&
|
||||
@@ -4240,22 +3610,7 @@ function defineVideoController() {
|
||||
this.genericAutoHideCleanup();
|
||||
this.genericAutoHideCleanup = null;
|
||||
}
|
||||
if (this.div) {
|
||||
if (this.div.showTimeOut !== undefined) {
|
||||
clearTimeout(this.div.showTimeOut);
|
||||
this.div.showTimeOut = undefined;
|
||||
}
|
||||
if (this.div.blinkTimeOut !== undefined) {
|
||||
clearTimeout(this.div.blinkTimeOut);
|
||||
this.div.blinkTimeOut = undefined;
|
||||
}
|
||||
var nudgeFlashIndicator = this.nudgeFlashIndicator;
|
||||
if (nudgeFlashIndicator && nudgeFlashIndicator._flashTimer) {
|
||||
clearTimeout(nudgeFlashIndicator._flashTimer);
|
||||
nudgeFlashIndicator._flashTimer = null;
|
||||
}
|
||||
this.div.remove();
|
||||
}
|
||||
if (this.div) this.div.remove();
|
||||
if (this.controllerHostCleanup) {
|
||||
this.controllerHostCleanup();
|
||||
this.controllerHostCleanup = null;
|
||||
@@ -4611,7 +3966,6 @@ function defineVideoController() {
|
||||
const speed = this.video.playbackRate.toFixed(2);
|
||||
var wrapper = doc.createElement("div");
|
||||
wrapper.classList.add("vsc-controller");
|
||||
wrapper.vscControllerHost = true;
|
||||
// Keep the host out of player layout while its shadow stylesheet loads.
|
||||
wrapper.style.position = "absolute";
|
||||
wrapper.style.pointerEvents = "none";
|
||||
@@ -4759,7 +4113,7 @@ function defineVideoController() {
|
||||
) {
|
||||
log("No suitable parent found, appending to body", 4);
|
||||
doc.body.appendChild(wrapper);
|
||||
setNormalControllerMount(this, doc.body);
|
||||
this.normalControllerMount = doc.body;
|
||||
setupControllerHostTracking(this, wrapper, doc.body);
|
||||
return wrapper;
|
||||
}
|
||||
@@ -4796,14 +4150,14 @@ function defineVideoController() {
|
||||
break;
|
||||
}
|
||||
mountEl.appendChild(wrapper);
|
||||
setNormalControllerMount(this, mountEl);
|
||||
this.normalControllerMount = mountEl;
|
||||
setupControllerHostTracking(this, wrapper, mountEl);
|
||||
log("Controller successfully inserted into DOM", 4);
|
||||
} catch (error) {
|
||||
log(`Error inserting controller: ${error.message}`, 2);
|
||||
// Fallback to body insertion
|
||||
doc.body.appendChild(wrapper);
|
||||
setNormalControllerMount(this, doc.body);
|
||||
this.normalControllerMount = doc.body;
|
||||
setupControllerHostTracking(this, wrapper, doc.body);
|
||||
}
|
||||
|
||||
@@ -4812,12 +4166,10 @@ function defineVideoController() {
|
||||
}
|
||||
|
||||
function applySiteRuleOverrides() {
|
||||
var currentUrl = location.href;
|
||||
var wasForceEnabled = tc.settings.forceLastSavedSpeed === true;
|
||||
var wasRememberEnabled = tc.settings.rememberSpeed === true;
|
||||
|
||||
function finishSiteRuleApplication(result) {
|
||||
tc.siteRuleAppliedHref = currentUrl;
|
||||
if (
|
||||
(wasForceEnabled && !tc.settings.forceLastSavedSpeed) ||
|
||||
(wasRememberEnabled && !tc.settings.rememberSpeed)
|
||||
@@ -4834,6 +4186,7 @@ function applySiteRuleOverrides() {
|
||||
return finishSiteRuleApplication(false);
|
||||
}
|
||||
|
||||
var currentUrl = location.href;
|
||||
var matchedRule = siteRuleUtils.matchSiteRule(currentUrl, tc.settings.siteRules);
|
||||
|
||||
if (!matchedRule) {
|
||||
@@ -4909,14 +4262,12 @@ function applySiteRuleOverrides() {
|
||||
|
||||
function removeIneligibleMediaControllers() {
|
||||
tc.mediaElements.slice().forEach(function(media) {
|
||||
if (!media) return;
|
||||
if (media.nodeName === "AUDIO" && !tc.settings.audioBoolean) {
|
||||
if (
|
||||
media &&
|
||||
((media.nodeName === "AUDIO" && !tc.settings.audioBoolean) ||
|
||||
isAmbientLoopMedia(media))
|
||||
) {
|
||||
removeController(media);
|
||||
return;
|
||||
}
|
||||
if (isAmbientLoopMedia(media)) {
|
||||
removeController(media);
|
||||
vscDeferredMediaCandidates.add(media);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -4934,12 +4285,8 @@ function refreshAllControllerGeometry() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Re-match site rules and refresh existing controllers once per URL. */
|
||||
/** Re-match site rules for current URL and refresh controller position/opacity on every video. */
|
||||
function reapplySiteRulesAndControllerGeometry() {
|
||||
if (tc.siteRuleAppliedHref === location.href) {
|
||||
return isSpeederActiveForCurrentPage();
|
||||
}
|
||||
|
||||
applySiteRuleOverrides();
|
||||
if (!isSpeederActiveForCurrentPage()) {
|
||||
tc.mediaElements.slice().forEach(function(video) {
|
||||
@@ -5184,7 +4531,6 @@ function attachKeydownListeners(doc) {
|
||||
keyTarget.addEventListener(
|
||||
"keydown",
|
||||
function(event) {
|
||||
if (!tc.runtimeSettingsHydrated || !tc.mediaElements.length) return;
|
||||
if (
|
||||
!event.getModifierState ||
|
||||
event.getModifierState("Alt") ||
|
||||
@@ -5205,6 +4551,8 @@ function attachKeydownListeners(doc) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tc.mediaElements.length) return;
|
||||
|
||||
var item = tc.settings.keyBindings.find(function(binding) {
|
||||
return matchesKeyBinding(binding, event);
|
||||
});
|
||||
@@ -5235,14 +4583,11 @@ function attachMediaTargetTracking(doc) {
|
||||
if (!Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!tc.lastPointerPosition ||
|
||||
tc.lastPointerPosition.document !== doc
|
||||
) {
|
||||
tc.lastPointerPosition = { document: doc, x: 0, y: 0 };
|
||||
}
|
||||
tc.lastPointerPosition.x = event.clientX;
|
||||
tc.lastPointerPosition.y = event.clientY;
|
||||
tc.lastPointerPosition = {
|
||||
document: doc,
|
||||
x: event.clientX,
|
||||
y: event.clientY
|
||||
};
|
||||
},
|
||||
{ capture: true, passive: true }
|
||||
);
|
||||
@@ -5252,218 +4597,109 @@ function attachMediaTargetTracking(doc) {
|
||||
function attachMutationObserver(root) {
|
||||
if (root.vscMutationObserverAttached) return;
|
||||
|
||||
var pendingControllerMountTargets = new Set();
|
||||
var pendingAddedCandidates = new Map();
|
||||
var pendingRemovedCandidates = new Map();
|
||||
var pendingAriaHiddenTargets = new Set();
|
||||
var pendingMutations = [];
|
||||
var mutationProcessingScheduled = false;
|
||||
var observer = new MutationObserver(function(mutations) {
|
||||
mutations.forEach(function(mutation) {
|
||||
if (mutation.type === "childList") {
|
||||
// Text-only updates can still change a player's geometry. Preserve the
|
||||
// former mount reconciliation while keeping the lookup mount-scoped.
|
||||
pendingControllerMountTargets.add(mutation.target);
|
||||
mutation.addedNodes.forEach(function(node) {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||
if (isSpeederControllerHost(node)) return;
|
||||
pendingAddedCandidates.set(node, {
|
||||
node: node,
|
||||
parent: mutation.target
|
||||
});
|
||||
});
|
||||
mutation.removedNodes.forEach(function(node) {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||
if (isSpeederControllerHost(node)) return;
|
||||
pendingRemovedCandidates.set(node, {
|
||||
node: node,
|
||||
parent: mutation.target
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
mutation.type === "attributes" &&
|
||||
mutation.attributeName === "aria-hidden"
|
||||
) {
|
||||
pendingAriaHiddenTargets.add(mutation.target);
|
||||
}
|
||||
});
|
||||
pendingMutations.push(...mutations);
|
||||
if (mutationProcessingScheduled) return;
|
||||
|
||||
mutationProcessingScheduled = true;
|
||||
requestIdle(
|
||||
function() {
|
||||
var mutationsToProcess = pendingMutations.splice(0);
|
||||
mutationProcessingScheduled = false;
|
||||
var controllerMountTargets = pendingControllerMountTargets;
|
||||
var addedCandidates = Array.from(pendingAddedCandidates.values());
|
||||
var removedCandidates = Array.from(pendingRemovedCandidates.values());
|
||||
var ariaHiddenTargets = pendingAriaHiddenTargets;
|
||||
pendingControllerMountTargets = new Set();
|
||||
pendingAddedCandidates = new Map();
|
||||
pendingRemovedCandidates = new Map();
|
||||
pendingAriaHiddenTargets = new Set();
|
||||
var controllerMountTargets = new Set();
|
||||
|
||||
// A detached shadow root can be suspended while this idle task is
|
||||
// queued. Ignore its stale summary; reconnecting the root performs a
|
||||
// fresh media scan.
|
||||
if (root.vscMutationObserver !== observer) return;
|
||||
mutationsToProcess.forEach(function(mutation) {
|
||||
if (mutation.type === "childList") {
|
||||
controllerMountTargets.add(mutation.target);
|
||||
mutation.addedNodes.forEach(function(node) {
|
||||
// Skip text nodes, comments, etc. — only elements can contain media
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||
scanNodeForMedia(node, node.parentNode || mutation.target, true);
|
||||
});
|
||||
mutation.removedNodes.forEach(function(node) {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||
scanNodeForMedia(node, node.parentNode || mutation.target, false);
|
||||
});
|
||||
|
||||
compactMutationScanCandidates(removedCandidates).forEach(
|
||||
function(candidate) {
|
||||
scanNodeForMedia(
|
||||
candidate.node,
|
||||
candidate.node.parentNode || candidate.parent,
|
||||
false
|
||||
);
|
||||
return;
|
||||
}
|
||||
);
|
||||
compactMutationScanCandidates(addedCandidates).forEach(
|
||||
function(candidate) {
|
||||
scanNodeForMedia(
|
||||
candidate.node,
|
||||
candidate.node.parentNode || candidate.parent,
|
||||
true
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ariaHiddenTargets.forEach(function(target) {
|
||||
if (mutation.type !== "attributes") return;
|
||||
|
||||
var target = mutation.target;
|
||||
if (
|
||||
target &&
|
||||
target.getAttribute &&
|
||||
target.getAttribute("aria-hidden") === "false"
|
||||
isMediaElement(target) &&
|
||||
(mutation.attributeName === "src" ||
|
||||
mutation.attributeName === "currentSrc")
|
||||
) {
|
||||
ensureController(target, target.parentElement || target.parentNode);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (
|
||||
target.nodeName === "SOURCE" &&
|
||||
mutation.attributeName === "src"
|
||||
) {
|
||||
ensureControllerForMediaChild(target);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
mutation.attributeName === "aria-hidden" &&
|
||||
target.attributes["aria-hidden"] &&
|
||||
target.attributes["aria-hidden"].value === "false"
|
||||
) {
|
||||
scanNodeForMedia(
|
||||
target,
|
||||
target.parentNode || root.host || target,
|
||||
target.parentNode || root.host || mutation.target,
|
||||
true
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (addedCandidates.length > 0) {
|
||||
// Reconnect shadow roots that were created while detached, including
|
||||
// roots nested below a larger subtree added in one DOM operation.
|
||||
resumeSuspendedObservedRoots(
|
||||
getObservedRootDocument(root)
|
||||
);
|
||||
}
|
||||
|
||||
// Reconcile once per batch, not once per mutation. Large news/social
|
||||
// pages can emit hundreds of childList records in one render pass.
|
||||
if (controllerMountTargets.size > 0) {
|
||||
controllerMountTargets.forEach(function(mount) {
|
||||
var controllers = vscControllersByMount.get(mount);
|
||||
if (!controllers) return;
|
||||
Array.from(controllers).forEach(function(videoController) {
|
||||
if (!videoController || !videoController.div) return;
|
||||
remountControllerHost(videoController, mount);
|
||||
});
|
||||
tc.mediaElements.slice().forEach(function(video) {
|
||||
if (!video || !video.vsc || !video.vsc.div) return;
|
||||
var mount = video.vsc.controllerHostMount;
|
||||
if (!controllerMountTargets.has(mount)) return;
|
||||
remountControllerHost(video.vsc, mount);
|
||||
});
|
||||
}
|
||||
|
||||
if (removedCandidates.length > 0) {
|
||||
// Document selectors do not cross shadow boundaries. A detached
|
||||
// custom-element host can therefore hide media from the removed
|
||||
// subtree scan; prune once, then suspend detached-root observers.
|
||||
tc.mediaElements.slice().forEach(function(video) {
|
||||
if (!video || video.isConnected) return;
|
||||
removeController(video);
|
||||
});
|
||||
Array.from(vscDeferredMediaCandidates).forEach(function(media) {
|
||||
if (!media || media.isConnected) return;
|
||||
vscDeferredMediaCandidates.delete(media);
|
||||
disconnectBootstrapMediaSourceObserver(media);
|
||||
});
|
||||
reconcileObservedRootConnections(
|
||||
getObservedRootDocument(root),
|
||||
false
|
||||
);
|
||||
}
|
||||
// Document selectors do not cross shadow boundaries. A detached
|
||||
// custom-element host can therefore hide connected-looking media from
|
||||
// the removed subtree scan; prune by composed connectivity as a final
|
||||
// reconciliation step for every mutation batch.
|
||||
tc.mediaElements.slice().forEach(function(video) {
|
||||
if (!video || video.isConnected) return;
|
||||
removeController(video);
|
||||
});
|
||||
},
|
||||
{ timeout: 1000 }
|
||||
);
|
||||
});
|
||||
|
||||
observer.observe(root, {
|
||||
attributeFilter: ["aria-hidden"],
|
||||
attributeFilter: ["aria-hidden", "src", "currentSrc"],
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true
|
||||
});
|
||||
|
||||
root.vscMutationObserver = observer;
|
||||
root.vscMutationObserverAttached = true;
|
||||
}
|
||||
|
||||
function scheduleMediaLifecycleReconcile(media, videoController) {
|
||||
if (
|
||||
!media ||
|
||||
!videoController ||
|
||||
videoController.lifecycleReconcileFrame !== null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
var doc = media.ownerDocument;
|
||||
var win = (doc && doc.defaultView) || window;
|
||||
videoController.lifecycleReconcileFrame = win.requestAnimationFrame(
|
||||
function() {
|
||||
videoController.lifecycleReconcileFrame = null;
|
||||
if (
|
||||
!media.isConnected ||
|
||||
media.vsc !== videoController ||
|
||||
!videoController.div
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
var nextMount = getControllerMount(media);
|
||||
if (nextMount) setNormalControllerMount(videoController, nextMount);
|
||||
// A play/metadata event can arrive after fullscreenchange. Resolve the
|
||||
// active fullscreen boundary again instead of restoring the normal host.
|
||||
syncControllerFullscreenMount(videoController);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function attachMediaDetectionListeners(root) {
|
||||
if (root.vscMediaEventListenersAttached) return;
|
||||
|
||||
var handleDetectedMedia = function(event) {
|
||||
var target = event.target;
|
||||
if (!isMediaElement(target)) return;
|
||||
|
||||
var existingController = target.vsc;
|
||||
var geometryMount =
|
||||
existingController &&
|
||||
getControllerGeometryMount(existingController.controllerHostMount);
|
||||
var ambientSignatureMayNowApply = Boolean(
|
||||
target.nodeName === "VIDEO" &&
|
||||
target.autoplay === true &&
|
||||
(target.muted === true || target.defaultMuted === true) &&
|
||||
target.loop === true &&
|
||||
target.playsInline === true &&
|
||||
target.controls !== true &&
|
||||
!tc.settings.showAmbientLoopControls
|
||||
);
|
||||
if (
|
||||
tc.siteRuleAppliedHref === location.href &&
|
||||
!ambientSignatureMayNowApply &&
|
||||
existingController &&
|
||||
existingController.div &&
|
||||
existingController.div.isConnected &&
|
||||
existingController.structureSignature === getControllerStructureSignature() &&
|
||||
geometryMount &&
|
||||
isComposedDescendant(target, geometryMount)
|
||||
) {
|
||||
// The controller's target-level lifecycle listeners own source and speed
|
||||
// updates. Defer the mount/geometry repair to one animation frame so a
|
||||
// lifecycle burst cannot force repeated synchronous layout.
|
||||
scheduleMediaLifecycleReconcile(target, existingController);
|
||||
return;
|
||||
}
|
||||
ensureController(target, target.parentElement || target.parentNode);
|
||||
};
|
||||
|
||||
@@ -5480,133 +4716,23 @@ function attachMediaDetectionListeners(root) {
|
||||
root.vscMediaEventListenersAttached = true;
|
||||
}
|
||||
|
||||
function scheduleNavigationRescan(event) {
|
||||
function scheduleNavigationRescan() {
|
||||
var nextHref = location.href;
|
||||
var urlChanged = window.vscLastObservedHref !== nextHref;
|
||||
var navigationSignaled = Boolean(event && event.type);
|
||||
var forceYouTubeReconcile = Boolean(
|
||||
event && event.type === "yt-navigate-finish"
|
||||
);
|
||||
if (!urlChanged && !navigationSignaled) return;
|
||||
|
||||
if (urlChanged) {
|
||||
// A media identity belongs to the previous route, but viewport pointer
|
||||
// coordinates remain valid while the cursor is stationary. Keep them so
|
||||
// shortcuts can still choose the closest controller after an SPA swap.
|
||||
if (window.vscLastObservedHref !== nextHref) {
|
||||
// Pointer and controller interaction state belongs to the previous route.
|
||||
// YouTube keeps hover-preview and Shorts players connected across SPA
|
||||
// navigations, so retaining either target can send shortcuts to an
|
||||
// off-screen/stale player even though the new watch controller works.
|
||||
tc.lastPointerPosition = null;
|
||||
tc.lastInteractedMedia = null;
|
||||
}
|
||||
window.vscLastObservedHref = nextHref;
|
||||
window.vscNavigationForceDiscovery =
|
||||
window.vscNavigationForceDiscovery === true || forceYouTubeReconcile;
|
||||
if (!urlChanged && window.vscNavigationRescanTimer) return;
|
||||
clearTimeout(window.vscNavigationRescanTimer);
|
||||
window.vscNavigationRescanTimer = setTimeout(function() {
|
||||
window.vscNavigationRescanTimer = null;
|
||||
var forceDiscovery = window.vscNavigationForceDiscovery === true;
|
||||
window.vscNavigationForceDiscovery = false;
|
||||
reconcileAfterNavigation(document, forceDiscovery);
|
||||
initializeWhenReady(document, true);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function stopLocationWatch() {
|
||||
if (!window.vscLocationWatchTimer) return;
|
||||
clearInterval(window.vscLocationWatchTimer);
|
||||
window.vscLocationWatchTimer = null;
|
||||
}
|
||||
|
||||
function startLocationWatchFallback() {
|
||||
if (
|
||||
window.vscLocationWatchTimer ||
|
||||
(window.vscPageShadowBridgeLoaded &&
|
||||
window.vscPageNavigationApiBridgeLoaded)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
window.vscLocationWatchTimer = setInterval(function() {
|
||||
if (window.vscLastObservedHref !== location.href) {
|
||||
scheduleNavigationRescan();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function reconcileAfterNavigation(doc, forceDiscovery) {
|
||||
if (!tc.runtimeSettingsHydrated || !doc || !doc.body) {
|
||||
initializeWhenReady(doc, true);
|
||||
return;
|
||||
}
|
||||
|
||||
var wasActive = isSpeederActiveForCurrentPage();
|
||||
var previouslyIncludedAudio = tc.settings.audioBoolean === true;
|
||||
var previouslyIncludedAmbient = tc.settings.showAmbientLoopControls === true;
|
||||
var previousStructureSignature = getControllerStructureSignature();
|
||||
|
||||
if (tc.siteRuleAppliedHref !== location.href) applySiteRuleOverrides();
|
||||
var isActive = isSpeederActiveForCurrentPage();
|
||||
|
||||
observeRoot(doc);
|
||||
resumeSuspendedObservedRoots(doc);
|
||||
if (!isActive) {
|
||||
clearDeferredMediaCandidates();
|
||||
tc.mediaElements.slice().forEach(function(media) {
|
||||
removeController(media);
|
||||
});
|
||||
vscInitializedDocuments.add(doc);
|
||||
return;
|
||||
}
|
||||
|
||||
removeIneligibleMediaControllers();
|
||||
var discoveryScopeExpanded = Boolean(
|
||||
!wasActive ||
|
||||
(!previouslyIncludedAudio && tc.settings.audioBoolean === true) ||
|
||||
(!previouslyIncludedAmbient &&
|
||||
tc.settings.showAmbientLoopControls === true)
|
||||
);
|
||||
var needsFullDiscovery = Boolean(
|
||||
forceDiscovery ||
|
||||
discoveryScopeExpanded ||
|
||||
!vscInitializedDocuments.has(doc) ||
|
||||
!window.vscPageShadowBridgeLoaded
|
||||
);
|
||||
|
||||
if (needsFullDiscovery) {
|
||||
scanRootForMedia(doc);
|
||||
rescanObservedMediaRoots(doc);
|
||||
} else {
|
||||
Array.from(vscDeferredMediaCandidates).forEach(function(media) {
|
||||
if (!media || !media.isConnected) {
|
||||
vscDeferredMediaCandidates.delete(media);
|
||||
return;
|
||||
}
|
||||
ensureController(media, media.parentElement || media.parentNode);
|
||||
});
|
||||
var structureChanged =
|
||||
previousStructureSignature !== getControllerStructureSignature();
|
||||
tc.mediaElements.slice().forEach(function(media) {
|
||||
if (!media || !media.isConnected) {
|
||||
removeController(media);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
structureChanged ||
|
||||
!media.vsc ||
|
||||
!media.vsc.div ||
|
||||
!media.vsc.div.isConnected
|
||||
) {
|
||||
ensureController(media, media.parentElement || media.parentNode);
|
||||
return;
|
||||
}
|
||||
scheduleMediaLifecycleReconcile(media, media.vsc);
|
||||
});
|
||||
}
|
||||
|
||||
refreshAllControllerGeometry();
|
||||
if (tc.settings.rememberSpeed || tc.settings.forceLastSavedSpeed) {
|
||||
tc.mediaElements.slice().forEach(applyRememberedSpeedPolicy);
|
||||
}
|
||||
vscInitializedDocuments.add(doc);
|
||||
flushPendingMediaCandidates();
|
||||
}
|
||||
|
||||
function attachNavigationListeners() {
|
||||
if (window.vscNavigationListenersAttached) return;
|
||||
|
||||
@@ -5615,7 +4741,7 @@ function attachNavigationListeners() {
|
||||
var original = history[method];
|
||||
history[method] = function() {
|
||||
var result = original.apply(this, arguments);
|
||||
scheduleNavigationRescan({ type: "history-state" });
|
||||
scheduleNavigationRescan();
|
||||
return result;
|
||||
};
|
||||
});
|
||||
@@ -5627,7 +4753,13 @@ function attachNavigationListeners() {
|
||||
document.addEventListener("yt-navigate-finish", scheduleNavigationRescan);
|
||||
}
|
||||
window.vscLastObservedHref = location.href;
|
||||
startLocationWatchFallback();
|
||||
if (!window.vscLocationWatchTimer) {
|
||||
window.vscLocationWatchTimer = setInterval(function() {
|
||||
if (window.vscLastObservedHref !== location.href) {
|
||||
scheduleNavigationRescan();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
window.vscNavigationListenersAttached = true;
|
||||
}
|
||||
|
||||
@@ -5638,15 +4770,13 @@ function initializeNow(doc, forceReinit = false) {
|
||||
// later whitelisted SPA route can never reactivate the extension.
|
||||
attachNavigationListeners();
|
||||
if (typeof tc.videoController === "undefined") defineVideoController();
|
||||
if (tc.siteRuleAppliedHref !== location.href) applySiteRuleOverrides();
|
||||
applySiteRuleOverrides();
|
||||
var isActive = isSpeederActiveForCurrentPage();
|
||||
|
||||
// Keep observing while inactive so dynamically-created media/shadow roots
|
||||
// are available to the next forced SPA rescan, but remove stale controls.
|
||||
observeRoot(doc);
|
||||
resumeSuspendedObservedRoots(doc);
|
||||
if (!isActive) {
|
||||
clearDeferredMediaCandidates();
|
||||
tc.mediaElements.slice().forEach(function(video) {
|
||||
removeController(video);
|
||||
});
|
||||
@@ -6065,7 +5195,7 @@ function resetSpeed(v, target, isFastKey = false) {
|
||||
setSpeed(v, lastToggle, false, true);
|
||||
} else {
|
||||
// Not at preferred speed, save current as toggle speed and go to preferred
|
||||
rememberToggleSpeed(videoId, currentSpeed);
|
||||
lastToggleSpeed[videoId] = currentSpeed;
|
||||
setSpeed(v, preferredSpeed, false, true);
|
||||
}
|
||||
} else {
|
||||
@@ -6082,7 +5212,7 @@ function resetSpeed(v, target, isFastKey = false) {
|
||||
setSpeed(v, speedToRestore, false, true, true);
|
||||
} else {
|
||||
// Not at 1.0, save current as toggle speed and go to 1.0
|
||||
rememberToggleSpeed(videoId, currentSpeed);
|
||||
lastToggleSpeed[videoId] = currentSpeed;
|
||||
v.vsc.resetToggleArmed = true;
|
||||
setSpeed(v, resetSpeedValue, false, true, true);
|
||||
}
|
||||
@@ -6148,8 +5278,6 @@ function jumpToMark(v) {
|
||||
|
||||
function handleDrag(video, e) {
|
||||
const c = video.vsc.div;
|
||||
const dragController = video.vsc;
|
||||
if (dragController.dragCleanup) dragController.dragCleanup();
|
||||
const sC = convertControllerToManualPosition(video.vsc);
|
||||
if (!sC) return;
|
||||
var pE =
|
||||
@@ -6188,11 +5316,7 @@ function handleDrag(video, e) {
|
||||
pE.removeEventListener("mouseleave", eD);
|
||||
sC.classList.remove("dragging");
|
||||
video.classList.remove("vcs-dragging");
|
||||
if (dragController.dragCleanup === eD) {
|
||||
dragController.dragCleanup = null;
|
||||
}
|
||||
};
|
||||
dragController.dragCleanup = eD;
|
||||
pE.addEventListener("mouseup", eD);
|
||||
pE.addEventListener("mouseleave", eD);
|
||||
pE.addEventListener("mousemove", sD);
|
||||
@@ -6237,15 +5361,11 @@ function handleFullscreenControllerTransition() {
|
||||
});
|
||||
}
|
||||
|
||||
function attachFullscreenListeners(doc) {
|
||||
if (!doc || doc.vscFullscreenListenersAttached) return;
|
||||
[
|
||||
"fullscreenchange",
|
||||
"webkitfullscreenchange",
|
||||
"mozfullscreenchange",
|
||||
"MSFullscreenChange"
|
||||
].forEach(function(eventName) {
|
||||
doc.addEventListener(eventName, handleFullscreenControllerTransition);
|
||||
});
|
||||
doc.vscFullscreenListenersAttached = true;
|
||||
}
|
||||
[
|
||||
"fullscreenchange",
|
||||
"webkitfullscreenchange",
|
||||
"mozfullscreenchange",
|
||||
"MSFullscreenChange"
|
||||
].forEach(function(eventName) {
|
||||
document.addEventListener(eventName, handleFullscreenControllerTransition);
|
||||
});
|
||||
@@ -1,68 +1,7 @@
|
||||
(function() {
|
||||
"use strict";
|
||||
|
||||
function isChallengeUrl(value) {
|
||||
try {
|
||||
var url = new URL(value);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
||||
var hostname = url.hostname.toLowerCase().replace(/\.$/, "");
|
||||
return (
|
||||
hostname === "challenges.cloudflare.com" ||
|
||||
hostname.endsWith(".challenges.cloudflare.com") ||
|
||||
url.pathname === "/cdn-cgi/challenge-platform" ||
|
||||
url.pathname.indexOf("/cdn-cgi/challenge-platform/") === 0
|
||||
);
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isChallengeDocument(win) {
|
||||
try {
|
||||
if (isChallengeUrl(win.location.href)) return true;
|
||||
if (!/^about:(blank|srcdoc)(?:[?#]|$)/i.test(win.location.href)) {
|
||||
return false;
|
||||
}
|
||||
// match_about_blank can inherit a challenge document's eligibility.
|
||||
if (
|
||||
isChallengeUrl(win.document.baseURI) ||
|
||||
isChallengeUrl(win.document.referrer)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return win.parent !== win && isChallengeDocument(win.parent);
|
||||
} catch (_error) {
|
||||
// A cross-origin ancestor is inaccessible; use only the URLs we can read.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Challenge pages inspect native APIs. Leave the page untouched, including
|
||||
// bridge flags and readiness events, even if the manifest was bypassed.
|
||||
if (isChallengeDocument(window)) return;
|
||||
|
||||
function dispatchBridgeEvent(eventName) {
|
||||
try {
|
||||
document.dispatchEvent(
|
||||
new Event(eventName, {
|
||||
bubbles: true,
|
||||
composed: true
|
||||
})
|
||||
);
|
||||
} catch (_error) {}
|
||||
}
|
||||
|
||||
function notifyBridgeReady() {
|
||||
if (window.__speederPageNavigationApiBridgeInstalled) {
|
||||
dispatchBridgeEvent("speeder-page-navigation-api-ready");
|
||||
}
|
||||
dispatchBridgeEvent("speeder-page-bridge-ready");
|
||||
}
|
||||
|
||||
if (window.__speederPageShadowBridgeInstalled) {
|
||||
notifyBridgeReady();
|
||||
return;
|
||||
}
|
||||
if (window.__speederPageShadowBridgeInstalled) return;
|
||||
|
||||
window.__speederPageShadowBridgeInstalled = true;
|
||||
|
||||
@@ -107,22 +46,4 @@
|
||||
return result;
|
||||
};
|
||||
});
|
||||
|
||||
// Modern Navigation API entries cover same-document navigations that do not
|
||||
// pass through the History wrappers above. The legacy content-side fallback
|
||||
// remains active when this API is unavailable or cannot be registered.
|
||||
if (
|
||||
window.navigation &&
|
||||
typeof window.navigation.addEventListener === "function"
|
||||
) {
|
||||
try {
|
||||
window.navigation.addEventListener(
|
||||
"currententrychange",
|
||||
notifyLocationChanged
|
||||
);
|
||||
window.__speederPageNavigationApiBridgeInstalled = true;
|
||||
} catch (_error) {}
|
||||
}
|
||||
|
||||
notifyBridgeReady();
|
||||
})();
|
||||
@@ -9,7 +9,6 @@
|
||||
/* Important declarations inside a shadow tree outrank important page styles.
|
||||
Repeat the top-layer positioning here so fullscreen cannot fall back to the
|
||||
base absolute host rule. */
|
||||
:host(.vsc-media-document),
|
||||
:host(.vsc-fullscreen-popover) {
|
||||
position: fixed !important;
|
||||
margin: 0 !important;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "Speeder",
|
||||
"short_name": "Speeder",
|
||||
"version": "6.0.9.1",
|
||||
"version": "6.0.8.2",
|
||||
"manifest_version": 2,
|
||||
"description": "Speed up, slow down, advance and rewind HTML5 audio/video with shortcuts (New and improved version of \"Video Speed Controller\")",
|
||||
"homepage_url": "https://github.com/SoPat712/speeder",
|
||||
@@ -55,11 +55,7 @@
|
||||
"exclude_matches": [
|
||||
"https://plus.google.com/hangouts/*",
|
||||
"https://hangouts.google.com/*",
|
||||
"https://meet.google.com/*",
|
||||
"*://*.challenges.cloudflare.com/*",
|
||||
"*://*/cdn-cgi/challenge-platform",
|
||||
"*://*/cdn-cgi/challenge-platform?*",
|
||||
"*://*/cdn-cgi/challenge-platform/*"
|
||||
"https://meet.google.com/*"
|
||||
],
|
||||
"css": [
|
||||
"content/inject.css"
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
import { JSDOM } from "jsdom";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
const { readWorkspaceFile } = require("./helpers/extension-test-utils");
|
||||
|
||||
const manifest = JSON.parse(
|
||||
readWorkspaceFile("extension/manifest.json")
|
||||
);
|
||||
const bridgeSource = readWorkspaceFile(
|
||||
"extension/content/shadow-bridge.js"
|
||||
);
|
||||
const injectSource = readWorkspaceFile(
|
||||
"extension/content/inject.js"
|
||||
);
|
||||
const challengeUrls = [
|
||||
"https://challenges.cloudflare.com/turnstile/v0/api.js",
|
||||
"http://challenges.cloudflare.com/",
|
||||
"https://nested.challenges.cloudflare.com/turnstile/",
|
||||
"https://example.org/cdn-cgi/challenge-platform/h/b/turnstile/if/ov2/av0/",
|
||||
"http://example.org/cdn-cgi/challenge-platform/",
|
||||
"https://example.org/cdn-cgi/challenge-platform",
|
||||
"https://example.org/cdn-cgi/challenge-platform?token=example#challenge"
|
||||
];
|
||||
const ordinaryUrls = [
|
||||
"https://cloudflare.com/",
|
||||
"https://www.cloudflare.com/products/stream/",
|
||||
"https://watch.cloudflarestream.com/video",
|
||||
"https://example.org/watch?v=123",
|
||||
"https://example.org/cdn-cgi/image/width=320/video-poster.jpg",
|
||||
"https://example.org/cdn-cgi/challenge-platform-guide",
|
||||
"https://challenges.cloudflare.com.example.org/video",
|
||||
"https://otherchallenges.cloudflare.com/video",
|
||||
"https://example.org/watch?next=/cdn-cgi/challenge-platform/"
|
||||
];
|
||||
|
||||
// Firefox match-pattern paths include the query string, but exclude fragments.
|
||||
function matchesPattern(pattern, href) {
|
||||
const [, scheme, host, path] = pattern.match(/^(\*|https?):\/\/([^/]+)(\/.*)$/);
|
||||
const url = new URL(href);
|
||||
const hostname = host.startsWith("*.") ? host.slice(2) : host;
|
||||
const hostMatches =
|
||||
host === "*" ||
|
||||
url.hostname === hostname ||
|
||||
(host.startsWith("*.") && url.hostname.endsWith("." + hostname));
|
||||
const pathExpression = path
|
||||
.split("*")
|
||||
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
||||
.join(".*");
|
||||
return (
|
||||
(scheme === "*" || url.protocol === scheme + ":") &&
|
||||
hostMatches &&
|
||||
new RegExp("^" + pathExpression + "$").test(url.pathname + url.search)
|
||||
);
|
||||
}
|
||||
|
||||
let activeDom;
|
||||
|
||||
function createWindow(url, options = {}) {
|
||||
activeDom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
url,
|
||||
runScripts: "dangerously",
|
||||
...options
|
||||
});
|
||||
return activeDom.window;
|
||||
}
|
||||
|
||||
function observeBridge(win) {
|
||||
const ready = vi.fn();
|
||||
const navigationReady = vi.fn();
|
||||
const navigationListener = vi.fn();
|
||||
win.document.addEventListener("speeder-page-bridge-ready", ready);
|
||||
win.document.addEventListener(
|
||||
"speeder-page-navigation-api-ready",
|
||||
navigationReady
|
||||
);
|
||||
win.navigation = { addEventListener: navigationListener };
|
||||
return {
|
||||
attachShadow: win.Element.prototype.attachShadow,
|
||||
pushState: win.history.pushState,
|
||||
replaceState: win.history.replaceState,
|
||||
ready,
|
||||
navigationReady,
|
||||
navigationListener
|
||||
};
|
||||
}
|
||||
|
||||
function expectInertBridge(win) {
|
||||
const original = observeBridge(win);
|
||||
win.eval(bridgeSource);
|
||||
expect(win.Element.prototype.attachShadow).toBe(original.attachShadow);
|
||||
expect(win.history.pushState).toBe(original.pushState);
|
||||
expect(win.history.replaceState).toBe(original.replaceState);
|
||||
expect(win.__speederPageShadowBridgeInstalled).toBeUndefined();
|
||||
expect(win.__speederPageNavigationApiBridgeInstalled).toBeUndefined();
|
||||
expect(original.ready).not.toHaveBeenCalled();
|
||||
expect(original.navigationReady).not.toHaveBeenCalled();
|
||||
expect(original.navigationListener).not.toHaveBeenCalled();
|
||||
|
||||
// The content runtime must be inert too, including when it is injected into
|
||||
// an inherited blank frame outside the manifest's ordinary URL matching.
|
||||
const addListener = vi.fn();
|
||||
const getSettings = vi.fn();
|
||||
win.chrome = {
|
||||
runtime: { getURL: vi.fn(), onMessage: { addListener } },
|
||||
storage: {
|
||||
sync: { get: getSettings },
|
||||
local: { get: getSettings },
|
||||
onChanged: { addListener }
|
||||
}
|
||||
};
|
||||
win.eval(injectSource);
|
||||
expect(win.Element.prototype.attachShadow).toBe(original.attachShadow);
|
||||
expect(win.history.pushState).toBe(original.pushState);
|
||||
expect(win.history.replaceState).toBe(original.replaceState);
|
||||
expect(win.document.querySelector("script")).toBeNull();
|
||||
expect(win.vscKeydownListenerAttached).toBeUndefined();
|
||||
expect(win.vscAttachShadowPatched).toBeUndefined();
|
||||
expect(win.vscPageBridgeListenersAttached).toBeUndefined();
|
||||
expect(getSettings).not.toHaveBeenCalled();
|
||||
expect(addListener).not.toHaveBeenCalled();
|
||||
expect(win.chrome.runtime.getURL).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (activeDom) activeDom.window.close();
|
||||
activeDom = null;
|
||||
});
|
||||
|
||||
describe("challenge content-script exclusions", () => {
|
||||
it("uses Firefox host and path match patterns for every content-script entry", () => {
|
||||
for (const script of manifest.content_scripts) {
|
||||
expect(script.exclude_matches).toEqual(
|
||||
expect.arrayContaining([
|
||||
"*://*.challenges.cloudflare.com/*",
|
||||
"*://*/cdn-cgi/challenge-platform",
|
||||
"*://*/cdn-cgi/challenge-platform?*",
|
||||
"*://*/cdn-cgi/challenge-platform/*"
|
||||
])
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(challengeUrls)("excludes challenge document %s", (url) => {
|
||||
for (const script of manifest.content_scripts) {
|
||||
expect(script.exclude_matches.some((pattern) => matchesPattern(pattern, url)))
|
||||
.toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(ordinaryUrls)("preserves ordinary document %s", (url) => {
|
||||
for (const script of manifest.content_scripts) {
|
||||
expect(script.exclude_matches.some((pattern) => matchesPattern(pattern, url)))
|
||||
.toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("challenge page bridge guard", () => {
|
||||
it.each(challengeUrls)("leaves native APIs untouched at %s", (url) => {
|
||||
expectInertBridge(createWindow(url));
|
||||
});
|
||||
|
||||
it.each(["about:blank", "about:srcdoc", "about:blank#frame"])(
|
||||
"leaves inherited challenge document %s untouched",
|
||||
(url) => {
|
||||
expectInertBridge(
|
||||
createWindow(url, { referrer: "https://challenges.cloudflare.com/" })
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it("recognizes the inherited base URL when a challenge hides its referrer", () => {
|
||||
const parent = createWindow(
|
||||
"https://example.org/cdn-cgi/challenge-platform/frame"
|
||||
);
|
||||
const iframe = parent.document.createElement("iframe");
|
||||
parent.document.body.appendChild(iframe);
|
||||
const child = iframe.contentWindow;
|
||||
Object.defineProperty(child.document, "referrer", { value: "" });
|
||||
expect(child.location.href).toBe("about:blank");
|
||||
expectInertBridge(child);
|
||||
});
|
||||
|
||||
it("recognizes challenge ancestors through nested inherited documents", () => {
|
||||
const parent = createWindow("https://challenges.cloudflare.com/");
|
||||
const iframe = parent.document.createElement("iframe");
|
||||
parent.document.body.appendChild(iframe);
|
||||
const child = iframe.contentWindow;
|
||||
Object.defineProperty(child.document, "referrer", { value: "" });
|
||||
Object.defineProperty(child.document, "baseURI", { value: "about:blank" });
|
||||
const nestedFrame = child.document.createElement("iframe");
|
||||
child.document.body.appendChild(nestedFrame);
|
||||
const nested = nestedFrame.contentWindow;
|
||||
Object.defineProperty(nested.document, "referrer", { value: "" });
|
||||
Object.defineProperty(nested.document, "baseURI", { value: "about:blank" });
|
||||
expectInertBridge(nested);
|
||||
});
|
||||
|
||||
it.each(ordinaryUrls)("keeps the normal bridge available at %s", (url) => {
|
||||
const win = createWindow(url, {
|
||||
referrer: "https://challenges.cloudflare.com/"
|
||||
});
|
||||
const original = observeBridge(win);
|
||||
win.eval(bridgeSource);
|
||||
expect(win.Element.prototype.attachShadow).not.toBe(original.attachShadow);
|
||||
expect(win.history.pushState).not.toBe(original.pushState);
|
||||
expect(win.history.replaceState).not.toBe(original.replaceState);
|
||||
expect(win.__speederPageShadowBridgeInstalled).toBe(true);
|
||||
expect(original.ready).toHaveBeenCalledTimes(1);
|
||||
expect(original.navigationReady).toHaveBeenCalledTimes(1);
|
||||
expect(original.navigationListener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("preserves native return values and bridge events on an ordinary page", () => {
|
||||
const win = createWindow("https://example.org/video");
|
||||
const shadowAttached = vi.fn();
|
||||
const locationChanged = vi.fn();
|
||||
win.document.addEventListener("speeder-shadow-root-attached", shadowAttached);
|
||||
win.document.addEventListener("speeder-location-changed", locationChanged);
|
||||
const original = observeBridge(win);
|
||||
win.eval(bridgeSource);
|
||||
const host = win.document.createElement("div");
|
||||
win.document.body.appendChild(host);
|
||||
const shadow = host.attachShadow({ mode: "open" });
|
||||
expect(shadow).toBe(host.shadowRoot);
|
||||
expect(shadowAttached).toHaveBeenCalledTimes(1);
|
||||
expect(win.history.pushState({ position: 1 }, "", "/next-video")).toBeUndefined();
|
||||
expect(win.history.replaceState({ position: 2 }, "", "/final-video"))
|
||||
.toBeUndefined();
|
||||
expect(locationChanged).toHaveBeenCalledTimes(2);
|
||||
expect(win.history.state).toEqual({ position: 2 });
|
||||
expect(win.location.pathname).toBe("/final-video");
|
||||
const wrappedAttachShadow = win.Element.prototype.attachShadow;
|
||||
win.eval(bridgeSource);
|
||||
expect(win.Element.prototype.attachShadow).toBe(wrappedAttachShadow);
|
||||
expect(original.ready).toHaveBeenCalledTimes(2);
|
||||
expect(original.navigationListener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -13,17 +13,11 @@ function bootInject(options) {
|
||||
url: config.url || "https://example.org/"
|
||||
});
|
||||
window.history.replaceState({}, "", config.path || "/");
|
||||
if (typeof config.configureWindow === "function") {
|
||||
config.configureWindow(window);
|
||||
}
|
||||
|
||||
const chrome = createChromeMock({
|
||||
syncData: config.syncData,
|
||||
localData: config.localData
|
||||
});
|
||||
if (typeof config.configureChrome === "function") {
|
||||
config.configureChrome(chrome);
|
||||
}
|
||||
|
||||
if (config.syncGetDelayMs) {
|
||||
chrome.storage.sync.get.mockImplementation((keys, callback) => {
|
||||
@@ -144,81 +138,21 @@ describe("inject.js media/controller lifecycle regressions", () => {
|
||||
loadHtmlString("<!doctype html><html><body></body></html>");
|
||||
let locationEvents = 0;
|
||||
let shadowEvents = 0;
|
||||
let readyEvents = 0;
|
||||
document.addEventListener("speeder-location-changed", () => {
|
||||
locationEvents += 1;
|
||||
});
|
||||
document.addEventListener("speeder-shadow-root-attached", () => {
|
||||
shadowEvents += 1;
|
||||
});
|
||||
document.addEventListener("speeder-page-bridge-ready", () => {
|
||||
readyEvents += 1;
|
||||
});
|
||||
evaluateScript("extension/content/shadow-bridge.js");
|
||||
evaluateScript("extension/content/shadow-bridge.js");
|
||||
|
||||
window.history.pushState({}, "", "/new-route");
|
||||
window.history.replaceState({}, "", window.location.href);
|
||||
const bridgePlayer = document.createElement("bridge-player");
|
||||
document.body.appendChild(bridgePlayer);
|
||||
bridgePlayer.attachShadow({ mode: "open" });
|
||||
|
||||
expect(locationEvents).toBe(2);
|
||||
expect(shadowEvents).toBe(1);
|
||||
expect(readyEvents).toBe(2);
|
||||
});
|
||||
|
||||
it("bridges Navigation API entry changes that bypass History wrappers", () => {
|
||||
loadHtmlString("<!doctype html><html><body></body></html>");
|
||||
const nativePushState = window.history.pushState.bind(window.history);
|
||||
let entryChangeListener = null;
|
||||
Object.defineProperty(window, "navigation", {
|
||||
configurable: true,
|
||||
value: {
|
||||
addEventListener(type, listener) {
|
||||
if (type === "currententrychange") entryChangeListener = listener;
|
||||
}
|
||||
}
|
||||
});
|
||||
let locationEvents = 0;
|
||||
let navigationReadyEvents = 0;
|
||||
document.addEventListener("speeder-location-changed", () => {
|
||||
locationEvents += 1;
|
||||
});
|
||||
document.addEventListener("speeder-page-navigation-api-ready", () => {
|
||||
navigationReadyEvents += 1;
|
||||
});
|
||||
evaluateScript("extension/content/shadow-bridge.js");
|
||||
|
||||
nativePushState({}, "", "/navigation-api-route");
|
||||
entryChangeListener(new Event("currententrychange"));
|
||||
|
||||
expect(locationEvents).toBe(1);
|
||||
expect(navigationReadyEvents).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps the bridge ready when Navigation API registration fails", () => {
|
||||
loadHtmlString("<!doctype html><html><body></body></html>");
|
||||
Object.defineProperty(window, "navigation", {
|
||||
configurable: true,
|
||||
value: {
|
||||
addEventListener() {
|
||||
throw new Error("partial Navigation API");
|
||||
}
|
||||
}
|
||||
});
|
||||
let readyEvents = 0;
|
||||
let navigationReadyEvents = 0;
|
||||
document.addEventListener("speeder-page-bridge-ready", () => {
|
||||
readyEvents += 1;
|
||||
});
|
||||
document.addEventListener("speeder-page-navigation-api-ready", () => {
|
||||
navigationReadyEvents += 1;
|
||||
});
|
||||
|
||||
expect(() => evaluateScript("extension/content/shadow-bridge.js")).not.toThrow();
|
||||
expect(readyEvents).toBe(1);
|
||||
expect(navigationReadyEvents).toBe(0);
|
||||
expect(shadowEvents).toBe(1);
|
||||
});
|
||||
|
||||
it("retries a blocked page bridge with bounded shadow fallback scans", async () => {
|
||||
@@ -396,33 +330,6 @@ describe("inject.js media/controller lifecycle regressions", () => {
|
||||
expect(video.vsc.div.style.getPropertyValue("top")).toBe("0px");
|
||||
});
|
||||
|
||||
it("reconciles controller geometry after text-only player layout updates", async () => {
|
||||
vi.useFakeTimers();
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
let playerRect = makeRect(0, 0, 640, 360);
|
||||
const { mount, video, wrapper } = createControlledVideo({
|
||||
mountRect: playerRect,
|
||||
videoRect: playerRect
|
||||
});
|
||||
mount.getBoundingClientRect = () => playerRect;
|
||||
video.getBoundingClientRect = () => playerRect;
|
||||
await settleLifecycle();
|
||||
expect(video.vsc.controllerHostMount).toBe(mount);
|
||||
const scheduleSpy = vi.spyOn(video.vsc, "controllerHostSchedule");
|
||||
|
||||
playerRect = makeRect(0, 0, 800, 450);
|
||||
setBoxMetrics(mount, 800, 450);
|
||||
mount.appendChild(document.createTextNode("updated player layout"));
|
||||
await settleLifecycle();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(scheduleSpy).toHaveBeenCalled();
|
||||
expect(wrapper.style.getPropertyValue("width")).toBe("800px");
|
||||
expect(wrapper.style.getPropertyValue("height")).toBe("450px");
|
||||
});
|
||||
|
||||
it("targets the controller nearest the pointer unless change-all is selected", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
@@ -463,134 +370,6 @@ describe("inject.js media/controller lifecycle regressions", () => {
|
||||
expect(second.video.playbackRate).toBe(1.2);
|
||||
});
|
||||
|
||||
it("targets the player under a stationary cursor after scrolling down the page", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
let scrollOffset = 0;
|
||||
Object.defineProperty(window, "scrollY", {
|
||||
configurable: true,
|
||||
get: () => scrollOffset
|
||||
});
|
||||
const top = createControlledVideo({
|
||||
src: "https://example.org/top.mp4",
|
||||
mountRect: makeRect(0, 0, 320, 180)
|
||||
});
|
||||
const lower = createControlledVideo({
|
||||
src: "https://example.org/lower.mp4",
|
||||
mountRect: makeRect(0, 1000, 320, 180)
|
||||
});
|
||||
const topLayoutRead = vi.fn(() =>
|
||||
makeRect(0, 0 - scrollOffset, 320, 180)
|
||||
);
|
||||
const lowerLayoutRead = vi.fn(() =>
|
||||
makeRect(0, 1000 - scrollOffset, 320, 180)
|
||||
);
|
||||
top.video.getBoundingClientRect = topLayoutRead;
|
||||
lower.video.getBoundingClientRect = lowerLayoutRead;
|
||||
|
||||
document.dispatchEvent(
|
||||
new MouseEvent("mousemove", { bubbles: true, clientX: 100, clientY: 90 })
|
||||
);
|
||||
scrollOffset = 1000;
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
|
||||
expect(topLayoutRead).not.toHaveBeenCalled();
|
||||
expect(lowerLayoutRead).not.toHaveBeenCalled();
|
||||
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
code: "KeyD",
|
||||
key: "d"
|
||||
})
|
||||
);
|
||||
|
||||
expect(top.video.playbackRate).toBe(1);
|
||||
expect(lower.video.playbackRate).toBe(1.1);
|
||||
expect(topLayoutRead).toHaveBeenCalledTimes(1);
|
||||
expect(lowerLayoutRead).toHaveBeenCalledTimes(1);
|
||||
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
code: "KeyD",
|
||||
key: "d"
|
||||
})
|
||||
);
|
||||
expect(lower.video.playbackRate).toBe(1.2);
|
||||
expect(
|
||||
window.getPrimaryVideoElement([top.video, lower.video])
|
||||
).toBe(lower.video);
|
||||
expect(topLayoutRead).toHaveBeenCalledTimes(1);
|
||||
expect(lowerLayoutRead).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses cursor position recorded before multiple controllers are created", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
document.dispatchEvent(
|
||||
new MouseEvent("mousemove", { bubbles: true, clientX: 100, clientY: 20 })
|
||||
);
|
||||
const first = createControlledVideo({
|
||||
src: "https://example.org/first.mp4",
|
||||
mountRect: makeRect(0, 0, 320, 180)
|
||||
});
|
||||
const second = createControlledVideo({
|
||||
src: "https://example.org/second.mp4",
|
||||
mountRect: makeRect(500, 0, 320, 180)
|
||||
});
|
||||
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
code: "KeyD",
|
||||
key: "d"
|
||||
})
|
||||
);
|
||||
|
||||
expect(first.video.playbackRate).toBe(1.1);
|
||||
expect(second.video.playbackRate).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps nearest-controller targeting when the cursor stays still across SPA navigation", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
const first = createControlledVideo({
|
||||
src: "https://example.org/first.mp4",
|
||||
mountRect: makeRect(0, 0, 320, 180)
|
||||
});
|
||||
const second = createControlledVideo({
|
||||
src: "https://example.org/second.mp4",
|
||||
mountRect: makeRect(500, 0, 320, 180)
|
||||
});
|
||||
|
||||
document.dispatchEvent(
|
||||
new MouseEvent("mousemove", { bubbles: true, clientX: 100, clientY: 20 })
|
||||
);
|
||||
window.history.pushState({}, "", "/next-route");
|
||||
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
code: "KeyD",
|
||||
key: "d"
|
||||
})
|
||||
);
|
||||
|
||||
expect(window.tc.lastPointerPosition).toEqual(
|
||||
expect.objectContaining({ document, x: 100, y: 20 })
|
||||
);
|
||||
expect(first.video.playbackRate).toBe(1.1);
|
||||
expect(second.video.playbackRate).toBe(1);
|
||||
});
|
||||
|
||||
it("ignores popup actions addressed to another frame", async () => {
|
||||
const chrome = bootInject();
|
||||
await settleLifecycle();
|
||||
@@ -939,144 +718,6 @@ describe("inject.js media/controller lifecycle regressions", () => {
|
||||
expect(document.documentElement.style.isolation).toBe("");
|
||||
});
|
||||
|
||||
it.each(["video/mp4", "video/webm", "audio/ogg", "application/ogg"])(
|
||||
"preserves native %s document layout while keeping speed controls",
|
||||
async (contentType) => {
|
||||
bootInject({
|
||||
configureWindow(win) {
|
||||
Object.defineProperty(win.document, "contentType", { value: contentType });
|
||||
}
|
||||
});
|
||||
await settleLifecycle();
|
||||
|
||||
// Firefox centers an absolutely positioned video against the viewport.
|
||||
// Its empty body has no height; positioning it collapses the video.
|
||||
document.body.style.position = "static";
|
||||
const video = document.createElement("video");
|
||||
video.src = "https://example.org/native-media";
|
||||
video.controls = true;
|
||||
video.getBoundingClientRect = () =>
|
||||
document.body.style.position === "static"
|
||||
? makeRect(80, 100, 640, 360)
|
||||
: makeRect(0, 0, 0, 0);
|
||||
setRect(document.body, makeRect(8, 8, 784, 0));
|
||||
document.body.appendChild(video);
|
||||
|
||||
const controller = window.ensureController(video, document.body);
|
||||
expect(document.body.style.position).toBe("static");
|
||||
expect(document.body.style.isolation).toBe("");
|
||||
expect(controller.div.parentNode).toBe(document.body);
|
||||
expect(hostIsGeometrySuppressed(controller.div)).toBe(false);
|
||||
expect(controller.div.style.top).toBe("100px");
|
||||
expect(controller.div.style.left).toBe("80px");
|
||||
expect(controller.div.style.height).toBe("360px");
|
||||
expect(video.controls).toBe(true);
|
||||
window.setSpeed(video, 1.5, false, false);
|
||||
expect(video.playbackRate).toBe(1.5);
|
||||
|
||||
controller.div.showPopover = vi.fn();
|
||||
controller.div.hidePopover = vi.fn();
|
||||
Object.defineProperty(document, "fullscreenElement", {
|
||||
configurable: true, value: video
|
||||
});
|
||||
document.dispatchEvent(new Event("fullscreenchange"));
|
||||
expect(controller.div.showPopover).toHaveBeenCalledOnce();
|
||||
Object.defineProperty(document, "fullscreenElement", {
|
||||
configurable: true, value: null
|
||||
});
|
||||
document.dispatchEvent(new Event("fullscreenchange"));
|
||||
await settleLifecycle();
|
||||
expect(hostIsGeometrySuppressed(controller.div)).toBe(false);
|
||||
expect(document.body.style.position).toBe("static");
|
||||
window.removeController(video);
|
||||
expect(document.body.style.position).toBe("static");
|
||||
expect(document.body.style.isolation).toBe("");
|
||||
}
|
||||
);
|
||||
|
||||
it("keeps the YouTube host in fullscreen through subsequent media events", async () => {
|
||||
bootInject({ url: "https://www.youtube.com/watch?v=fullscreen" });
|
||||
await settleLifecycle();
|
||||
const player = document.createElement("div");
|
||||
player.className = "html5-video-player";
|
||||
const provider = document.createElement("div");
|
||||
const rect = makeRect(0, 0, 640, 360);
|
||||
setRect(player, rect);
|
||||
setRect(provider, rect);
|
||||
setBoxMetrics(player, 640, 360);
|
||||
setBoxMetrics(provider, 640, 360);
|
||||
player.appendChild(provider);
|
||||
document.body.appendChild(player);
|
||||
const { video, wrapper } = createControlledVideo({ mount: provider });
|
||||
expect(wrapper.parentNode).toBe(player);
|
||||
await settleLifecycle(8);
|
||||
|
||||
Object.defineProperty(document, "fullscreenElement", {
|
||||
configurable: true, value: provider
|
||||
});
|
||||
document.dispatchEvent(new Event("fullscreenchange"));
|
||||
expect(wrapper.parentNode).toBe(provider);
|
||||
await settleLifecycle(8);
|
||||
["play", "playing", "loadedmetadata", "canplay"].forEach((type) => {
|
||||
video.dispatchEvent(new Event(type));
|
||||
});
|
||||
await settleLifecycle(8);
|
||||
expect(wrapper.parentNode).toBe(provider);
|
||||
expect(hostIsGeometrySuppressed(wrapper)).toBe(false);
|
||||
|
||||
Object.defineProperty(document, "fullscreenElement", {
|
||||
configurable: true, value: null
|
||||
});
|
||||
document.dispatchEvent(new Event("fullscreenchange"));
|
||||
await settleLifecycle();
|
||||
expect(wrapper.parentNode).toBe(player);
|
||||
});
|
||||
|
||||
it("uses the current player when fullscreen follows a video move before reconciliation", async () => {
|
||||
bootInject({ url: "https://www.youtube.com/watch?v=moved-player" });
|
||||
await settleLifecycle();
|
||||
const first = document.createElement("div");
|
||||
const second = document.createElement("div");
|
||||
const rect = makeRect(0, 0, 640, 360);
|
||||
[first, second].forEach((player) => {
|
||||
player.className = "html5-video-player";
|
||||
setRect(player, rect);
|
||||
setBoxMetrics(player, 640, 360);
|
||||
document.body.appendChild(player);
|
||||
});
|
||||
const { video, wrapper } = createControlledVideo({ mount: first });
|
||||
second.appendChild(video);
|
||||
Object.defineProperty(document, "fullscreenElement", {
|
||||
configurable: true, value: document.documentElement
|
||||
});
|
||||
document.dispatchEvent(new Event("fullscreenchange"));
|
||||
expect(wrapper.parentNode).toBe(second);
|
||||
expect(video.vsc.normalControllerMount).toBe(second);
|
||||
});
|
||||
|
||||
it("preserves an intentional sibling overlay mount during fullscreen sync", async () => {
|
||||
bootInject({ url: "https://tv.apple.com/" });
|
||||
await settleLifecycle();
|
||||
const scrim = document.createElement("div");
|
||||
scrim.className = "scrim";
|
||||
setRect(scrim, makeRect(0, 0, 640, 360));
|
||||
setBoxMetrics(scrim, 640, 360);
|
||||
document.body.appendChild(scrim);
|
||||
const { video, wrapper, controller } = createControlledVideo();
|
||||
expect(wrapper.parentNode).toBe(scrim);
|
||||
expect(scrim.contains(video)).toBe(false);
|
||||
Object.defineProperty(document, "fullscreenElement", {
|
||||
configurable: true, value: document.documentElement
|
||||
});
|
||||
document.dispatchEvent(new Event("fullscreenchange"));
|
||||
expect(wrapper.parentNode).toBe(scrim);
|
||||
Object.defineProperty(document, "fullscreenElement", {
|
||||
configurable: true, value: null
|
||||
});
|
||||
window.syncControllerFullscreenMount(controller);
|
||||
expect(wrapper.parentNode).toBe(scrim);
|
||||
});
|
||||
|
||||
it("only promotes the directly-fullscreen video's controller", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
@@ -1223,25 +864,6 @@ describe("inject.js media/controller lifecycle regressions", () => {
|
||||
expect(video.vsc.div.parentElement).toBe(mount);
|
||||
});
|
||||
|
||||
it("does not remount a controller after fallback cleanup", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
const { mount, video, wrapper, controller } = createControlledVideo();
|
||||
await settleLifecycle();
|
||||
controller.remove = vi.fn(() => {
|
||||
throw new Error("cleanup failed");
|
||||
});
|
||||
|
||||
window.removeController(video);
|
||||
mount.appendChild(document.createElement("span"));
|
||||
await settleLifecycle();
|
||||
|
||||
expect(video.vsc).toBeUndefined();
|
||||
expect(window.tc.mediaElements).not.toContain(video);
|
||||
expect(wrapper.isConnected).toBe(false);
|
||||
});
|
||||
|
||||
it("still creates controls when a custom player throws from playbackRate", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
@@ -1265,36 +887,6 @@ describe("inject.js media/controller lifecycle regressions", () => {
|
||||
expect(video.vsc.div.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
it("retries controller creation when a source changes after a transient failure", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
const OriginalVideoController = window.tc.videoController;
|
||||
let shouldFail = true;
|
||||
window.tc.videoController = function(target, parent) {
|
||||
if (shouldFail) {
|
||||
shouldFail = false;
|
||||
throw new Error("player mount not ready");
|
||||
}
|
||||
return new OriginalVideoController(target, parent);
|
||||
};
|
||||
|
||||
const video = document.createElement("video");
|
||||
video.src = "https://example.org/transient-first.mp4";
|
||||
document.body.appendChild(video);
|
||||
await settleLifecycle();
|
||||
|
||||
expect(video.vsc).toBeUndefined();
|
||||
expect(video.vscBootstrapSourceObserver).toBeDefined();
|
||||
|
||||
video.src = "https://example.org/transient-second.mp4";
|
||||
await settleLifecycle();
|
||||
|
||||
expect(video.vsc).toBeDefined();
|
||||
expect(video.vsc.div.isConnected).toBe(true);
|
||||
expect(video.vscBootstrapSourceObserver).toBeUndefined();
|
||||
});
|
||||
|
||||
it("remounts an existing controller when its video moves between players", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
@@ -1340,505 +932,6 @@ describe("inject.js media/controller lifecycle regressions", () => {
|
||||
expect(mount.lastElementChild).toBe(wrapper);
|
||||
});
|
||||
|
||||
it("compacts overlapping mutation scan roots without losing coverage", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
const outer = document.createElement("section");
|
||||
const inner = document.createElement("div");
|
||||
const sibling = document.createElement("aside");
|
||||
const shadowHost = document.createElement("nested-player");
|
||||
const firstVideo = document.createElement("video");
|
||||
const secondVideo = document.createElement("video");
|
||||
firstVideo.src = "https://example.org/first-nested.mp4";
|
||||
secondVideo.src = "https://example.org/second-nested.mp4";
|
||||
inner.appendChild(firstVideo);
|
||||
sibling.appendChild(secondVideo);
|
||||
shadowHost.attachShadow({ mode: "open" });
|
||||
outer.append(inner, sibling, shadowHost);
|
||||
|
||||
const compacted = window.compactMutationScanCandidates([
|
||||
{ node: inner, parent: outer },
|
||||
{ node: firstVideo, parent: inner },
|
||||
{ node: outer, parent: document.body },
|
||||
{ node: sibling, parent: outer },
|
||||
{ node: shadowHost, parent: outer },
|
||||
{ node: inner, parent: outer }
|
||||
]);
|
||||
|
||||
expect(compacted.map((candidate) => candidate.node)).toEqual([
|
||||
outer,
|
||||
shadowHost
|
||||
]);
|
||||
|
||||
document.body.appendChild(outer);
|
||||
await settleLifecycle(8);
|
||||
expect(firstVideo.vsc).toBeDefined();
|
||||
expect(secondVideo.vsc).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not refresh existing controllers for every new video on one URL", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
createControlledVideo({ src: "https://example.org/existing.mp4" });
|
||||
const originalRefresh = window.refreshAllControllerGeometry;
|
||||
const refreshSpy = vi.fn(function() {
|
||||
return originalRefresh();
|
||||
});
|
||||
window.refreshAllControllerGeometry = refreshSpy;
|
||||
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
createControlledVideo({
|
||||
src: `https://example.org/batch-${index}.mp4`
|
||||
});
|
||||
}
|
||||
|
||||
expect(refreshSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("coalesces repeated lifecycle geometry work into one animation frame", async () => {
|
||||
vi.useFakeTimers();
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
const rect = makeRect(0, 0, 640, 360);
|
||||
const { mount, video } = createControlledVideo({
|
||||
mountRect: rect,
|
||||
videoRect: rect
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await settleLifecycle();
|
||||
|
||||
const videoRectSpy = vi.fn(() => rect);
|
||||
const mountRectSpy = vi.fn(() => rect);
|
||||
video.getBoundingClientRect = videoRectSpy;
|
||||
mount.getBoundingClientRect = mountRectSpy;
|
||||
|
||||
[
|
||||
"loadstart",
|
||||
"loadedmetadata",
|
||||
"loadeddata",
|
||||
"canplay",
|
||||
"playing",
|
||||
"play"
|
||||
].forEach((eventName) => video.dispatchEvent(new Event(eventName)));
|
||||
|
||||
expect(videoRectSpy).not.toHaveBeenCalled();
|
||||
expect(mountRectSpy).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(videoRectSpy.mock.calls.length).toBeLessThanOrEqual(1);
|
||||
expect(mountRectSpy.mock.calls.length).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("recomputes a changed controller mount asynchronously after media lifecycle events", async () => {
|
||||
vi.useFakeTimers();
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
const firstMount = document.createElement("div");
|
||||
const nextMount = document.createElement("div");
|
||||
const rect = makeRect(0, 0, 640, 360);
|
||||
[firstMount, nextMount].forEach((mount) => {
|
||||
setRect(mount, rect);
|
||||
setBoxMetrics(mount, rect.width, rect.height);
|
||||
document.body.appendChild(mount);
|
||||
});
|
||||
const { video, wrapper } = createControlledVideo({
|
||||
mount: firstMount,
|
||||
mountRect: rect,
|
||||
videoRect: rect
|
||||
});
|
||||
await settleLifecycle();
|
||||
|
||||
const getMountSpy = vi
|
||||
.spyOn(window, "getControllerMount")
|
||||
.mockReturnValue(nextMount);
|
||||
video.dispatchEvent(new Event("loadeddata"));
|
||||
|
||||
expect(wrapper.parentElement).toBe(firstMount);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await settleLifecycle();
|
||||
|
||||
expect(getMountSpy).toHaveBeenCalled();
|
||||
expect(wrapper.parentElement).toBe(nextMount);
|
||||
expect(video.vsc.controllerHostMount).toBe(nextMount);
|
||||
});
|
||||
|
||||
it("does not reorder multiple controller hosts past each other forever", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
const mount = document.createElement("div");
|
||||
const rect = makeRect(0, 0, 640, 360);
|
||||
setRect(mount, rect);
|
||||
setBoxMetrics(mount, rect.width, rect.height);
|
||||
document.body.appendChild(mount);
|
||||
|
||||
const first = createControlledVideo({
|
||||
mount,
|
||||
src: "https://example.org/shared-first.mp4",
|
||||
mountRect: rect,
|
||||
videoRect: rect
|
||||
});
|
||||
const second = createControlledVideo({
|
||||
mount,
|
||||
src: "https://example.org/shared-second.mp4",
|
||||
mountRect: rect,
|
||||
videoRect: rect
|
||||
});
|
||||
await settleLifecycle();
|
||||
|
||||
const appendSpy = vi.spyOn(mount, "appendChild");
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
window.remountControllerHost(first.video.vsc, mount);
|
||||
window.remountControllerHost(second.video.vsc, mount);
|
||||
}
|
||||
|
||||
expect(appendSpy).not.toHaveBeenCalled();
|
||||
expect(first.wrapper.parentNode).toBe(mount);
|
||||
expect(second.wrapper.parentNode).toBe(mount);
|
||||
});
|
||||
|
||||
it("reconciles existing siblings when another Speeder host is inserted", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
const { mount, wrapper } = createControlledVideo();
|
||||
const pageOverlay = document.createElement("div");
|
||||
pageOverlay.className = "page-owned-overlay";
|
||||
mount.appendChild(pageOverlay);
|
||||
document.vscMutationObserver.takeRecords();
|
||||
|
||||
const addedControllerHost = document.createElement("div");
|
||||
addedControllerHost.className = "vsc-controller";
|
||||
addedControllerHost.vscControllerHost = true;
|
||||
mount.appendChild(addedControllerHost);
|
||||
await settleLifecycle(8);
|
||||
|
||||
expect(pageOverlay.compareDocumentPosition(wrapper)).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps Speeder controller shadows out of the page observer network", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
const { wrapper } = createControlledVideo();
|
||||
await settleLifecycle();
|
||||
|
||||
expect(wrapper.shadowRoot.vscMutationObserverAttached).not.toBe(true);
|
||||
expect(wrapper.shadowRoot.vscMediaEventListenersAttached).not.toBe(true);
|
||||
expect(wrapper.shadowRoot.vscRateListenerAttached).not.toBe(true);
|
||||
});
|
||||
|
||||
it("does not observe unrelated src attributes across the document", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
const image = document.createElement("img");
|
||||
document.body.appendChild(image);
|
||||
await settleLifecycle();
|
||||
|
||||
image.src = "https://example.org/poster.jpg";
|
||||
expect(document.vscMutationObserver.takeRecords()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("still detects a source that receives its src after insertion", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
const video = document.createElement("video");
|
||||
const source = document.createElement("source");
|
||||
video.appendChild(source);
|
||||
document.body.appendChild(video);
|
||||
await settleLifecycle();
|
||||
expect(video.vsc).toBeUndefined();
|
||||
expect(video.vscBootstrapSourceObserver).toBeDefined();
|
||||
expect(window.vscMediaSourceObserver).toBeUndefined();
|
||||
|
||||
source.src = "https://example.org/deferred-source.mp4";
|
||||
await settleLifecycle();
|
||||
|
||||
expect(video.vsc).toBeDefined();
|
||||
expect(video.vscBootstrapSourceObserver).toBeUndefined();
|
||||
expect(video.vsc.div.isConnected).toBe(true);
|
||||
|
||||
const sourcePolicySpy = vi.spyOn(window, "applySourceTransitionPolicy");
|
||||
source.src = "https://example.org/next-deferred-source.mp4";
|
||||
await settleLifecycle();
|
||||
expect(sourcePolicySpy).toHaveBeenCalledWith(video, false);
|
||||
});
|
||||
|
||||
it("ignores caption src changes when tracking media source transitions", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
const { video } = createControlledVideo();
|
||||
const track = document.createElement("track");
|
||||
video.appendChild(track);
|
||||
await settleLifecycle();
|
||||
video.playbackRate = 1.5;
|
||||
const sourcePolicySpy = vi.spyOn(window, "applySourceTransitionPolicy");
|
||||
|
||||
track.src = "https://example.org/captions.vtt";
|
||||
await settleLifecycle();
|
||||
|
||||
expect(sourcePolicySpy).not.toHaveBeenCalled();
|
||||
expect(video.playbackRate).toBe(1.5);
|
||||
});
|
||||
|
||||
it("retains in-session speed histories while bounding persisted data", async () => {
|
||||
vi.useFakeTimers();
|
||||
bootInject({ syncData: { rememberSpeed: true } });
|
||||
await settleLifecycle();
|
||||
|
||||
for (let index = 0; index < 260; index += 1) {
|
||||
const source = `https://example.org/history-${index}.mp4`;
|
||||
window.rememberSourceSpeed({ src: source }, 1.5);
|
||||
window.rememberToggleSpeed(source, 1.25);
|
||||
}
|
||||
|
||||
expect(Object.keys(window.tc.settings.speeds)).toHaveLength(260);
|
||||
expect(Object.keys(window.lastToggleSpeed)).toHaveLength(260);
|
||||
expect(Object.keys(window.buildRememberedSpeedsPayload())).toHaveLength(200);
|
||||
expect(window.tc.settings.speeds).toHaveProperty(
|
||||
"https://example.org/history-0.mp4"
|
||||
);
|
||||
expect(window.lastToggleSpeed).toHaveProperty(
|
||||
"https://example.org/history-0.mp4"
|
||||
);
|
||||
expect(window.tc.settings.speeds).toHaveProperty(
|
||||
"https://example.org/history-259.mp4"
|
||||
);
|
||||
expect(window.lastToggleSpeed).toHaveProperty(
|
||||
"https://example.org/history-259.mp4"
|
||||
);
|
||||
});
|
||||
|
||||
it("avoids redundant startup reads and forced initialization", async () => {
|
||||
vi.useFakeTimers();
|
||||
const chrome = bootInject({ syncData: { controllerOpacity: 0.6 } });
|
||||
await settleLifecycle();
|
||||
|
||||
const scanSpy = vi.spyOn(window, "scanRootForMedia");
|
||||
const refreshSpy = vi.spyOn(window, "refreshAllControllerGeometry");
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await settleLifecycle();
|
||||
|
||||
expect(scanSpy).not.toHaveBeenCalled();
|
||||
expect(refreshSpy).not.toHaveBeenCalled();
|
||||
expect(chrome.storage.sync.get).toHaveBeenCalledTimes(1);
|
||||
expect(chrome.storage.local.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not lose a settings change during delayed startup hydration", async () => {
|
||||
vi.useFakeTimers();
|
||||
const chrome = bootInject({
|
||||
syncData: { controllerOpacity: 0.3 },
|
||||
configureChrome(chromeMock) {
|
||||
chromeMock.storage.sync.get.mockImplementation((_keys, callback) => {
|
||||
const snapshot = chromeMock.storage.sync._dump();
|
||||
setTimeout(() => callback(snapshot), 25);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
chrome.storage.sync.set({ controllerOpacity: 0.8 });
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
await settleLifecycle();
|
||||
|
||||
expect(chrome.storage.sync.get.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
expect(window.tc.settings.controllerOpacity).toBe(0.8);
|
||||
});
|
||||
|
||||
it("performs a recovery read when all initial storage attempts fail", async () => {
|
||||
vi.useFakeTimers();
|
||||
const source = "https://example.org/recovered-speed.mp4";
|
||||
let syncAttempts = 0;
|
||||
let localAttempts = 0;
|
||||
const chrome = bootInject({
|
||||
syncData: { controllerOpacity: 0.45 },
|
||||
localData: {
|
||||
rememberedSpeeds: {
|
||||
[source]: { speed: 1.7, updatedAt: 100 }
|
||||
}
|
||||
},
|
||||
configureChrome(chromeMock) {
|
||||
const syncSnapshot = chromeMock.storage.sync._dump();
|
||||
const localSnapshot = chromeMock.storage.local._dump();
|
||||
chromeMock.storage.sync.get.mockImplementation((_keys, callback) => {
|
||||
syncAttempts += 1;
|
||||
if (syncAttempts <= 4) {
|
||||
chromeMock.runtime.lastError = { message: "sync unavailable" };
|
||||
callback({});
|
||||
chromeMock.runtime.lastError = null;
|
||||
return;
|
||||
}
|
||||
callback(syncSnapshot);
|
||||
});
|
||||
chromeMock.storage.local.get.mockImplementation((_keys, callback) => {
|
||||
localAttempts += 1;
|
||||
if (localAttempts <= 4) {
|
||||
chromeMock.runtime.lastError = { message: "local unavailable" };
|
||||
callback({});
|
||||
chromeMock.runtime.lastError = null;
|
||||
return;
|
||||
}
|
||||
callback(localSnapshot);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await settleLifecycle();
|
||||
|
||||
expect(chrome.storage.sync.get).toHaveBeenCalledTimes(5);
|
||||
expect(chrome.storage.local.get.mock.calls.length).toBeGreaterThanOrEqual(5);
|
||||
expect(window.tc.settings.controllerOpacity).toBe(0.45);
|
||||
expect(window.tc.settings.speeds[source]).toBe(1.7);
|
||||
});
|
||||
|
||||
it("stops URL polling after the page-world bridge loads", async () => {
|
||||
vi.useFakeTimers();
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
const bridge = document.querySelector(
|
||||
'script[src$="content/shadow-bridge.js"]'
|
||||
);
|
||||
expect(window.vscLocationWatchTimer).not.toBeNull();
|
||||
|
||||
const shadowCatchupSpy = vi.spyOn(window, "rescanOpenShadowRoots");
|
||||
document.dispatchEvent(new Event("speeder-page-navigation-api-ready"));
|
||||
document.dispatchEvent(new Event("speeder-page-bridge-ready"));
|
||||
bridge.dispatchEvent(new Event("load"));
|
||||
|
||||
expect(shadowCatchupSpy).toHaveBeenCalledTimes(1);
|
||||
expect(window.vscLocationWatchTimer).toBeNull();
|
||||
expect(window.vscBoundedShadowFallbackTimers).toHaveLength(0);
|
||||
|
||||
const documentScanSpy = vi.spyOn(window, "scanRootForMedia");
|
||||
const shadowScanSpy = vi.spyOn(window, "rescanObservedMediaRoots");
|
||||
window.history.replaceState({}, "", window.location.href);
|
||||
expect(window.vscNavigationRescanTimer).not.toBeNull();
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
expect(documentScanSpy).not.toHaveBeenCalled();
|
||||
expect(shadowScanSpy).not.toHaveBeenCalled();
|
||||
|
||||
window.history.pushState({}, "", "/event-driven-route");
|
||||
expect(window.vscNavigationRescanTimer).not.toBeNull();
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
|
||||
expect(documentScanSpy).not.toHaveBeenCalled();
|
||||
expect(shadowScanSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retains URL polling when Navigation API route events are unavailable", async () => {
|
||||
vi.useFakeTimers();
|
||||
let nativePushState;
|
||||
bootInject({
|
||||
configureWindow(win) {
|
||||
nativePushState = win.history.pushState.bind(win.history);
|
||||
}
|
||||
});
|
||||
await settleLifecycle();
|
||||
|
||||
const bridge = document.querySelector(
|
||||
'script[src$="content/shadow-bridge.js"]'
|
||||
);
|
||||
document.dispatchEvent(new Event("speeder-page-bridge-ready"));
|
||||
bridge.dispatchEvent(new Event("load"));
|
||||
expect(window.vscLocationWatchTimer).not.toBeNull();
|
||||
|
||||
nativePushState({}, "", "/unwrapped-route");
|
||||
expect(window.vscNavigationRescanTimer).toBeFalsy();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(window.vscNavigationRescanTimer).not.toBeNull();
|
||||
});
|
||||
|
||||
it("rechecks deferred media on same-URL state navigation without a full scan", async () => {
|
||||
vi.useFakeTimers();
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
document.dispatchEvent(new Event("speeder-page-bridge-ready"));
|
||||
|
||||
const mount = document.createElement("div");
|
||||
const video = document.createElement("video");
|
||||
video.src = "https://example.org/ambient-to-player.mp4";
|
||||
video.autoplay = true;
|
||||
video.defaultMuted = true;
|
||||
video.muted = true;
|
||||
video.loop = true;
|
||||
video.playsInline = true;
|
||||
mount.appendChild(video);
|
||||
document.body.appendChild(mount);
|
||||
await settleLifecycle();
|
||||
expect(video.vsc).toBeUndefined();
|
||||
|
||||
video.loop = false;
|
||||
const documentScanSpy = vi.spyOn(window, "scanRootForMedia");
|
||||
window.history.replaceState({}, "", window.location.href);
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
await settleLifecycle();
|
||||
|
||||
expect(documentScanSpy).not.toHaveBeenCalled();
|
||||
expect(video.vsc).toBeDefined();
|
||||
expect(video.vsc.div.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
it("reuses one pointer record instead of allocating on every mouse move", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
createControlledVideo();
|
||||
|
||||
document.dispatchEvent(
|
||||
new MouseEvent("mousemove", { clientX: 10, clientY: 20, bubbles: true })
|
||||
);
|
||||
const pointerRecord = window.tc.lastPointerPosition;
|
||||
document.dispatchEvent(
|
||||
new MouseEvent("mousemove", { clientX: 30, clientY: 40, bubbles: true })
|
||||
);
|
||||
|
||||
expect(window.tc.lastPointerPosition).toBe(pointerRecord);
|
||||
expect(pointerRecord).toEqual(
|
||||
expect.objectContaining({ x: 30, y: 40, document })
|
||||
);
|
||||
});
|
||||
|
||||
it("matches site rules once per URL instead of once per media event", async () => {
|
||||
bootInject({
|
||||
syncData: {
|
||||
siteRules: [
|
||||
{ pattern: "example.org", controllerLocation: "bottom-left" }
|
||||
]
|
||||
}
|
||||
});
|
||||
await settleLifecycle();
|
||||
|
||||
const matchSpy = vi.spyOn(
|
||||
window.SpeederShared.siteRules,
|
||||
"matchSiteRule"
|
||||
);
|
||||
const { mount, video } = createControlledVideo();
|
||||
[
|
||||
"loadstart",
|
||||
"loadedmetadata",
|
||||
"loadeddata",
|
||||
"canplay",
|
||||
"playing",
|
||||
"play"
|
||||
].forEach((eventName) => video.dispatchEvent(new Event(eventName)));
|
||||
|
||||
expect(matchSpy).not.toHaveBeenCalled();
|
||||
|
||||
window.history.pushState({}, "", "/next-player");
|
||||
window.ensureController(video, mount);
|
||||
expect(matchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps expanded controls visible and disables auto-hide while hovered", async () => {
|
||||
vi.useFakeTimers();
|
||||
bootInject({
|
||||
@@ -2034,78 +1127,6 @@ describe("inject.js media/controller lifecycle regressions", () => {
|
||||
expect(video.vsc).toBeUndefined();
|
||||
expect(window.tc.mediaElements).not.toContain(video);
|
||||
expect(wrapper.isConnected).toBe(false);
|
||||
expect(shadow.vscMutationObserverAttached).toBe(false);
|
||||
|
||||
document.body.appendChild(host);
|
||||
await settleLifecycle(6);
|
||||
|
||||
expect(shadow.vscMutationObserverAttached).toBe(true);
|
||||
expect(video.vsc).toBeDefined();
|
||||
expect(video.vsc.div.getRootNode()).toBe(shadow);
|
||||
});
|
||||
|
||||
it("releases detached shadow roots without WeakRef and rediscovers them on insertion", async () => {
|
||||
bootInject({
|
||||
configureWindow(targetWindow) {
|
||||
Object.defineProperty(targetWindow, "WeakRef", {
|
||||
configurable: true,
|
||||
value: undefined
|
||||
});
|
||||
}
|
||||
});
|
||||
await settleLifecycle();
|
||||
|
||||
const host = document.createElement("legacy-shadow-player");
|
||||
const shadow = host.attachShadow({ mode: "open" });
|
||||
const video = document.createElement("video");
|
||||
video.src = "https://example.org/legacy-shadow.mp4";
|
||||
shadow.appendChild(video);
|
||||
document.body.appendChild(host);
|
||||
await settleLifecycle(6);
|
||||
|
||||
expect(window.vscSupportsWeakRootReferences).toBe(false);
|
||||
expect(video.vsc).toBeDefined();
|
||||
|
||||
host.remove();
|
||||
await settleLifecycle(6);
|
||||
|
||||
expect(shadow.vscMutationObserverAttached).toBe(false);
|
||||
expect(shadow.vscObservedRootTracked).toBe(false);
|
||||
expect(window.vscSuspendedObservedRootList).not.toContain(shadow);
|
||||
expect(video.vsc).toBeUndefined();
|
||||
|
||||
document.body.appendChild(host);
|
||||
await settleLifecycle(8);
|
||||
|
||||
expect(shadow.vscMutationObserverAttached).toBe(true);
|
||||
expect(shadow.vscObservedRootTracked).toBe(true);
|
||||
expect(video.vsc).toBeDefined();
|
||||
});
|
||||
|
||||
it("keeps controls when a video moves within an open ShadowRoot", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
const host = document.createElement("moving-shadow-player");
|
||||
const shadow = host.attachShadow({ mode: "open" });
|
||||
const firstMount = document.createElement("div");
|
||||
const secondMount = document.createElement("div");
|
||||
const video = document.createElement("video");
|
||||
video.src = "https://example.org/moving-shadow.mp4";
|
||||
firstMount.appendChild(video);
|
||||
shadow.append(firstMount, secondMount);
|
||||
document.body.appendChild(host);
|
||||
window.observeRoot(shadow);
|
||||
window.scanRootForMedia(shadow);
|
||||
const wrapper = video.vsc.div;
|
||||
|
||||
secondMount.appendChild(video);
|
||||
await settleLifecycle(8);
|
||||
|
||||
expect(video.vsc).toBeDefined();
|
||||
expect(video.vsc.div).toBe(wrapper);
|
||||
expect(wrapper.isConnected).toBe(true);
|
||||
expect(wrapper.parentNode).toBe(secondMount);
|
||||
});
|
||||
|
||||
it("starts and ends a controller drag mounted directly in an open ShadowRoot", async () => {
|
||||
@@ -2155,32 +1176,6 @@ describe("inject.js media/controller lifecycle regressions", () => {
|
||||
expect(video.vsc.div.getRootNode()).toBe(shadow);
|
||||
});
|
||||
|
||||
it("keeps one set-backed queue entry per media element before hydration", async () => {
|
||||
vi.useFakeTimers();
|
||||
bootInject({ syncGetDelayMs: 100 });
|
||||
|
||||
const videos = Array.from({ length: 24 }, (_, index) => {
|
||||
const video = document.createElement("video");
|
||||
video.src = `https://example.org/early-${index}.mp4`;
|
||||
document.body.appendChild(video);
|
||||
for (let repeat = 0; repeat < 6; repeat += 1) {
|
||||
window.ensureController(video, document.body);
|
||||
}
|
||||
return video;
|
||||
});
|
||||
|
||||
expect(window.tc.pendingMediaCandidates).toHaveLength(videos.length);
|
||||
expect(
|
||||
videos.every((video) => window.tc.pendingMediaCandidateNodes.has(video))
|
||||
).toBe(true);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(120);
|
||||
await settleLifecycle();
|
||||
|
||||
expect(window.tc.pendingMediaCandidates).toHaveLength(0);
|
||||
expect(videos.every((video) => video.vsc && video.vsc.div)).toBe(true);
|
||||
});
|
||||
|
||||
it("reconciles source children and the emptied lifecycle on an existing controller", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
Reference in new issue
Block a user