fix: reduce page overhead and preserve media targeting

This commit is contained in:
joshpatra committed 2026-09-03 11:57:39 -04:00
1 parent 7c9259e5ea
commit 9a4ea94dcd
3 files changed
+1942 -226

No files matched your search

+1034 -224
View File
@@ -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;
}
+40 -1
View File
@@ -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();
})();
+868 -1
View File
@@ -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();