mirror of
https://github.com/SoPat712/Speeder.git
synced 2026-10-06 18:55:39 -04:00
fix: reduce page overhead and preserve media targeting
This commit is contained in:
3 files changed
+1942
-226
No files matched your search
+1034
-224
@@ -27,9 +27,63 @@ 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;
|
||||
return (wrapper && wrapper.vscVideoRect) || null;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function getPrimaryVideoElement(mediaElements) {
|
||||
@@ -242,8 +296,11 @@ var tc = {
|
||||
activeSiteRule: null,
|
||||
tabPaused: false,
|
||||
siteRuleBase: null,
|
||||
siteRuleAppliedHref: null,
|
||||
runtimeSettingsHydrated: false,
|
||||
rawSyncSettingsSnapshot: null,
|
||||
pendingMediaCandidates: [],
|
||||
pendingMediaCandidateNodes: new WeakSet(),
|
||||
settingsReloadRetries: 0,
|
||||
lastPointerPosition: null,
|
||||
lastInteractedMedia: null,
|
||||
@@ -268,6 +325,7 @@ function applyTabPausedState(paused) {
|
||||
if (!tc.runtimeSettingsHydrated) return;
|
||||
if (tc.tabPaused) {
|
||||
clearAllSpeedRestoreEnforcement();
|
||||
clearDeferredMediaCandidates();
|
||||
tc.mediaElements.slice().forEach(function(media) {
|
||||
removeController(media);
|
||||
});
|
||||
@@ -283,10 +341,15 @@ 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)
|
||||
@@ -605,6 +668,7 @@ function applyControllerLocation(videoController, location) {
|
||||
}
|
||||
|
||||
function captureSiteRuleBase() {
|
||||
tc.siteRuleAppliedHref = null;
|
||||
tc.siteRuleBase = {
|
||||
startHidden: tc.settings.startHidden,
|
||||
hideWithControls: tc.settings.hideWithControls,
|
||||
@@ -1094,6 +1158,11 @@ 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 &&
|
||||
@@ -1500,6 +1569,63 @@ 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;
|
||||
@@ -1590,12 +1716,12 @@ function hasUsableMediaSource(node) {
|
||||
}
|
||||
|
||||
if (node.querySelectorAll) {
|
||||
return Array.from(node.querySelectorAll("source[src]")).some(function(
|
||||
source
|
||||
) {
|
||||
var sources = node.querySelectorAll("source[src]");
|
||||
for (var i = 0; i < sources.length; i += 1) {
|
||||
var source = sources[i];
|
||||
var src = source.getAttribute("src");
|
||||
return typeof src === "string" && src.trim().length > 0;
|
||||
});
|
||||
if (typeof src === "string" && src.trim().length > 0) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -1615,10 +1741,9 @@ function ensureController(node, parent) {
|
||||
if (
|
||||
node &&
|
||||
(node.nodeName === "VIDEO" || node.nodeName === "AUDIO") &&
|
||||
!tc.pendingMediaCandidates.some(function(candidate) {
|
||||
return candidate.node === node;
|
||||
})
|
||||
!tc.pendingMediaCandidateNodes.has(node)
|
||||
) {
|
||||
tc.pendingMediaCandidateNodes.add(node);
|
||||
tc.pendingMediaCandidates.push({ node: node, parent: parent });
|
||||
}
|
||||
return null;
|
||||
@@ -1626,15 +1751,18 @@ function ensureController(node, parent) {
|
||||
if (!isMediaElement(node)) return null;
|
||||
if (typeof tc.videoController === "undefined") defineVideoController();
|
||||
|
||||
// href selects site rules; re-run on every new/usable media so all runtime
|
||||
// paths agree on activation and effective settings.
|
||||
applySiteRuleOverrides();
|
||||
if (!isSpeederActiveForCurrentPage()) {
|
||||
// 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);
|
||||
if (node.vsc) removeController(node);
|
||||
return null;
|
||||
}
|
||||
if (isAmbientLoopMedia(node)) {
|
||||
if (node.vsc) removeController(node);
|
||||
disconnectBootstrapMediaSourceObserver(node);
|
||||
vscDeferredMediaCandidates.add(node);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1650,6 +1778,8 @@ function ensureController(node, parent) {
|
||||
}
|
||||
|
||||
if (node.vsc) {
|
||||
vscDeferredMediaCandidates.delete(node);
|
||||
disconnectBootstrapMediaSourceObserver(node);
|
||||
var existingController = node.vsc;
|
||||
var hasSource = hasUsableMediaSource(node);
|
||||
if (existingController.div) {
|
||||
@@ -1676,6 +1806,8 @@ function ensureController(node, parent) {
|
||||
}
|
||||
|
||||
if (!hasUsableMediaSource(node)) {
|
||||
vscDeferredMediaCandidates.add(node);
|
||||
observeMediaSourceNodes(node);
|
||||
log(
|
||||
`Deferring controller creation for ${node.tagName}: no usable source yet`,
|
||||
5
|
||||
@@ -1683,13 +1815,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
|
||||
@@ -1697,11 +1829,15 @@ 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 (
|
||||
@@ -1717,6 +1853,7 @@ 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,
|
||||
@@ -1735,7 +1872,10 @@ function ensureControllerForMediaChild(node) {
|
||||
}
|
||||
|
||||
function removeController(node) {
|
||||
if (!node || !node.vsc) return;
|
||||
if (!node) return;
|
||||
vscDeferredMediaCandidates.delete(node);
|
||||
disconnectBootstrapMediaSourceObserver(node);
|
||||
if (!node.vsc) return;
|
||||
var controller = node.vsc;
|
||||
if (typeof controller.remove === "function") {
|
||||
try {
|
||||
@@ -1745,6 +1885,14 @@ 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();
|
||||
}
|
||||
@@ -1768,8 +1916,9 @@ function scanNodeForMedia(node, parent, added) {
|
||||
return;
|
||||
}
|
||||
|
||||
var ownerDocument = node.ownerDocument || document;
|
||||
if (!added && ownerDocument.body && ownerDocument.body.contains(node)) return;
|
||||
// A removed-node record can represent a move. isConnected works across
|
||||
// shadow boundaries; document.body.contains() does not.
|
||||
if (!added && node.isConnected) return;
|
||||
|
||||
// Check if the node itself is a media element
|
||||
if (isMediaElement(node)) {
|
||||
@@ -1809,6 +1958,39 @@ 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) {
|
||||
@@ -1860,52 +2042,208 @@ function rescanOpenShadowRoots(root, visited, rescanObserved) {
|
||||
|
||||
hosts.forEach(function(host) {
|
||||
var shadowRoot = host.shadowRoot;
|
||||
if (!shadowRoot || seen.has(shadowRoot)) return;
|
||||
if (
|
||||
!shadowRoot ||
|
||||
seen.has(shadowRoot) ||
|
||||
isSpeederControllerShadowRoot(shadowRoot)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
seen.add(shadowRoot);
|
||||
if (rescanObserved !== false || !vscObservedRoots.has(shadowRoot)) {
|
||||
if (
|
||||
rescanObserved !== false ||
|
||||
!vscObservedRoots.has(shadowRoot) ||
|
||||
!shadowRoot.vscMutationObserverAttached
|
||||
) {
|
||||
observeRoot(shadowRoot);
|
||||
}
|
||||
rescanOpenShadowRoots(shadowRoot, seen, rescanObserved);
|
||||
});
|
||||
}
|
||||
|
||||
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)
|
||||
) {
|
||||
scanRootForMedia(root);
|
||||
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 = retainedRoots;
|
||||
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;
|
||||
}
|
||||
if (
|
||||
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);
|
||||
}
|
||||
|
||||
function observeRoot(root) {
|
||||
if (!root) return;
|
||||
if (!root || isSpeederControllerShadowRoot(root)) return;
|
||||
|
||||
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;
|
||||
}
|
||||
var isConnected = isObservedRootConnected(root);
|
||||
|
||||
if (!vscObservedRoots.has(root)) {
|
||||
vscObservedRoots.add(root);
|
||||
vscObservedRootList.push(root);
|
||||
}
|
||||
trackObservedRoot(root);
|
||||
|
||||
if (isConnected) {
|
||||
setupListener(root);
|
||||
attachMutationObserver(root);
|
||||
attachMediaDetectionListeners(root);
|
||||
if (!root.vscMutationObserverAttached) attachMutationObserver(root);
|
||||
vscSuspendedObservedRoots.delete(root);
|
||||
} else {
|
||||
trackSuspendedObservedRoot(root);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -1943,6 +2281,23 @@ 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;
|
||||
|
||||
@@ -1963,11 +2318,24 @@ function installPageShadowBridge() {
|
||||
);
|
||||
document.addEventListener(
|
||||
"speeder-location-changed",
|
||||
function() {
|
||||
scheduleNavigationRescan();
|
||||
function(event) {
|
||||
scheduleNavigationRescan(event);
|
||||
},
|
||||
true
|
||||
);
|
||||
document.addEventListener(
|
||||
"speeder-page-navigation-api-ready",
|
||||
function() {
|
||||
window.vscPageNavigationApiBridgeLoaded = true;
|
||||
if (window.vscPageShadowBridgeLoaded) stopLocationWatch();
|
||||
},
|
||||
true
|
||||
);
|
||||
document.addEventListener(
|
||||
"speeder-page-bridge-ready",
|
||||
markPageShadowBridgeReady,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
function startBoundedShadowFallback() {
|
||||
@@ -1980,7 +2348,7 @@ function installPageShadowBridge() {
|
||||
function() {
|
||||
if (
|
||||
!document.body ||
|
||||
(delay !== 3000 && window.vscPageShadowBridgeLoaded)
|
||||
window.vscPageShadowBridgeLoaded
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -2022,14 +2390,19 @@ 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();
|
||||
window.vscPageShadowBridgeLoaded = true;
|
||||
window.vscPageShadowBridgeRetries = 0;
|
||||
clearTimeout(window.vscPageShadowBridgeRetryTimer);
|
||||
if (!window.vscPageShadowBridgeLoaded) handleBridgeFailure();
|
||||
});
|
||||
bridge.addEventListener("error", function() {
|
||||
bridge.remove();
|
||||
@@ -2063,8 +2436,31 @@ 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 || {})
|
||||
@@ -2374,6 +2770,7 @@ 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) {
|
||||
@@ -2388,6 +2785,14 @@ 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 || {}, {
|
||||
@@ -2410,17 +2815,57 @@ function loadInitialLocalSettings(attempt, callback) {
|
||||
chrome.storage.local.get(
|
||||
["customButtonIcons", "rememberedSpeeds", "rememberedSpeedsResetAt"],
|
||||
function(localStorage) {
|
||||
if (chrome.runtime.lastError && attempt < 3) {
|
||||
var readFailed = Boolean(chrome.runtime.lastError);
|
||||
if (readFailed && attempt < 3) {
|
||||
setTimeout(function() {
|
||||
loadInitialLocalSettings(attempt + 1, callback);
|
||||
}, 100 * (attempt + 1));
|
||||
return;
|
||||
}
|
||||
callback(chrome.runtime.lastError ? {} : localStorage || {});
|
||||
callback(readFailed ? {} : localStorage || {}, readFailed);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -2430,7 +2875,8 @@ installPageShadowBridge();
|
||||
|
||||
function loadInitialRuntimeSettings(attempt) {
|
||||
chrome.storage.sync.get(null, function(rawStorage) {
|
||||
if (chrome.runtime.lastError && attempt < 3) {
|
||||
var initialSyncReadFailed = Boolean(chrome.runtime.lastError);
|
||||
if (initialSyncReadFailed && attempt < 3) {
|
||||
setTimeout(function() {
|
||||
loadInitialRuntimeSettings(attempt + 1);
|
||||
}, 100 * (attempt + 1));
|
||||
@@ -2536,7 +2982,7 @@ function loadInitialRuntimeSettings(attempt) {
|
||||
// Set the flag to prevent adding the listener again.
|
||||
window.vscMessageListener = true;
|
||||
}
|
||||
loadInitialLocalSettings(0, function(loc) {
|
||||
loadInitialLocalSettings(0, function(loc, initialLocalReadFailed) {
|
||||
applyCustomButtonIcons(loc && loc.customButtonIcons);
|
||||
|
||||
applyRememberedSpeedsResetMarker(
|
||||
@@ -2554,6 +3000,9 @@ 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)
|
||||
@@ -2656,48 +3105,65 @@ function loadInitialRuntimeSettings(attempt) {
|
||||
applyCustomButtonIcons(nv);
|
||||
});
|
||||
}
|
||||
// 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;
|
||||
// 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;
|
||||
if (
|
||||
!rememberedSpeedsPayloadMatches(
|
||||
latestRememberedSpeeds,
|
||||
buildRememberedSpeedsPayload()
|
||||
)
|
||||
customIconGeneration ===
|
||||
(Number(window.vscCustomIconGeneration) || 0)
|
||||
) {
|
||||
schedulePersistRememberedSpeeds(true);
|
||||
var latestCustomButtonIcons =
|
||||
(latestLocal && latestLocal.customButtonIcons) || {};
|
||||
if (
|
||||
!runtimeStorageSnapshotsEqual(
|
||||
latestCustomButtonIcons,
|
||||
tc.settings.customButtonIcons || {}
|
||||
)
|
||||
) {
|
||||
applyCustomButtonIcons(latestCustomButtonIcons);
|
||||
}
|
||||
}
|
||||
if (tc.settings.rememberSpeed || tc.settings.forceLastSavedSpeed) {
|
||||
tc.mediaElements.slice().forEach(applyRememberedSpeedPolicy);
|
||||
if (
|
||||
rememberedSpeedsGeneration ===
|
||||
(Number(window.vscRememberedSpeedsGeneration) || 0)
|
||||
) {
|
||||
var resetMarkerChanged = applyRememberedSpeedsResetMarker(
|
||||
latestLocal && latestLocal.rememberedSpeedsResetAt
|
||||
);
|
||||
var latestRememberedSpeeds =
|
||||
latestLocal && latestLocal.rememberedSpeeds;
|
||||
var currentRememberedSpeeds = buildRememberedSpeedsPayload();
|
||||
var rememberedSpeedsChanged = !rememberedSpeedsPayloadMatches(
|
||||
latestRememberedSpeeds,
|
||||
currentRememberedSpeeds
|
||||
);
|
||||
if (rememberedSpeedsChanged) {
|
||||
mergeRememberedSpeeds(latestRememberedSpeeds);
|
||||
schedulePersistRememberedSpeeds(true);
|
||||
}
|
||||
if (
|
||||
(resetMarkerChanged || rememberedSpeedsChanged) &&
|
||||
(tc.settings.rememberSpeed || tc.settings.forceLastSavedSpeed)
|
||||
) {
|
||||
tc.mediaElements.slice().forEach(applyRememberedSpeedPolicy);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
);
|
||||
}
|
||||
tc.runtimeSettingsHydrated = true;
|
||||
initializeWhenReady(document);
|
||||
});
|
||||
@@ -2707,6 +3173,7 @@ function loadInitialRuntimeSettings(attempt) {
|
||||
// Install before async settings hydration so SPA-owned window capture handlers
|
||||
// cannot hide later key events from Speeder.
|
||||
attachKeydownListeners(document);
|
||||
beginStartupStorageWatch();
|
||||
loadInitialRuntimeSettings(0);
|
||||
|
||||
function getKeyBindings(action, what = "value") {
|
||||
@@ -2976,14 +3443,7 @@ function positionControllerHost(wrapper, video, mount) {
|
||||
return;
|
||||
}
|
||||
var videoRect = video.getBoundingClientRect();
|
||||
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
|
||||
};
|
||||
cacheVideoRect(wrapper, video, videoRect);
|
||||
if (wrapper.classList.contains("vsc-fullscreen-popover")) {
|
||||
if (videoRect.width <= 0 || videoRect.height <= 0) {
|
||||
wrapper.classList.add("vsc-geometry-hidden");
|
||||
@@ -3078,6 +3538,57 @@ 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;
|
||||
|
||||
@@ -3158,28 +3669,35 @@ function setupControllerHostTracking(videoController, wrapper, mount) {
|
||||
passive: true
|
||||
});
|
||||
});
|
||||
win.addEventListener("resize", schedule, { passive: true });
|
||||
doc.addEventListener("fullscreenchange", schedule, { passive: true });
|
||||
attachSharedViewportScrollTracking(win);
|
||||
attachSharedControllerResizeListener(win);
|
||||
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;
|
||||
return (
|
||||
controllerHost !== wrapper &&
|
||||
isSpeederControllerHost(controllerHost)
|
||||
);
|
||||
});
|
||||
} catch (e) {}
|
||||
if (
|
||||
@@ -3252,16 +3770,33 @@ function remountControllerHost(videoController, mount) {
|
||||
videoController.div.isConnected &&
|
||||
videoController.div.parentNode === mount
|
||||
) {
|
||||
// Keep the max-z-index host last among equal-z-index player overlays.
|
||||
if (videoController.div.nextSibling) {
|
||||
// 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) {
|
||||
mount.appendChild(videoController.div);
|
||||
return true;
|
||||
}
|
||||
positionControllerHost(
|
||||
videoController.div,
|
||||
videoController.video,
|
||||
mount
|
||||
);
|
||||
if (typeof videoController.controllerHostSchedule === "function") {
|
||||
videoController.controllerHostSchedule();
|
||||
} else {
|
||||
positionControllerHost(
|
||||
videoController.div,
|
||||
videoController.video,
|
||||
mount
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -3392,12 +3927,15 @@ 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);
|
||||
|
||||
@@ -3554,23 +4092,21 @@ function defineVideoController() {
|
||||
|
||||
var srcObserver = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (
|
||||
mutation.type === "attributes" &&
|
||||
(mutation.attributeName === "src" ||
|
||||
mutation.attributeName === "currentSrc")
|
||||
) {
|
||||
if (isMediaSourceAttributeMutation(mutation, this.video)) {
|
||||
log("mutation of A/V element", 5);
|
||||
if (this.div) {
|
||||
this.stopSubtitleNudge();
|
||||
if (!mutation.target.src && !mutation.target.currentSrc) {
|
||||
this.div.classList.toggle(
|
||||
"vsc-nosource",
|
||||
!hasUsableMediaSource(mutation.target)
|
||||
);
|
||||
var sourceAvailable = hasUsableMediaSource(this.video);
|
||||
this.div.classList.toggle("vsc-nosource", !sourceAvailable);
|
||||
if (!sourceAvailable) {
|
||||
this.mediaSourceKey = "unknown_src";
|
||||
this.targetSpeedSourceKey = "unknown_src";
|
||||
} else {
|
||||
this.div.classList.remove("vsc-nosource");
|
||||
applySourceTransitionPolicy(this.video, true);
|
||||
if (!mutation.target.paused) this.startSubtitleNudge();
|
||||
applySourceTransitionPolicy(
|
||||
this.video,
|
||||
mutation.target === this.video
|
||||
);
|
||||
if (!this.video.paused) this.startSubtitleNudge();
|
||||
}
|
||||
updateSubtitleNudgeIndicator(this.video);
|
||||
}
|
||||
@@ -3578,7 +4114,11 @@ function defineVideoController() {
|
||||
});
|
||||
});
|
||||
this.srcObserver = srcObserver;
|
||||
srcObserver.observe(target, { attributeFilter: ["src", "currentSrc"] });
|
||||
srcObserver.observe(target, {
|
||||
attributes: true,
|
||||
subtree: true,
|
||||
attributeFilter: ["src", "currentSrc"]
|
||||
});
|
||||
if (!target.paused && target.playbackRate !== 1.0)
|
||||
this.startSubtitleNudge();
|
||||
};
|
||||
@@ -3586,6 +4126,18 @@ 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 &&
|
||||
@@ -3610,7 +4162,22 @@ function defineVideoController() {
|
||||
this.genericAutoHideCleanup();
|
||||
this.genericAutoHideCleanup = null;
|
||||
}
|
||||
if (this.div) this.div.remove();
|
||||
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.controllerHostCleanup) {
|
||||
this.controllerHostCleanup();
|
||||
this.controllerHostCleanup = null;
|
||||
@@ -3966,6 +4533,7 @@ 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";
|
||||
@@ -4166,10 +4734,12 @@ 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)
|
||||
@@ -4186,7 +4756,6 @@ function applySiteRuleOverrides() {
|
||||
return finishSiteRuleApplication(false);
|
||||
}
|
||||
|
||||
var currentUrl = location.href;
|
||||
var matchedRule = siteRuleUtils.matchSiteRule(currentUrl, tc.settings.siteRules);
|
||||
|
||||
if (!matchedRule) {
|
||||
@@ -4262,12 +4831,14 @@ function applySiteRuleOverrides() {
|
||||
|
||||
function removeIneligibleMediaControllers() {
|
||||
tc.mediaElements.slice().forEach(function(media) {
|
||||
if (
|
||||
media &&
|
||||
((media.nodeName === "AUDIO" && !tc.settings.audioBoolean) ||
|
||||
isAmbientLoopMedia(media))
|
||||
) {
|
||||
if (!media) return;
|
||||
if (media.nodeName === "AUDIO" && !tc.settings.audioBoolean) {
|
||||
removeController(media);
|
||||
return;
|
||||
}
|
||||
if (isAmbientLoopMedia(media)) {
|
||||
removeController(media);
|
||||
vscDeferredMediaCandidates.add(media);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -4285,8 +4856,12 @@ function refreshAllControllerGeometry() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Re-match site rules for current URL and refresh controller position/opacity on every video. */
|
||||
/** Re-match site rules and refresh existing controllers once per URL. */
|
||||
function reapplySiteRulesAndControllerGeometry() {
|
||||
if (tc.siteRuleAppliedHref === location.href) {
|
||||
return isSpeederActiveForCurrentPage();
|
||||
}
|
||||
|
||||
applySiteRuleOverrides();
|
||||
if (!isSpeederActiveForCurrentPage()) {
|
||||
tc.mediaElements.slice().forEach(function(video) {
|
||||
@@ -4531,6 +5106,7 @@ function attachKeydownListeners(doc) {
|
||||
keyTarget.addEventListener(
|
||||
"keydown",
|
||||
function(event) {
|
||||
if (!tc.runtimeSettingsHydrated || !tc.mediaElements.length) return;
|
||||
if (
|
||||
!event.getModifierState ||
|
||||
event.getModifierState("Alt") ||
|
||||
@@ -4551,8 +5127,6 @@ function attachKeydownListeners(doc) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tc.mediaElements.length) return;
|
||||
|
||||
var item = tc.settings.keyBindings.find(function(binding) {
|
||||
return matchesKeyBinding(binding, event);
|
||||
});
|
||||
@@ -4583,11 +5157,14 @@ function attachMediaTargetTracking(doc) {
|
||||
if (!Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) {
|
||||
return;
|
||||
}
|
||||
tc.lastPointerPosition = {
|
||||
document: doc,
|
||||
x: event.clientX,
|
||||
y: event.clientY
|
||||
};
|
||||
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;
|
||||
},
|
||||
{ capture: true, passive: true }
|
||||
);
|
||||
@@ -4597,109 +5174,226 @@ function attachMediaTargetTracking(doc) {
|
||||
function attachMutationObserver(root) {
|
||||
if (root.vscMutationObserverAttached) return;
|
||||
|
||||
var pendingMutations = [];
|
||||
var pendingControllerMountTargets = new Set();
|
||||
var pendingAddedCandidates = new Map();
|
||||
var pendingRemovedCandidates = new Map();
|
||||
var pendingAriaHiddenTargets = new Set();
|
||||
var mutationProcessingScheduled = false;
|
||||
var observer = new MutationObserver(function(mutations) {
|
||||
pendingMutations.push(...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);
|
||||
}
|
||||
});
|
||||
if (mutationProcessingScheduled) return;
|
||||
|
||||
mutationProcessingScheduled = true;
|
||||
requestIdle(
|
||||
function() {
|
||||
var mutationsToProcess = pendingMutations.splice(0);
|
||||
mutationProcessingScheduled = false;
|
||||
var controllerMountTargets = new Set();
|
||||
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();
|
||||
|
||||
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);
|
||||
});
|
||||
// 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;
|
||||
|
||||
return;
|
||||
compactMutationScanCandidates(removedCandidates).forEach(
|
||||
function(candidate) {
|
||||
scanNodeForMedia(
|
||||
candidate.node,
|
||||
candidate.node.parentNode || candidate.parent,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
if (mutation.type !== "attributes") return;
|
||||
|
||||
var target = mutation.target;
|
||||
if (
|
||||
isMediaElement(target) &&
|
||||
(mutation.attributeName === "src" ||
|
||||
mutation.attributeName === "currentSrc")
|
||||
) {
|
||||
ensureController(target, target.parentElement || target.parentNode);
|
||||
return;
|
||||
);
|
||||
compactMutationScanCandidates(addedCandidates).forEach(
|
||||
function(candidate) {
|
||||
scanNodeForMedia(
|
||||
candidate.node,
|
||||
candidate.node.parentNode || candidate.parent,
|
||||
true
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
ariaHiddenTargets.forEach(function(target) {
|
||||
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"
|
||||
target &&
|
||||
target.getAttribute &&
|
||||
target.getAttribute("aria-hidden") === "false"
|
||||
) {
|
||||
scanNodeForMedia(
|
||||
target,
|
||||
target.parentNode || root.host || mutation.target,
|
||||
target.parentNode || root.host || 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) {
|
||||
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);
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 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);
|
||||
});
|
||||
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
|
||||
);
|
||||
}
|
||||
},
|
||||
{ timeout: 1000 }
|
||||
);
|
||||
});
|
||||
|
||||
observer.observe(root, {
|
||||
attributeFilter: ["aria-hidden", "src", "currentSrc"],
|
||||
attributeFilter: ["aria-hidden"],
|
||||
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 &&
|
||||
(nextMount !== videoController.controllerHostMount ||
|
||||
videoController.div.parentNode !== nextMount ||
|
||||
!videoController.div.isConnected)
|
||||
) {
|
||||
remountControllerHost(videoController, nextMount);
|
||||
return;
|
||||
}
|
||||
if (typeof videoController.controllerHostSchedule === "function") {
|
||||
videoController.controllerHostSchedule();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -4716,23 +5410,133 @@ function attachMediaDetectionListeners(root) {
|
||||
root.vscMediaEventListenersAttached = true;
|
||||
}
|
||||
|
||||
function scheduleNavigationRescan() {
|
||||
function scheduleNavigationRescan(event) {
|
||||
var nextHref = location.href;
|
||||
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;
|
||||
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.
|
||||
tc.lastInteractedMedia = null;
|
||||
}
|
||||
window.vscLastObservedHref = nextHref;
|
||||
window.vscNavigationForceDiscovery =
|
||||
window.vscNavigationForceDiscovery === true || forceYouTubeReconcile;
|
||||
if (!urlChanged && window.vscNavigationRescanTimer) return;
|
||||
clearTimeout(window.vscNavigationRescanTimer);
|
||||
window.vscNavigationRescanTimer = setTimeout(function() {
|
||||
initializeWhenReady(document, true);
|
||||
window.vscNavigationRescanTimer = null;
|
||||
var forceDiscovery = window.vscNavigationForceDiscovery === true;
|
||||
window.vscNavigationForceDiscovery = false;
|
||||
reconcileAfterNavigation(document, forceDiscovery);
|
||||
}, 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;
|
||||
|
||||
@@ -4741,7 +5545,7 @@ function attachNavigationListeners() {
|
||||
var original = history[method];
|
||||
history[method] = function() {
|
||||
var result = original.apply(this, arguments);
|
||||
scheduleNavigationRescan();
|
||||
scheduleNavigationRescan({ type: "history-state" });
|
||||
return result;
|
||||
};
|
||||
});
|
||||
@@ -4753,13 +5557,7 @@ function attachNavigationListeners() {
|
||||
document.addEventListener("yt-navigate-finish", scheduleNavigationRescan);
|
||||
}
|
||||
window.vscLastObservedHref = location.href;
|
||||
if (!window.vscLocationWatchTimer) {
|
||||
window.vscLocationWatchTimer = setInterval(function() {
|
||||
if (window.vscLastObservedHref !== location.href) {
|
||||
scheduleNavigationRescan();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
startLocationWatchFallback();
|
||||
window.vscNavigationListenersAttached = true;
|
||||
}
|
||||
|
||||
@@ -4770,13 +5568,15 @@ function initializeNow(doc, forceReinit = false) {
|
||||
// later whitelisted SPA route can never reactivate the extension.
|
||||
attachNavigationListeners();
|
||||
if (typeof tc.videoController === "undefined") defineVideoController();
|
||||
applySiteRuleOverrides();
|
||||
if (tc.siteRuleAppliedHref !== location.href) 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);
|
||||
});
|
||||
@@ -5195,7 +5995,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
|
||||
lastToggleSpeed[videoId] = currentSpeed;
|
||||
rememberToggleSpeed(videoId, currentSpeed);
|
||||
setSpeed(v, preferredSpeed, false, true);
|
||||
}
|
||||
} else {
|
||||
@@ -5212,7 +6012,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
|
||||
lastToggleSpeed[videoId] = currentSpeed;
|
||||
rememberToggleSpeed(videoId, currentSpeed);
|
||||
v.vsc.resetToggleArmed = true;
|
||||
setSpeed(v, resetSpeedValue, false, true, true);
|
||||
}
|
||||
@@ -5278,6 +6078,8 @@ 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 =
|
||||
@@ -5316,7 +6118,11 @@ 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);
|
||||
@@ -5361,11 +6167,15 @@ function handleFullscreenControllerTransition() {
|
||||
});
|
||||
}
|
||||
|
||||
[
|
||||
"fullscreenchange",
|
||||
"webkitfullscreenchange",
|
||||
"mozfullscreenchange",
|
||||
"MSFullscreenChange"
|
||||
].forEach(function(eventName) {
|
||||
document.addEventListener(eventName, handleFullscreenControllerTransition);
|
||||
});
|
||||
function attachFullscreenListeners(doc) {
|
||||
if (!doc || doc.vscFullscreenListenersAttached) return;
|
||||
[
|
||||
"fullscreenchange",
|
||||
"webkitfullscreenchange",
|
||||
"mozfullscreenchange",
|
||||
"MSFullscreenChange"
|
||||
].forEach(function(eventName) {
|
||||
doc.addEventListener(eventName, handleFullscreenControllerTransition);
|
||||
});
|
||||
doc.vscFullscreenListenersAttached = true;
|
||||
}
|
||||
@@ -1,7 +1,28 @@
|
||||
(function() {
|
||||
"use strict";
|
||||
|
||||
if (window.__speederPageShadowBridgeInstalled) 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;
|
||||
}
|
||||
|
||||
window.__speederPageShadowBridgeInstalled = true;
|
||||
|
||||
@@ -46,4 +67,22 @@
|
||||
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();
|
||||
})();
|
||||
@@ -13,11 +13,17 @@ 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) => {
|
||||
@@ -138,21 +144,81 @@ 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(1);
|
||||
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);
|
||||
});
|
||||
|
||||
it("retries a blocked page bridge with bounded shadow fallback scans", async () => {
|
||||
@@ -330,6 +396,33 @@ 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();
|
||||
@@ -370,6 +463,134 @@ 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();
|
||||
@@ -864,6 +1085,25 @@ 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();
|
||||
@@ -887,6 +1127,36 @@ 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();
|
||||
@@ -932,6 +1202,505 @@ 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({
|
||||
@@ -1127,6 +1896,78 @@ 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 () => {
|
||||
@@ -1176,6 +2017,32 @@ 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