Compare commits

...
Author SHA1 Message Date
joshpatra 0beeee950d v6.0.9.0-beta.1 2026-09-03 11:58:32 -04:00
joshpatra 71851de849 chore(release): bump version to 6.0.9.0 2026-09-03 11:58:29 -04:00
joshpatra 9a4ea94dcd fix: reduce page overhead and preserve media targeting 2026-09-03 11:57:39 -04:00
joshpatra d5d697b7ea v6.0.8.2-beta.1 2026-08-20 01:39:53 -04:00
joshpatra 7c9259e5ea chore(release): bump version to 6.0.8.2 2026-08-20 01:39:51 -04:00
joshpatra 0872b66686 chore: refresh release and test tooling 2026-08-20 01:39:21 -04:00
joshpatra eede171ad9 fix(settings): recognize all managed raw backups 2026-08-20 01:39:10 -04:00
joshpatra 15673b0a38 fix(icons): harden custom SVG sanitization 2026-08-20 01:39:06 -04:00
joshpatra 06b23b3a53 fix(shortcuts): avoid layout work during key handling 2026-08-20 01:38:58 -04:00
joshpatra 807bd0a29d chore(release): bump version to 6.0.8.1 2026-08-19 21:50:46 -04:00
joshpatra 6d17f15bc0 fix(controller): preserve overlays in Firefox fullscreen 2026-08-19 21:47:29 -04:00
joshpatra 8073014bce v6.0.8.0-beta.1 2026-08-02 23:35:19 -04:00
joshpatra f8cfbf05c8 chore(release): bump version to 6.0.8.0 2026-08-02 23:34:34 -04:00
joshpatra b4234e0387 feat(options): add diagnostics copy action 2026-08-02 22:23:08 -04:00
joshpatra d55006dd0a feat(popup): pause Speeder per tab 2026-08-02 21:18:39 -04:00
joshpatra 745af2f1b9 feat(popup): add rule for current site 2026-08-02 21:03:39 -04:00
joshpatra 7e9ce436d3 feat(popup): copy privacy-safe diagnostics 2026-08-02 21:02:08 -04:00
joshpatra 7e3054f6fa v6.0.7.0-beta.1 2026-08-02 10:09:10 -04:00
joshpatra 98546adf41 Bump version to 6.0.7.0 2026-08-02 10:09:09 -04:00
joshpatra daeff63d2a fix(controller): avoid popovers over fullscreen players 2026-08-02 10:07:32 -04:00
joshpatra 80aa00670c chore(release): prepare v6.0.6.0 beta.2 2026-08-02 00:08:01 -04:00
joshpatra aa4f1d15e7 test: make export dates timezone independent 2026-08-02 00:07:40 -04:00
34 changed files with 3023 additions and 620 deletions

No files matched your search

+4 -4
View File
@@ -12,9 +12,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: actions/setup-node@v4
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
@@ -26,7 +26,7 @@ jobs:
run: npm test
- name: Install web-ext
run: npm install -g web-ext
run: npm install -g web-ext@10.6.0
- name: Lint
run: web-ext lint --source-dir extension
@@ -54,7 +54,7 @@ jobs:
- name: Create GitHub Prerelease (beta)
if: contains(github.ref_name, '-beta')
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
-34
View File
@@ -1,34 +0,0 @@
# Implementation Plan
Constraints: local commits only; no push; no browser testing; one commit per feature.
## Controller and targeting
- [x] Keep the controller visible for its own video in element and ancestor fullscreen.
- [x] Target popup actions at the frame represented by the displayed speed; keep “all videos” intentional.
- [x] Ignore shortcuts originating from editable controls, including shadow-DOM inputs.
## Accessibility and usability
- [x] Give in-player controls accessible names, keyboard behavior, and visible focus.
- [x] Make control-bar customization operable by keyboard as well as drag and drop.
- [x] Label generated shortcut and site-rule form controls.
- [x] Improve popup status announcements, focus indicators, and icon-search semantics.
## Settings safety and validation
- [x] Confirm before Restore Defaults removes preferences and remembered data.
- [x] Report partial imports accurately when custom icons cannot be restored.
- [x] Reject malformed slash-prefixed regular expressions before saving site rules.
## Extension lifecycle and copy
- [x] Initialize and synchronize the disabled toolbar icon from background state.
- [x] Correct shortcut, subtitle-nudge, live-update, and obsolete troubleshooting copy.
- [x] Run automated tests in the release workflow before packaging.
## Verification
- [x] Run focused automated checks after each non-trivial change.
- [x] Run the complete non-browser test suite and review the final local commit series.
- [x] Leave cross-site fullscreen visual verification for reporter/user validation.
+1 -1
View File
@@ -72,7 +72,7 @@ The unpacked extension root is `extension/`. Load that directory in
```sh
npm test
npx --yes web-ext lint --source-dir extension
npx --yes web-ext@10.6.0 lint --source-dir extension
```
## FAQ
+42 -1
View File
@@ -9,6 +9,15 @@ function setToolbarIcon(enabled) {
});
}
var pausedTabIds = new Set();
function getMessageTabId(request, sender) {
if (request && Number.isInteger(request.tabId)) return request.tabId;
return sender && sender.tab && Number.isInteger(sender.tab.id)
? sender.tab.id
: null;
}
chrome.storage.sync.get(["enabled"], function(storage) {
if (!chrome.runtime.lastError) setToolbarIcon(storage.enabled !== false);
});
@@ -19,8 +28,40 @@ chrome.storage.onChanged.addListener(function(changes, areaName) {
}
});
chrome.runtime.onMessage.addListener(function (request) {
chrome.runtime.onMessage.addListener(function (request, sender, sendResponse) {
if (request.action === "openOptions") {
chrome.tabs.create({ url: chrome.runtime.getURL("options/options.html") });
return false;
}
if (request.action === "get_tab_pause_state") {
var queriedTabId = getMessageTabId(request, sender);
sendResponse({
paused: queriedTabId !== null && pausedTabIds.has(queriedTabId)
});
return false;
}
if (request.action === "set_tab_paused") {
var tabId = getMessageTabId(request, sender);
if (tabId === null) {
sendResponse({ paused: false });
return false;
}
var paused = request.paused === true;
if (paused) pausedTabIds.add(tabId);
else pausedTabIds.delete(tabId);
chrome.tabs.sendMessage(
tabId,
{ action: "set_tab_paused", paused: paused },
function () {
void chrome.runtime.lastError;
}
);
sendResponse({ paused: paused });
return false;
}
return false;
});
chrome.tabs.onRemoved.addListener(function(tabId) {
pausedTabIds.delete(tabId);
});
@@ -12,6 +12,10 @@
typeof tc === "object" && typeof tc.frameToken === "string"
? tc.frameToken
: null,
diagnostics:
typeof getDiagnosticsSnapshot === "function"
? getDiagnosticsSnapshot(v)
: null,
preferred: !v.paused,
forceLastSavedSpeed: Boolean(
typeof tc === "object" && tc.settings && tc.settings.forceLastSavedSpeed
-5
View File
@@ -1,10 +1,6 @@
/* Base styles for the controller wrapper (the shadow host) */
.vsc-controller {
position: absolute !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
pointer-events: none !important;
/* Keep the interactive controller above player-owned click/pause panes. */
z-index: 2147483647 !important;
@@ -17,7 +13,6 @@
controller in the browser top layer without nesting it inside <video>. */
.vsc-controller.vsc-fullscreen-popover {
position: fixed !important;
inset: auto !important;
margin: 0 !important;
padding: 0 !important;
border: 0 !important;
+1208 -290
View File
@@ -1,4 +1,3 @@
var isUserSeek = false; // Track if seek was user-initiated
var lastToggleSpeed = {}; // Store last toggle speeds per video
var speederShared =
typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {};
@@ -28,6 +27,65 @@ function getSharedDefault(key, fallback) {
return fallback;
}
function getViewportScrollEpoch(win) {
return Number(win && win.vscViewportScrollEpoch) || 0;
}
function getViewportScrollX(win) {
var scrollX = Number(win && win.scrollX);
if (Number.isFinite(scrollX)) return scrollX;
return Number(win && win.pageXOffset) || 0;
}
function getViewportScrollY(win) {
var scrollY = Number(win && win.scrollY);
if (Number.isFinite(scrollY)) return scrollY;
return Number(win && win.pageYOffset) || 0;
}
function cacheVideoRect(wrapper, video, rect) {
if (!wrapper || !rect) return null;
var win =
video && video.ownerDocument ? video.ownerDocument.defaultView : window;
wrapper.vscVideoRect = {
left: Number(rect.left) || 0,
top: Number(rect.top) || 0,
right: Number(rect.right) || 0,
bottom: Number(rect.bottom) || 0,
width: Number(rect.width) || 0,
height: Number(rect.height) || 0,
viewportScrollEpoch: getViewportScrollEpoch(win),
viewportScrollX: getViewportScrollX(win),
viewportScrollY: getViewportScrollY(win)
};
return wrapper.vscVideoRect;
}
function getCachedVideoRect(video) {
var wrapper = video && video.vsc && video.vsc.div;
var rect = (wrapper && wrapper.vscVideoRect) || null;
if (!rect || !video) return rect;
var win = video.ownerDocument && video.ownerDocument.defaultView;
var currentScrollEpoch = getViewportScrollEpoch(win);
if (
rect.viewportScrollEpoch === currentScrollEpoch &&
rect.viewportScrollX === getViewportScrollX(win) &&
rect.viewportScrollY === getViewportScrollY(win)
) {
return rect;
}
// Scrolling changes getBoundingClientRect() without changing player layout.
// Refresh lazily on the next action so targeting stays exact for normal,
// fixed, sticky, and nested-scroll players without layout work per scroll.
try {
return cacheVideoRect(wrapper, video, video.getBoundingClientRect());
} catch (_error) {
return rect;
}
}
function getPrimaryVideoElement(mediaElements) {
var candidates = Array.isArray(mediaElements)
? mediaElements
@@ -39,10 +97,13 @@ function getPrimaryVideoElement(mediaElements) {
candidates.forEach(function(el, index) {
if (!el || !el.vsc || !el.isConnected) return;
var rect = null;
try {
rect = el.getBoundingClientRect();
} catch (_error) {}
var rect = getCachedVideoRect(el);
var hasCachedRect = Boolean(rect);
if (!rect) {
try {
rect = el.getBoundingClientRect();
} catch (_error) {}
}
var width = rect && Number(rect.width) > 0 ? Number(rect.width) : 0;
var height = rect && Number(rect.height) > 0 ? Number(rect.height) : 0;
@@ -57,17 +118,24 @@ function getPrimaryVideoElement(mediaElements) {
: 0;
var visibleArea = visibleWidth * visibleHeight;
var visuallyAvailable = visibleArea > 0;
try {
var computed = win && win.getComputedStyle(el);
if (
computed &&
(computed.display === "none" ||
computed.visibility === "hidden" ||
Number(computed.opacity) === 0)
) {
visuallyAvailable = false;
}
} catch (_error) {}
if (
el.vsc.div &&
el.vsc.div.classList.contains("vsc-geometry-hidden")
) {
visuallyAvailable = false;
} else if (!hasCachedRect) {
try {
var computed = win && win.getComputedStyle(el);
if (
computed &&
(computed.display === "none" ||
computed.visibility === "hidden" ||
Number(computed.opacity) === 0)
) {
visuallyAvailable = false;
}
} catch (_error) {}
}
var score = visibleArea;
if (visuallyAvailable) score += 1e12;
@@ -90,6 +158,81 @@ function getPrimaryVideoElement(mediaElements) {
return best;
}
function getDiagnosticsSnapshot(media) {
var primary = media || getPrimaryVideoElement();
if (!primary) return null;
var controller = primary.vsc || null;
var wrapper = controller && controller.div;
var fullscreenElement = getFullscreenElement(primary.ownerDocument);
var rect = null;
try {
rect = primary.getBoundingClientRect();
} catch (_error) {}
return {
mediaCount: tc.mediaElements.filter(function(item) {
return item && item.isConnected;
}).length,
mediaType: String(primary.nodeName || "media").toLowerCase(),
playbackRate: Number(primary.playbackRate),
paused: primary.paused === true,
ended: primary.ended === true,
readyState: Number(primary.readyState) || 0,
muted: primary.muted === true,
volume: Number(primary.volume),
dimensions: rect
? [Math.round(Number(rect.width) || 0), Math.round(Number(rect.height) || 0)]
: [0, 0],
fullscreen: {
active: Boolean(fullscreenElement),
element: fullscreenElement
? String(fullscreenElement.nodeName || "element").toLowerCase()
: null,
ownsMedia: Boolean(
fullscreenElement &&
(fullscreenElement === primary ||
isComposedDescendant(primary, fullscreenElement))
)
},
controller: {
present: Boolean(controller && wrapper),
connected: Boolean(wrapper && wrapper.isConnected),
hidden: Boolean(wrapper && wrapper.classList.contains("vsc-hidden")),
geometryHidden: Boolean(
wrapper && wrapper.classList.contains("vsc-geometry-hidden")
),
fullscreenPopover: Boolean(
wrapper && wrapper.classList.contains("vsc-fullscreen-popover")
),
location: controller ? controller.controllerLocation : null
},
effectiveSettings: {
enabled: tc.settings.enabled !== false,
tabPaused: tc.tabPaused === true,
startHidden: tc.settings.startHidden === true,
hideWithControls: tc.settings.hideWithControls === true,
rememberSpeed: tc.settings.rememberSpeed === true,
forceLastSavedSpeed: tc.settings.forceLastSavedSpeed === true,
shortcutTargetMode: tc.settings.shortcutTargetMode
},
siteRule: {
matched: Boolean(tc.activeSiteRule),
disabled: Boolean(
tc.activeSiteRule &&
siteRuleUtils.isSiteRuleDisabled(tc.activeSiteRule)
),
overrideKeys: tc.activeSiteRule
? Object.keys(tc.activeSiteRule)
.filter(function(key) {
return key !== "pattern" && key !== "title";
})
.sort()
: []
}
};
}
var tc = {
settings: {
lastSpeed: getSharedDefault("lastSpeed", 1.0),
@@ -151,9 +294,13 @@ var tc = {
speedAccessTimes: {},
persistedLastSpeed: 1.0,
activeSiteRule: null,
tabPaused: false,
siteRuleBase: null,
siteRuleAppliedHref: null,
runtimeSettingsHydrated: false,
rawSyncSettingsSnapshot: null,
pendingMediaCandidates: [],
pendingMediaCandidateNodes: new WeakSet(),
settingsReloadRetries: 0,
lastPointerPosition: null,
lastInteractedMedia: null,
@@ -163,6 +310,30 @@ var tc = {
: String(Date.now()) + "-" + Math.random().toString(36).slice(2)
};
function isSpeederActiveForCurrentPage() {
return (
tc.tabPaused !== true &&
siteRuleUtils.isSpeederActiveForSite(
tc.settings.enabled,
tc.activeSiteRule
)
);
}
function applyTabPausedState(paused) {
tc.tabPaused = paused === true;
if (!tc.runtimeSettingsHydrated) return;
if (tc.tabPaused) {
clearAllSpeedRestoreEnforcement();
clearDeferredMediaCandidates();
tc.mediaElements.slice().forEach(function(media) {
removeController(media);
});
return;
}
initializeWhenReady(document, true);
}
var MIN_SPEED = Number(keyBindingUtils.MIN_SPEED) || 0.1;
var MAX_SPEED = Number(keyBindingUtils.MAX_SPEED) || 16;
var YT_NATIVE_MIN = 0.25;
@@ -170,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)
@@ -492,6 +668,7 @@ function applyControllerLocation(videoController, location) {
}
function captureSiteRuleBase() {
tc.siteRuleAppliedHref = null;
tc.siteRuleBase = {
startHidden: tc.settings.startHidden,
hideWithControls: tc.settings.hideWithControls,
@@ -981,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 &&
@@ -1387,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;
@@ -1477,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;
@@ -1502,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;
@@ -1513,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 (!siteRuleUtils.isSpeederActiveForSite(tc.settings.enabled, tc.activeSiteRule)) {
// 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;
}
@@ -1537,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) {
@@ -1563,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
@@ -1570,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
@@ -1584,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 (
@@ -1604,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,
@@ -1622,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 {
@@ -1632,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();
}
@@ -1655,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)) {
@@ -1696,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) {
@@ -1747,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 (
@@ -1830,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;
@@ -1850,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() {
@@ -1867,7 +2348,7 @@ function installPageShadowBridge() {
function() {
if (
!document.body ||
(delay !== 3000 && window.vscPageShadowBridgeLoaded)
window.vscPageShadowBridgeLoaded
) {
return;
}
@@ -1909,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();
@@ -1950,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 || {})
@@ -2261,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) {
@@ -2275,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 || {}, {
@@ -2297,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;
@@ -2317,19 +2875,32 @@ 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));
return;
}
hydrateRuntimeSettings(rawStorage || {});
if (chrome.runtime && typeof chrome.runtime.sendMessage === "function") {
chrome.runtime.sendMessage({ action: "get_tab_pause_state" }, function(response) {
if (!chrome.runtime.lastError && response) {
applyTabPausedState(response.paused === true);
}
});
}
// patchAttachShadow() is now called at top-level before this callback
// Add a listener for messages from the popup.
// We use a global flag to ensure the listener is only attached once.
if (!window.vscMessageListener) {
chrome.runtime.onMessage.addListener(
function(request, sender, sendResponse) {
if (request.action === "set_tab_paused") {
applyTabPausedState(request.paused === true);
sendResponse({ paused: tc.tabPaused });
return false;
}
if (request.action === "rescan_page") {
log("Re-scan command received from popup.", 4);
initializeWhenReady(document, true);
@@ -2344,6 +2915,7 @@ function loadInitialRuntimeSettings(attempt) {
sendResponse({
speed: videoGs.playbackRate,
frameToken: tc.frameToken,
diagnostics: getDiagnosticsSnapshot(videoGs),
forceLastSavedSpeed: tc.settings.forceLastSavedSpeed === true,
forceLastSavedSpeedControlledBySiteRule: Boolean(
tc.activeSiteRule &&
@@ -2387,10 +2959,7 @@ function loadInitialRuntimeSettings(attempt) {
return false;
}
if (
!siteRuleUtils.isSpeederActiveForSite(
tc.settings.enabled,
tc.activeSiteRule
)
!isSpeederActiveForCurrentPage()
) {
return false;
}
@@ -2413,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(
@@ -2431,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)
@@ -2533,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);
});
@@ -2584,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") {
@@ -2785,8 +3375,6 @@ function getControllerMount(video, boundary) {
return isShadowRootNode(directRoot) ? directRoot : null;
}
var mountBoundary = null;
var videoRect = video.getBoundingClientRect();
var mount = video.parentElement;
var candidate = mount;
@@ -2804,15 +3392,7 @@ function getControllerMount(video, boundary) {
// Climb through tightly-sized wrappers so our host shares their stacking
// context, but stop before broad page-layout containers.
while (candidate && candidate.parentElement && depth < 5) {
if (mountBoundary && candidate === mountBoundary) break;
var next = candidate.parentElement;
if (
mountBoundary &&
next !== mountBoundary &&
!mountBoundary.contains(next)
) {
break;
}
var nextRect = next.getBoundingClientRect();
var widthLimit = Math.max(videoRect.width * 1.35, videoRect.width + 80);
var heightLimit = Math.max(videoRect.height * 1.35, videoRect.height + 80);
@@ -2836,10 +3416,6 @@ function getControllerMount(video, boundary) {
candidate = next;
depth += 1;
// In fullscreen, the wrapper must remain inside the exact subtree the
// browser promotes to its top layer.
if (mountBoundary && next === mountBoundary) break;
// Never climb out of a player-owned stacking context. Doing so lets the
// controller's high local z-index escape above sticky page headers.
if (createsControllerStackingContext(next)) break;
@@ -2867,6 +3443,7 @@ function positionControllerHost(wrapper, video, mount) {
return;
}
var videoRect = video.getBoundingClientRect();
cacheVideoRect(wrapper, video, videoRect);
if (wrapper.classList.contains("vsc-fullscreen-popover")) {
if (videoRect.width <= 0 || videoRect.height <= 0) {
wrapper.classList.add("vsc-geometry-hidden");
@@ -2961,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;
@@ -3035,25 +3663,41 @@ function setupControllerHostTracking(videoController, wrapper, mount) {
resizeObserver.observe(geometryMount);
}
win.addEventListener("resize", schedule, { passive: true });
doc.addEventListener("fullscreenchange", schedule, { passive: true });
var mediaGeometryEvents = ["loadedmetadata", "play", "playing"];
mediaGeometryEvents.forEach(function(eventName) {
videoController.video.addEventListener(eventName, 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);
win.removeEventListener("resize", schedule);
doc.removeEventListener("fullscreenchange", schedule);
mediaGeometryEvents.forEach(function(eventName) {
videoController.video.removeEventListener(eventName, 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 (
@@ -3126,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;
}
@@ -3168,14 +3829,14 @@ function disableDirectFullscreenPopover(videoController) {
wrapper.removeAttribute("popover");
}
function enableFullscreenPopover(videoController, preferredMount) {
function enableDirectFullscreenPopover(videoController) {
if (!videoController || !videoController.video || !videoController.div) {
return false;
}
var wrapper = videoController.div;
if (typeof wrapper.showPopover !== "function") return false;
var normalMount = preferredMount || videoController.normalControllerMount;
var normalMount = videoController.normalControllerMount;
var normalMountIsConnected = Boolean(
normalMount &&
(normalMount.isConnected ||
@@ -3220,8 +3881,15 @@ function syncControllerFullscreenMount(videoController) {
(fullscreenElement === video ||
isComposedDescendant(video, fullscreenElement))
);
var normalGeometryMount = getControllerGeometryMount(targetMount);
var normalMountIsAlreadyFullscreenVisible = Boolean(
fullscreenElement &&
fullscreenElement !== video &&
normalGeometryMount &&
isComposedDescendant(normalGeometryMount, fullscreenElement)
);
if (ownsFullscreen) {
if (ownsFullscreen && !normalMountIsAlreadyFullscreenVisible) {
targetMount = getControllerMount(video, fullscreenElement);
} else if (!fullscreenElement && (!targetMount || !targetMount.isConnected)) {
targetMount = getControllerMount(video);
@@ -3230,13 +3898,10 @@ function syncControllerFullscreenMount(videoController) {
if (!targetMount) return false;
if (ownsFullscreen) {
// Fullscreen elements and popovers both participate in the browser's top
// layer. Showing Speeder's host after the player enters fullscreen keeps it
// above provider-owned surfaces even when the provider clips descendants or
// creates a new fullscreen stacking context (notably Firefox + YouTube).
// Browsers without the Popover API retain the player-local remount fallback.
if (enableFullscreenPopover(videoController, targetMount)) return true;
if (fullscreenElement === video) {
// A replaced <video> cannot paint author children, so direct-media
// fullscreen is the only case that needs a separate top-layer popover.
if (enableDirectFullscreenPopover(videoController)) return true;
} else {
disableDirectFullscreenPopover(videoController);
}
@@ -3262,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);
@@ -3378,9 +4046,6 @@ function defineVideoController() {
setSpeed(event.target, expectedSpeed, false, false);
}
if (isUserSeek) {
isUserSeek = false;
}
}
};
@@ -3427,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);
}
@@ -3451,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();
};
@@ -3459,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 &&
@@ -3483,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;
@@ -3774,11 +4468,6 @@ function defineVideoController() {
timer = setTimeout(() => {
timer = null;
if (this.controllerInteractionActive) return;
// Only hide if the video is not paused
// (Many players keep controls visible while paused)
// However, the user said "Reveal on every mouse and keyboard input"
// and "auto-hidden after timespan".
// We'll follow the timer strictly.
wrapper.classList.add("vsc-idle-hidden");
log("Generic hide: controller hidden due to inactivity", 5);
}, tc.settings.hideWithControlsTimer * 1000);
@@ -3801,8 +4490,8 @@ function defineVideoController() {
// Initial show/timer
resetTimer();
// The wrapper covers the player area on most sites due to inject.css styles,
// but we listen on both the video and the wrapper for maximum coverage.
// Players dispatch activity at different layers, so observe the media,
// aligned controller host, and player mount.
const activityEvents = ["mousemove", "mousedown", "keydown", "touchstart"];
const parentEl =
getControllerGeometryMount(this.controllerHostMount) ||
@@ -3844,6 +4533,10 @@ 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";
if (!hasUsableMediaSource(this.video))
wrapper.classList.add("vsc-nosource");
if (tc.settings.startHidden) wrapper.classList.add("vsc-hidden");
@@ -4041,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)
@@ -4061,7 +4756,6 @@ function applySiteRuleOverrides() {
return finishSiteRuleApplication(false);
}
var currentUrl = location.href;
var matchedRule = siteRuleUtils.matchSiteRule(currentUrl, tc.settings.siteRules);
if (!matchedRule) {
@@ -4137,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);
}
});
}
@@ -4160,10 +4856,14 @@ 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 (!siteRuleUtils.isSpeederActiveForSite(tc.settings.enabled, tc.activeSiteRule)) {
if (!isSpeederActiveForCurrentPage()) {
tc.mediaElements.slice().forEach(function(video) {
removeController(video);
});
@@ -4406,6 +5106,7 @@ function attachKeydownListeners(doc) {
keyTarget.addEventListener(
"keydown",
function(event) {
if (!tc.runtimeSettingsHydrated || !tc.mediaElements.length) return;
if (
!event.getModifierState ||
event.getModifierState("Alt") ||
@@ -4421,16 +5122,11 @@ function attachKeydownListeners(doc) {
if (isEditableShortcutTarget(event)) return;
if (
!siteRuleUtils.isSpeederActiveForSite(
tc.settings.enabled,
tc.activeSiteRule
)
!isSpeederActiveForCurrentPage()
) {
return;
}
if (!tc.mediaElements.length) return;
var item = tc.settings.keyBindings.find(function(binding) {
return matchesKeyBinding(binding, event);
});
@@ -4461,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 }
);
@@ -4475,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);
};
@@ -4594,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;
@@ -4619,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;
};
});
@@ -4631,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;
}
@@ -4648,16 +5568,15 @@ function initializeNow(doc, forceReinit = false) {
// later whitelisted SPA route can never reactivate the extension.
attachNavigationListeners();
if (typeof tc.videoController === "undefined") defineVideoController();
applySiteRuleOverrides();
var isActive = siteRuleUtils.isSpeederActiveForSite(
tc.settings.enabled,
tc.activeSiteRule
);
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);
});
@@ -4804,16 +5723,7 @@ function getClosestMediaToPointer(candidates, pointerPosition) {
candidates.forEach(function(video) {
if (!video || !video.vsc || !video.isConnected) return;
var target = getControllerElement(video.vsc) || video;
var rect = null;
try {
rect = target.getBoundingClientRect();
if (!rect || rect.width <= 0 || rect.height <= 0) {
rect = video.getBoundingClientRect();
}
} catch (_error) {
return;
}
var rect = getCachedVideoRect(video);
if (!rect || rect.width <= 0 || rect.height <= 0) return;
var distance = distanceSquaredToRect(
pointerPosition.x,
@@ -4942,12 +5852,10 @@ function runAction(action, value, e) {
);
switch (action) {
case "rewind":
isUserSeek = true;
extendSpeedRestoreWindow(v);
v.currentTime -= numValue;
break;
case "advance":
isUserSeek = true;
extendSpeedRestoreWindow(v);
v.currentTime += numValue;
break;
@@ -5087,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 {
@@ -5104,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);
}
@@ -5170,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 =
@@ -5208,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);
@@ -5253,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();
})();
-5
View File
@@ -1,9 +1,5 @@
:host {
position: absolute !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
pointer-events: none !important;
z-index: 2147483647 !important;
white-space: normal;
@@ -15,7 +11,6 @@
base absolute host rule. */
:host(.vsc-fullscreen-popover) {
position: fixed !important;
inset: auto !important;
margin: 0 !important;
padding: 0 !important;
border: 0 !important;
+1 -2
View File
@@ -1,7 +1,7 @@
{
"name": "Speeder",
"short_name": "Speeder",
"version": "6.0.6.0",
"version": "6.0.9.0",
"manifest_version": 2,
"description": "Speed up, slow down, advance and rewind HTML5 audio/video with shortcuts (New and improved version of \"Video Speed Controller\")",
"homepage_url": "https://github.com/SoPat712/speeder",
@@ -71,7 +71,6 @@
}
],
"web_accessible_resources": [
"content/inject.css",
"content/shadow-bridge.js",
"content/shadow.css"
]
+1
View File
@@ -1029,6 +1029,7 @@
<button id="restore">Restore Defaults</button>
<button id="exportSettings">Export Settings</button>
<button id="importSettings">Import Settings</button>
<button id="copyDiagnostics">Copy Diagnostics</button>
</div>
<div id="status" role="status" aria-live="polite"></div>
+35
View File
@@ -2104,6 +2104,41 @@ document.addEventListener("DOMContentLoaded", function () {
.addEventListener("change", updatePopupEditorDisabledState);
document.getElementById("save").addEventListener("click", save_options);
document.getElementById("copyDiagnostics").addEventListener("click", function () {
var status = document.getElementById("status");
if (
!navigator.clipboard ||
typeof navigator.clipboard.writeText !== "function"
) {
status.textContent = "Clipboard access is unavailable.";
return;
}
var report = popupControlUtils.buildDiagnosticReport({
speederVersion: manifest.version,
browser: navigator.userAgent,
platform: navigator.platform || null,
storage: {
enabled: document.getElementById("enabled").checked,
rememberSpeed: document.getElementById("rememberSpeed").checked,
forceLastSavedSpeed:
document.getElementById("forceLastSavedSpeed").checked,
audioBoolean: document.getElementById("audioBoolean").checked,
startHidden: document.getElementById("startHidden").checked,
hideWithControls: document.getElementById("hideWithControls").checked,
controllerLocation: document.getElementById("controllerLocation").value,
shortcutTargetMode:
document.getElementById("shortcutTargetMode").value
}
});
navigator.clipboard.writeText(report).then(
function() {
status.textContent = "Diagnostics copied. Review before sharing.";
},
function() {
status.textContent = "Could not copy diagnostics.";
}
);
});
const addSelector = document.getElementById("addShortcutSelector");
if (addSelector) {
+13 -2
View File
@@ -86,8 +86,13 @@ button:active {
button:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
outline-offset: 2px;
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
#refresh {
background: var(--accent);
@@ -113,6 +118,12 @@ button:focus-visible {
border-color: #9fbd98;
color: #285d21;
}
#pauseTab[aria-pressed="true"] {
background: #fff3d6;
border-color: #d8b66b;
color: #704f0d;
}
.popup-divider {
height: 1px;
+8
View File
@@ -24,6 +24,12 @@
type="button"
aria-pressed="false"
>Force last saved speed</button>
<button
id="pauseTab"
class="popup-compact-action"
type="button"
aria-pressed="false"
>Pause on this tab</button>
<div class="popup-divider"></div>
<div id="popupControlBar" class="popup-control-bar">
<span id="popupSpeed" class="popup-speed">1.00</span>
@@ -41,6 +47,8 @@
<div class="popup-links">
<button id="config">Settings</button>
<div class="popup-secondary">
<button id="addSiteRule" class="secondary">Add current site</button>
<button id="copyDiagnostics" class="secondary">Copy diagnostics</button>
<button id="feedback" class="secondary">Feedback</button>
<button id="about" class="secondary">About</button>
</div>
+190 -5
View File
@@ -1,4 +1,6 @@
document.addEventListener("DOMContentLoaded", function () {
if (window.vscPopupInitialized) return;
window.vscPopupInitialized = true;
var speederShared =
typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {};
var siteRuleUtils = speederShared.siteRules || {};
@@ -29,6 +31,61 @@ document.addEventListener("DOMContentLoaded", function () {
var forceLastSavedSpeedControlledBySiteRule = null;
var selectedFrameToken = null;
var shortcutTargetMode = "closest";
var diagnosticContext = null;
function updateTabPauseButton(paused) {
var button = document.querySelector("#pauseTab");
button.setAttribute("aria-pressed", paused ? "true" : "false");
button.textContent = paused ? "Resume on this tab" : "Pause on this tab";
}
function getTabPauseState(tab, callback) {
if (!tab || !Number.isInteger(tab.id)) {
callback(false);
return;
}
chrome.runtime.sendMessage(
{ action: "get_tab_pause_state", tabId: tab.id },
function(response) {
callback(
!chrome.runtime.lastError && response && response.paused === true
);
}
);
}
function getCurrentSiteRuleDetails(url) {
try {
var parsed = new URL(url);
if (
(parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
!parsed.host
) {
return null;
}
return {
title: parsed.host,
pattern: parsed.protocol + "//" + parsed.host
};
} catch (_error) {
return null;
}
}
function buildDiagnosticReport() {
if (!diagnosticContext) return null;
return popupControlUtils.buildDiagnosticReport({
speederVersion: chrome.runtime.getManifest().version,
browser: navigator.userAgent,
platform: navigator.platform || null,
url: diagnosticContext.url,
storage: diagnosticContext.storage,
siteRule: diagnosticContext.siteRule,
siteRuleDisabled: isSiteRuleDisabled(diagnosticContext.siteRule),
tabPaused: diagnosticContext.tabPaused,
frame: diagnosticContext.frame
});
}
function persistExpandedSettings(rawStorage, settings, callback) {
var mutation = vscBuildManagedStorageMutation(rawStorage, settings);
@@ -125,16 +182,23 @@ document.addEventListener("DOMContentLoaded", function () {
}
function getActiveTabContext(callback) {
function finish(context) {
getTabPauseState(context.tab, function(paused) {
context.tabPaused = paused;
if (callback) callback(context);
});
}
chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
var activeTab = tabs && tabs[0] ? tabs[0] : null;
if (!activeTab || !activeTab.id) {
if (callback) callback({ tab: null, url: "" });
finish({ tab: null, url: "" });
return;
}
var tabUrl = typeof activeTab.url === "string" ? activeTab.url : "";
if (tabUrl.length > 0) {
if (callback) callback({ tab: activeTab, url: tabUrl });
finish({ tab: activeTab, url: tabUrl });
return;
}
@@ -143,13 +207,13 @@ document.addEventListener("DOMContentLoaded", function () {
{ action: "get_page_context" },
function (response) {
if (chrome.runtime.lastError) {
if (callback) callback({ tab: activeTab, url: "" });
finish({ tab: activeTab, url: "" });
return;
}
var pageUrl =
response && typeof response.url === "string" ? response.url : "";
if (callback) callback({ tab: activeTab, url: pageUrl });
finish({ tab: activeTab, url: pageUrl });
}
);
});
@@ -288,6 +352,104 @@ document.addEventListener("DOMContentLoaded", function () {
window.open("https://github.com/SoPat712/Speeder/issues");
});
document.querySelector("#copyDiagnostics").addEventListener("click", function () {
var report = buildDiagnosticReport();
if (!report) {
setStatusMessage("Diagnostics are still loading.");
return;
}
if (!navigator.clipboard || typeof navigator.clipboard.writeText !== "function") {
setStatusMessage("Clipboard access is unavailable.");
return;
}
navigator.clipboard.writeText(report).then(
function () {
setStatusMessage("Diagnostics copied. Review before sharing.");
},
function () {
setStatusMessage("Could not copy diagnostics.");
}
);
});
document.querySelector("#addSiteRule").addEventListener("click", function () {
var details = diagnosticContext
? getCurrentSiteRuleDetails(diagnosticContext.url)
: null;
if (!details) {
setStatusMessage("A site rule cannot be created for this page.");
return;
}
var added = false;
updateStoredSettings(
function (settings) {
var rules = Array.isArray(settings.siteRules) ? settings.siteRules : [];
if (
rules.some(function(rule) {
return rule && rule.pattern === details.pattern;
})
) {
return;
}
settings.siteRules = rules.concat([
{
title: details.title,
pattern: details.pattern,
enabled: true
}
]);
added = true;
},
function (error) {
if (error) {
setStatusMessage("Could not add this site: " + error.message);
return;
}
setStatusMessage(
added
? "Site rule added. Opening settings..."
: "Site rule already exists. Opening settings..."
);
window.open(chrome.runtime.getURL("options/options.html"));
}
);
});
document.querySelector("#pauseTab").addEventListener("click", function () {
var tab = diagnosticContext && diagnosticContext.tab;
if (!tab || !Number.isInteger(tab.id)) {
setStatusMessage("This page cannot be paused.");
return;
}
var button = this;
var paused = button.getAttribute("aria-pressed") !== "true";
button.disabled = true;
chrome.runtime.sendMessage(
{ action: "set_tab_paused", tabId: tab.id, paused: paused },
function(response) {
button.disabled = false;
if (chrome.runtime.lastError || !response) {
setStatusMessage("Could not update this tab.");
return;
}
diagnosticContext.tabPaused = response.paused === true;
updateTabPauseButton(diagnosticContext.tabPaused);
setControlBarVisible(
!diagnosticContext.tabPaused &&
diagnosticContext.siteAvailable &&
diagnosticContext.showBar
);
setForceButtonLoading(diagnosticContext.tabPaused);
setStatusMessage(
diagnosticContext.tabPaused
? "Speeder is paused for this tab."
: "Speeder resumed for this tab."
);
}
);
});
document.querySelector("#donate").addEventListener("click", function () {
this.classList.add("hide");
document.querySelector("#donateOptions").classList.remove("hide");
@@ -400,6 +562,9 @@ document.addEventListener("DOMContentLoaded", function () {
getActiveTabContext(function (context) {
if (currentRenderToken !== renderToken) return;
var tabPaused = context && context.tabPaused === true;
updateTabPauseButton(tabPaused);
var url = context && context.url ? context.url : "";
var siteRule = matchSiteRule(url, storage.siteRules);
var siteDisabled = isSiteRuleDisabled(siteRule);
@@ -419,9 +584,22 @@ document.addEventListener("DOMContentLoaded", function () {
forceLastSavedSpeedControlledBySiteRule
? siteRule.forceLastSavedSpeed === true
: storage.forceLastSavedSpeed === true;
diagnosticContext = {
tab: context && context.tab,
url: url,
storage: storage,
siteRule: siteRule,
frame: null,
tabPaused: tabPaused,
siteAvailable: siteAvailable,
showBar: showBar
};
document.querySelector("#addSiteRule").disabled =
!getCurrentSiteRuleDetails(url);
if (siteRule && siteRule.showPopupControlBar !== undefined) {
showBar = siteRule.showPopupControlBar;
diagnosticContext.showBar = showBar;
}
toggleEnabledUI(storage.enabled !== false);
@@ -430,7 +608,13 @@ document.addEventListener("DOMContentLoaded", function () {
resolvePopupButtons(storage, siteRule),
customIconsMap
);
setControlBarVisible(siteAvailable && showBar);
setControlBarVisible(!tabPaused && siteAvailable && showBar);
if (tabPaused) {
setForceButtonLoading(true);
setStatusMessage("Speeder is paused for this tab.");
return;
}
if (siteDisabled) {
setForceButtonLoading(false);
@@ -443,6 +627,7 @@ document.addEventListener("DOMContentLoaded", function () {
if (siteAvailable) {
querySpeed(function(frameContext) {
if (currentRenderToken !== renderToken) return;
diagnosticContext.frame = frameContext || null;
if (
frameContext &&
typeof frameContext.forceLastSavedSpeed === "boolean"
+2
View File
@@ -28,6 +28,8 @@
"popupControllerButtons",
"popupMatchHoverControls",
"rememberSpeed",
"shortcutTargetMode",
"showAmbientLoopControls",
"showPopupControlBar",
"siteRules",
"siteRulesFormat",
+57
View File
@@ -71,6 +71,9 @@
if (typeof result.frameToken === "string") {
normalized.frameToken = result.frameToken;
}
if (result.diagnostics && typeof result.diagnostics === "object") {
normalized.diagnostics = result.diagnostics;
}
if (typeof result.forceLastSavedSpeed === "boolean") {
normalized.forceLastSavedSpeed = result.forceLastSavedSpeed;
}
@@ -94,7 +97,61 @@
return fallback;
}
function getSafePageDetails(url) {
try {
var parsed = new URL(url);
return {
protocol: parsed.protocol,
hostname: parsed.hostname || null
};
} catch (_error) {
return { protocol: null, hostname: null };
}
}
function buildDiagnosticReport(context) {
var config = context || {};
var storage = config.storage || {};
var siteRule = config.siteRule || null;
var frame = config.frame || null;
return JSON.stringify(
{
speederVersion: config.speederVersion || null,
browser: config.browser || null,
platform: config.platform || null,
page: getSafePageDetails(config.url),
globalSettings: {
enabled: storage.enabled !== false,
rememberSpeed: storage.rememberSpeed === true,
forceLastSavedSpeed: storage.forceLastSavedSpeed === true,
audioEnabled: storage.audioBoolean === true,
startHidden: storage.startHidden === true,
hideWithControls: storage.hideWithControls === true,
controllerLocation: storage.controllerLocation,
shortcutTargetMode: storage.shortcutTargetMode
},
matchedSiteRule: {
matched: Boolean(siteRule),
disabled: config.siteRuleDisabled === true,
overrideKeys: siteRule
? Object.keys(siteRule)
.filter(function(key) {
return key !== "pattern" && key !== "title";
})
.sort()
: []
},
tabPaused: config.tabPaused === true,
frame: frame && frame.diagnostics ? frame.diagnostics : null
},
null,
2
);
}
return {
buildDiagnosticReport: buildDiagnosticReport,
pickBestFrameSpeedResult: pickBestFrameSpeedResult,
resolvePopupButtons: resolvePopupButtons,
sanitizeButtonOrder: sanitizeButtonOrder
+13 -9
View File
@@ -72,22 +72,26 @@ function vscClearElement(el) {
function vscSanitizeSvgTree(svg) {
if (!svg || String(svg.tagName).toLowerCase() !== "svg") return null;
svg.querySelectorAll("script, style, foreignObject").forEach(function (n) {
n.remove();
});
svg
.querySelectorAll(
"script, style, foreignObject, iframe, object, embed, image, use, a, " +
"animate, animateMotion, animateTransform, set"
)
.forEach(function (n) {
n.remove();
});
[svg].concat(Array.from(svg.querySelectorAll("*"))).forEach(function (el) {
for (var i = el.attributes.length - 1; i >= 0; i--) {
var attr = el.attributes[i];
var name = attr.name.toLowerCase();
var val = attr.value;
if (name.indexOf("on") === 0) {
el.removeAttribute(attr.name);
continue;
}
if (
(name === "href" || name === "xlink:href") &&
/^\s*javascript:/i.test(val)
name.indexOf("on") === 0 ||
name === "style" ||
name === "href" ||
name === "xlink:href" ||
/url\s*\(/i.test(val)
) {
el.removeAttribute(attr.name);
}
+161 -200
View File
@@ -7,7 +7,7 @@
"name": "speeder",
"devDependencies": {
"jsdom": "^26.1.0",
"vitest": "^3.2.4"
"vitest": "^3.2.7"
}
},
"node_modules/@asamuzakjp/css-color": {
@@ -140,9 +140,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
"cpu": [
"ppc64"
],
@@ -157,9 +157,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
"cpu": [
"arm"
],
@@ -174,9 +174,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
"cpu": [
"arm64"
],
@@ -191,9 +191,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
"cpu": [
"x64"
],
@@ -208,9 +208,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
"cpu": [
"arm64"
],
@@ -225,9 +225,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
"cpu": [
"x64"
],
@@ -242,9 +242,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
"cpu": [
"arm64"
],
@@ -259,9 +259,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
"cpu": [
"x64"
],
@@ -276,9 +276,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
"cpu": [
"arm"
],
@@ -293,9 +293,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
"cpu": [
"arm64"
],
@@ -310,9 +310,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
"cpu": [
"ia32"
],
@@ -327,9 +327,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
"cpu": [
"loong64"
],
@@ -344,9 +344,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
"cpu": [
"mips64el"
],
@@ -361,9 +361,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
"cpu": [
"ppc64"
],
@@ -378,9 +378,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
"cpu": [
"riscv64"
],
@@ -395,9 +395,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
"cpu": [
"s390x"
],
@@ -412,9 +412,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
"cpu": [
"x64"
],
@@ -429,9 +429,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
"cpu": [
"arm64"
],
@@ -446,9 +446,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
"cpu": [
"x64"
],
@@ -463,9 +463,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
"cpu": [
"arm64"
],
@@ -480,9 +480,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
"cpu": [
"x64"
],
@@ -497,9 +497,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
"cpu": [
"arm64"
],
@@ -514,9 +514,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
"cpu": [
"x64"
],
@@ -531,9 +531,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
"cpu": [
"arm64"
],
@@ -548,9 +548,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
"cpu": [
"ia32"
],
@@ -565,9 +565,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
"cpu": [
"x64"
],
@@ -680,9 +680,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -697,9 +694,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -714,9 +708,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -731,9 +722,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -748,9 +736,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -765,9 +750,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -782,9 +764,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -799,9 +778,6 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -816,9 +792,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -833,9 +806,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -850,9 +820,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -867,9 +834,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -884,9 +848,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1003,15 +964,15 @@
"license": "MIT"
},
"node_modules/@vitest/expect": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
"integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/spy": "3.2.4",
"@vitest/utils": "3.2.4",
"@vitest/spy": "3.2.7",
"@vitest/utils": "3.2.7",
"chai": "^5.2.0",
"tinyrainbow": "^2.0.0"
},
@@ -1020,13 +981,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz",
"integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "3.2.4",
"@vitest/spy": "3.2.7",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.17"
},
@@ -1047,9 +1008,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz",
"integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1060,13 +1021,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz",
"integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "3.2.4",
"@vitest/utils": "3.2.7",
"pathe": "^2.0.3",
"strip-literal": "^3.0.0"
},
@@ -1075,13 +1036,13 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz",
"integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.4",
"@vitest/pretty-format": "3.2.7",
"magic-string": "^0.30.17",
"pathe": "^2.0.3"
},
@@ -1090,9 +1051,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz",
"integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1103,13 +1064,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz",
"integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.4",
"@vitest/pretty-format": "3.2.7",
"loupe": "^3.1.4",
"tinyrainbow": "^2.0.0"
},
@@ -1258,9 +1219,9 @@
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -1271,32 +1232,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.7",
"@esbuild/android-arm": "0.27.7",
"@esbuild/android-arm64": "0.27.7",
"@esbuild/android-x64": "0.27.7",
"@esbuild/darwin-arm64": "0.27.7",
"@esbuild/darwin-x64": "0.27.7",
"@esbuild/freebsd-arm64": "0.27.7",
"@esbuild/freebsd-x64": "0.27.7",
"@esbuild/linux-arm": "0.27.7",
"@esbuild/linux-arm64": "0.27.7",
"@esbuild/linux-ia32": "0.27.7",
"@esbuild/linux-loong64": "0.27.7",
"@esbuild/linux-mips64el": "0.27.7",
"@esbuild/linux-ppc64": "0.27.7",
"@esbuild/linux-riscv64": "0.27.7",
"@esbuild/linux-s390x": "0.27.7",
"@esbuild/linux-x64": "0.27.7",
"@esbuild/netbsd-arm64": "0.27.7",
"@esbuild/netbsd-x64": "0.27.7",
"@esbuild/openbsd-arm64": "0.27.7",
"@esbuild/openbsd-x64": "0.27.7",
"@esbuild/openharmony-arm64": "0.27.7",
"@esbuild/sunos-x64": "0.27.7",
"@esbuild/win32-arm64": "0.27.7",
"@esbuild/win32-ia32": "0.27.7",
"@esbuild/win32-x64": "0.27.7"
"@esbuild/aix-ppc64": "0.28.2",
"@esbuild/android-arm": "0.28.2",
"@esbuild/android-arm64": "0.28.2",
"@esbuild/android-x64": "0.28.2",
"@esbuild/darwin-arm64": "0.28.2",
"@esbuild/darwin-x64": "0.28.2",
"@esbuild/freebsd-arm64": "0.28.2",
"@esbuild/freebsd-x64": "0.28.2",
"@esbuild/linux-arm": "0.28.2",
"@esbuild/linux-arm64": "0.28.2",
"@esbuild/linux-ia32": "0.28.2",
"@esbuild/linux-loong64": "0.28.2",
"@esbuild/linux-mips64el": "0.28.2",
"@esbuild/linux-ppc64": "0.28.2",
"@esbuild/linux-riscv64": "0.28.2",
"@esbuild/linux-s390x": "0.28.2",
"@esbuild/linux-x64": "0.28.2",
"@esbuild/netbsd-arm64": "0.28.2",
"@esbuild/netbsd-x64": "0.28.2",
"@esbuild/openbsd-arm64": "0.28.2",
"@esbuild/openbsd-x64": "0.28.2",
"@esbuild/openharmony-arm64": "0.28.2",
"@esbuild/sunos-x64": "0.28.2",
"@esbuild/win32-arm64": "0.28.2",
"@esbuild/win32-ia32": "0.28.2",
"@esbuild/win32-x64": "0.28.2"
}
},
"node_modules/estree-walker": {
@@ -1492,9 +1453,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"dev": true,
"funding": [
{
@@ -1568,9 +1529,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"dev": true,
"funding": [
{
@@ -1588,7 +1549,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"nanoid": "^3.3.17",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -1837,13 +1798,13 @@
}
},
"node_modules/vite": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"version": "7.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
"integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.27.0",
"esbuild": "^0.27.0 || ^0.28.0",
"fdir": "^6.5.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
@@ -1935,20 +1896,20 @@
}
},
"node_modules/vitest": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz",
"integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
"@vitest/mocker": "3.2.4",
"@vitest/pretty-format": "^3.2.4",
"@vitest/runner": "3.2.4",
"@vitest/snapshot": "3.2.4",
"@vitest/spy": "3.2.4",
"@vitest/utils": "3.2.4",
"@vitest/expect": "3.2.7",
"@vitest/mocker": "3.2.7",
"@vitest/pretty-format": "^3.2.7",
"@vitest/runner": "3.2.7",
"@vitest/snapshot": "3.2.7",
"@vitest/spy": "3.2.7",
"@vitest/utils": "3.2.7",
"chai": "^5.2.0",
"debug": "^4.4.1",
"expect-type": "^1.2.1",
@@ -1978,8 +1939,8 @@
"@edge-runtime/vm": "*",
"@types/debug": "^4.1.12",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"@vitest/browser": "3.2.4",
"@vitest/ui": "3.2.4",
"@vitest/browser": "3.2.7",
"@vitest/ui": "3.2.7",
"happy-dom": "*",
"jsdom": "*"
},
@@ -2086,9 +2047,9 @@
}
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"dev": true,
"license": "MIT",
"engines": {
+1 -1
View File
@@ -7,6 +7,6 @@
},
"devDependencies": {
"jsdom": "^26.1.0",
"vitest": "^3.2.4"
"vitest": "^3.2.7"
}
}
+15 -5
View File
@@ -45,8 +45,8 @@ validate_semver() {
echo "Error: empty version." >&2
return 1
fi
if [[ ! "$s" =~ ^[0-9]+(\.[0-9]+){0,3}(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then
echo "Error: invalid version (use something like 5.0.4)." >&2
if [[ ! "$s" =~ ^[0-9]+(\.[0-9]+){0,3}$ ]]; then
echo "Error: Firefox versions must contain only 1-4 numeric parts (for example, 6.0.8.1)." >&2
return 1
fi
}
@@ -59,10 +59,15 @@ fi
git checkout beta
git pull origin beta
echo "Current version on beta ($MANIFEST_PATH): $(manifest_version)"
CURRENT_VERSION="$(manifest_version)"
echo "Current version on beta ($MANIFEST_PATH): $CURRENT_VERSION"
read -r -p "Release version for $MANIFEST_PATH + tag (e.g. 5.0.4): " SEMVER_IN
SEMVER="$(normalize_semver "$SEMVER_IN")"
validate_semver "$SEMVER"
if [[ "$SEMVER" == "$CURRENT_VERSION" ]]; then
echo "Error: release version must differ from the current manifest version $CURRENT_VERSION." >&2
exit 1
fi
TAG="v${SEMVER}"
if [[ "$TAG" == *-beta* ]]; then
@@ -70,6 +75,11 @@ if [[ "$TAG" == *-beta* ]]; then
read -r -p "Continue anyway? [y/N] " w
[[ "${w:-}" =~ ^[yY](es)?$ ]] || { echo "Aborted."; exit 1; }
fi
if git show-ref --verify --quiet "refs/tags/$TAG" ||
git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then
echo "Error: tag $TAG already exists." >&2
exit 1
fi
echo
echo "This will:"
@@ -87,11 +97,11 @@ git pull origin main
git merge --squash beta
bump_manifest "$SEMVER"
git add -A
git commit -m "Release $TAG"
git commit -m "chore(release): prepare $TAG"
git push origin main
git tag -a "$TAG" -m "$TAG"
git tag -s "$TAG" -m "$TAG"
git push origin "$TAG"
git checkout dev
+16 -6
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Merge dev → beta, push beta, and push an annotated beta tag (v*-beta*).
# Merge dev → beta, push beta, and push a signed beta tag (v*-beta*).
# Triggers .github/workflows/deploy.yml: unlisted AMO sign + GitHub prerelease.
set -euo pipefail
@@ -44,8 +44,8 @@ validate_semver() {
echo "Error: empty version." >&2
return 1
fi
if [[ ! "$s" =~ ^[0-9]+(\.[0-9]+){0,3}(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then
echo "Error: invalid version (use something like 5.0.4 or 5.0.4-beta.1)." >&2
if [[ ! "$s" =~ ^[0-9]+(\.[0-9]+){0,3}$ ]]; then
echo "Error: Firefox versions must contain only 1-4 numeric parts (for example, 6.0.8.1)." >&2
return 1
fi
}
@@ -58,10 +58,15 @@ fi
git checkout dev
git pull origin dev
echo "Current version in $MANIFEST_PATH: $(manifest_version)"
CURRENT_VERSION="$(manifest_version)"
echo "Current version in $MANIFEST_PATH: $CURRENT_VERSION"
read -r -p "New version for $MANIFEST_PATH (e.g. 5.0.4): " SEMVER_IN
SEMVER="$(normalize_semver "$SEMVER_IN")"
validate_semver "$SEMVER"
if [[ "$SEMVER" == "$CURRENT_VERSION" ]]; then
echo "Error: release version must differ from the current manifest version $CURRENT_VERSION." >&2
exit 1
fi
echo "Beta git tag will include '-beta' (required by deploy.yml)."
read -r -p "Beta tag suffix [beta.1]: " SUFFIX_IN
@@ -74,6 +79,11 @@ if [[ "$TAG" != *-beta* ]]; then
echo "Error: beta tag must contain '-beta' for the workflow (got $TAG). Try suffix like beta.1." >&2
exit 1
fi
if git show-ref --verify --quiet "refs/tags/$TAG" ||
git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then
echo "Error: tag $TAG already exists." >&2
exit 1
fi
echo
echo "This will:"
@@ -88,7 +98,7 @@ echo "🚀 Releasing beta $TAG"
bump_manifest "$SEMVER"
git add "$MANIFEST_PATH"
git commit -m "Bump version to $SEMVER"
git commit -m "chore(release): bump version to $SEMVER"
git push origin dev
git checkout beta
@@ -96,7 +106,7 @@ git pull origin beta
git merge dev --no-ff -m "$TAG"
git push origin beta
git tag -a "$TAG" -m "$TAG"
git tag -s "$TAG" -m "$TAG"
git push origin "$TAG"
git checkout dev
+30
View File
@@ -33,4 +33,34 @@ describe("background toolbar state", () => {
}
});
});
it("keeps tab pauses in memory and clears closed tabs", () => {
loadHtmlString("<!doctype html><html><body></body></html>");
const chrome = createChromeMock();
global.chrome = chrome;
window.chrome = chrome;
evaluateScript("extension/background/background.js");
const listener = chrome.runtime.onMessage.listeners[0];
const respond = vi.fn();
listener(
{ action: "set_tab_paused", tabId: 42, paused: true },
{},
respond
);
expect(respond).toHaveBeenLastCalledWith({ paused: true });
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(
42,
{ action: "set_tab_paused", paused: true },
expect.any(Function)
);
listener({ action: "get_tab_pause_state" }, { tab: { id: 42 } }, respond);
expect(respond).toHaveBeenLastCalledWith({ paused: true });
chrome.tabs.onRemoved.emit(42);
listener({ action: "get_tab_pause_state", tabId: 42 }, {}, respond);
expect(respond).toHaveBeenLastCalledWith({ paused: false });
});
});
+10 -1
View File
@@ -8,6 +8,7 @@ import { applyJSDOMWindow } from "./jsdom-globals.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, "..", "..");
let activeDom = null;
function readRepoFile(relPath) {
return fs.readFileSync(path.join(repoRoot, relPath), "utf8");
@@ -18,11 +19,13 @@ function readRepoFile(relPath) {
* top-level `const` redeclaration errors (avoids document.write).
*/
export function loadHtmlString(html, options = {}) {
if (activeDom) activeDom.window.close();
const dom = new JSDOM(html, {
url: options.url || "https://example.org/",
pretendToBeVisual: true,
runScripts: "dangerously"
});
activeDom = dom;
applyJSDOMWindow(dom.window);
}
@@ -173,7 +176,13 @@ export function createChromeMock(options = {}) {
getManifest: vi.fn(() => ({
version: options.manifestVersion || "9.9.9"
})),
getURL: vi.fn((url) => "moz-extension://speeder/" + url)
getURL: vi.fn((url) => "moz-extension://speeder/" + url),
sendMessage: vi.fn((message, callback) => {
if (options.runtimeSendMessageImpl) {
return options.runtimeSendMessageImpl(message, callback);
}
if (callback) callback({ paused: false });
})
},
storage: {
sync: syncArea,
+12 -1
View File
@@ -4,6 +4,7 @@ const { JSDOM } = require("jsdom");
const vi = globalThis.vi;
const ROOT = path.resolve(__dirname, "..", "..");
let activeDom = null;
function clone(value) {
if (value === undefined) return undefined;
@@ -47,11 +48,13 @@ function applyJSDOMWindow(win) {
function loadHtmlString(html, options) {
const config = options || {};
if (activeDom) activeDom.window.close();
const dom = new JSDOM(html, {
url: config.url || "https://example.org/",
pretendToBeVisual: true,
runScripts: "dangerously"
});
activeDom = dom;
applyJSDOMWindow(dom.window);
}
@@ -245,6 +248,7 @@ function createChromeMock(options) {
const storageOnChanged = createChromeEvent();
const tabsOnActivated = createChromeEvent();
const tabsOnUpdated = createChromeEvent();
const tabsOnRemoved = createChromeEvent();
const runtimeOnMessage = createChromeEvent();
const chrome = {
@@ -252,6 +256,12 @@ function createChromeMock(options) {
lastError: null,
getManifest: vi.fn(() => clone(config.manifest) || { version: "0.0.0-test" }),
getURL: vi.fn((relPath) => `moz-extension://${relPath}`),
sendMessage: vi.fn((message, callback) => {
if (config.runtimeSendMessageImpl) {
return config.runtimeSendMessageImpl(message, callback);
}
if (callback) callback({ paused: false });
}),
onMessage: runtimeOnMessage
},
browserAction: {
@@ -272,7 +282,8 @@ function createChromeMock(options) {
}),
create: vi.fn(),
onActivated: tabsOnActivated,
onUpdated: tabsOnUpdated
onUpdated: tabsOnUpdated,
onRemoved: tabsOnRemoved
},
storage: {
onChanged: storageOnChanged,
+3 -2
View File
@@ -44,7 +44,8 @@ describe("import/export flows", () => {
it("exports sync and local settings as a JSON download", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 3, 4, 8, 9, 10));
const exportTime = new Date(2026, 3, 4, 8, 9, 10);
vi.setSystemTime(exportTime);
const chrome = await setupImportExport({
sync: { rememberSpeed: true, shortcutTargetMode: "all" },
local: { customButtonIcons: { faster: { slug: "rocket" } } }
@@ -89,7 +90,7 @@ describe("import/export flows", () => {
const blobText = await capturedBlob.text();
expect(JSON.parse(blobText)).toEqual({
version: "1.1",
exportDate: "2026-04-04T12:09:10.000Z",
exportDate: exportTime.toISOString(),
settings: {
rememberSpeed: true,
shortcutTargetMode: "all",
+1 -1
View File
@@ -70,7 +70,7 @@ describe("options/import-export.js", () => {
});
it("generates timestamped backup filenames", () => {
vi.setSystemTime(new Date("2026-04-04T13:14:15Z"));
vi.setSystemTime(new Date(2026, 3, 4, 9, 14, 15));
bootImportExport();
expect(window.generateBackupFilename()).toBe(
+975 -42
View File
@@ -2,7 +2,8 @@ const {
createChromeMock,
evaluateScript,
flushAsyncWork,
loadHtmlString
loadHtmlString,
readWorkspaceFile
} = require("./helpers/extension-test-utils");
function bootInject(options) {
@@ -12,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) => {
@@ -137,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 () => {
@@ -280,6 +347,82 @@ describe("inject.js media/controller lifecycle regressions", () => {
expect(video.vsc.div.style.getPropertyValue("height")).toBe("226px");
});
it("keeps measured geometry out of controller stylesheet defaults", () => {
[
{
css: readWorkspaceFile("extension/content/inject.css"),
host: /\.vsc-controller\s*\{([^}]*)\}/,
fullscreen:
/\.vsc-controller\.vsc-fullscreen-popover\s*\{([^}]*)\}/
},
{
css: readWorkspaceFile("extension/content/shadow.css"),
host: /:host\s*\{([^}]*)\}/,
fullscreen: /:host\(\.vsc-fullscreen-popover\)\s*\{([^}]*)\}/
}
].forEach(({ css, host, fullscreen }) => {
expect(css.match(host)[1]).not.toMatch(
/\b(?:top|left|width|height)\s*:/
);
expect(css.match(fullscreen)[1]).not.toMatch(/\binset\s*:/);
});
});
it("repositions a Shorts controller when playback moves the video on-screen", async () => {
vi.useFakeTimers();
bootInject({ url: "https://www.youtube.com/shorts/example" });
await settleLifecycle();
const player = document.createElement("div");
player.className = "html5-video-player";
const video = document.createElement("video");
const playerRect = makeRect(40, 64, 351, 624);
let videoRect = makeRect(40, -560, 351, 624);
setRect(player, playerRect);
setBoxMetrics(player, playerRect.width, playerRect.height);
video.getBoundingClientRect = () => videoRect;
video.src = "blob:https://www.youtube.com/shorts";
player.appendChild(video);
document.body.appendChild(player);
window.ensureController(video, player);
expect(video.vsc.div.style.getPropertyValue("top")).toBe("-624px");
videoRect = playerRect;
video.dispatchEvent(new Event("playing"));
await vi.advanceTimersByTimeAsync(0);
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();
@@ -292,16 +435,27 @@ describe("inject.js media/controller lifecycle regressions", () => {
src: "https://example.org/second.mp4",
mountRect: makeRect(500, 0, 320, 180)
});
setRect(window.getControllerElement(first.controller), makeRect(10, 10, 120, 30));
setRect(window.getControllerElement(second.controller), makeRect(510, 10, 120, 30));
const firstLayoutRead = vi.fn(() => first.mount.getBoundingClientRect());
const secondLayoutRead = vi.fn(() => second.mount.getBoundingClientRect());
first.video.getBoundingClientRect = firstLayoutRead;
second.video.getBoundingClientRect = secondLayoutRead;
document.dispatchEvent(
new MouseEvent("mousemove", { bubbles: true, clientX: 600, clientY: 20 })
);
window.runAction("faster", 0.1);
document.dispatchEvent(
new KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
code: "KeyD",
key: "d"
})
);
expect(first.video.playbackRate).toBe(1);
expect(second.video.playbackRate).toBe(1.1);
expect(firstLayoutRead).not.toHaveBeenCalled();
expect(secondLayoutRead).not.toHaveBeenCalled();
window.tc.settings.shortcutTargetMode = "all";
window.runAction("faster", 0.1);
@@ -309,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();
@@ -329,6 +611,31 @@ describe("inject.js media/controller lifecycle regressions", () => {
expect(video.playbackRate).toBe(initialSpeed);
});
it("removes and restores controls when this tab is paused", async () => {
const chrome = bootInject();
await settleLifecycle();
const { video } = createControlledVideo();
const listener = chrome.runtime.onMessage.listeners[0];
const playbackRate = video.playbackRate;
listener(
{ action: "set_tab_paused", paused: true },
{},
vi.fn()
);
expect(window.tc.tabPaused).toBe(true);
expect(video.vsc).toBeUndefined();
expect(video.playbackRate).toBe(playbackRate);
listener(
{ action: "set_tab_paused", paused: false },
{},
vi.fn()
);
expect(window.tc.tabPaused).toBe(false);
expect(video.vsc).toBeTruthy();
});
it("drops a stale hover-preview shortcut target after SPA navigation", async () => {
bootInject({
url: "https://www.youtube.com/",
@@ -590,6 +897,7 @@ describe("inject.js media/controller lifecycle regressions", () => {
div: wrapper,
normalControllerMount: normalMount
};
wrapper.showPopover = vi.fn();
window.setupControllerHostTracking(controller, wrapper, normalMount);
Object.defineProperty(document, "fullscreenElement", {
configurable: true,
@@ -598,6 +906,8 @@ describe("inject.js media/controller lifecycle regressions", () => {
window.syncControllerFullscreenMount(controller);
expect(fullscreenPlayer.contains(wrapper)).toBe(true);
expect(wrapper.showPopover).not.toHaveBeenCalled();
expect(wrapper.hasAttribute("popover")).toBe(false);
Object.defineProperty(document, "fullscreenElement", {
configurable: true,
@@ -610,53 +920,26 @@ describe("inject.js media/controller lifecycle regressions", () => {
controller.controllerHostCleanup();
});
it("promotes an ancestor-fullscreen controller into the browser top layer", async () => {
it("keeps a visible player-local host when Firefox fullscreens the page root", async () => {
bootInject();
await settleLifecycle();
const fullscreenPlayer = document.createElement("div");
const video = document.createElement("video");
const wrapper = document.createElement("div");
const rect = makeRect(0, 0, 1280, 720);
fullscreenPlayer.append(video, wrapper);
document.body.appendChild(fullscreenPlayer);
[fullscreenPlayer, video].forEach((element) => {
setRect(element, rect);
setBoxMetrics(element, rect.width, rect.height);
});
wrapper.showPopover = vi.fn();
wrapper.hidePopover = vi.fn();
const controller = {
video,
div: wrapper,
normalControllerMount: fullscreenPlayer
};
window.setupControllerHostTracking(controller, wrapper, fullscreenPlayer);
const { mount, controller, wrapper } = createControlledVideo();
setRect(document.documentElement, makeRect(0, 0, 0, 0));
Object.defineProperty(document, "fullscreenElement", {
configurable: true,
value: fullscreenPlayer
value: document.documentElement
});
expect(window.syncControllerFullscreenMount(controller)).toBe(true);
expect(wrapper.parentNode).toBe(fullscreenPlayer);
expect(wrapper.showPopover).toHaveBeenCalledOnce();
expect(wrapper.getAttribute("popover")).toBe("manual");
expect(wrapper.classList.contains("vsc-fullscreen-popover")).toBe(true);
Object.defineProperty(document, "fullscreenElement", {
configurable: true,
value: null
});
window.syncControllerFullscreenMount(controller);
expect(wrapper.hidePopover).toHaveBeenCalledOnce();
wrapper.remove();
controller.controllerHostCleanup();
expect(wrapper.parentElement).toBe(mount);
expect(wrapper.classList.contains("vsc-geometry-hidden")).toBe(false);
expect(document.documentElement.style.position).toBe("");
expect(document.documentElement.style.isolation).toBe("");
});
it("only promotes the controller owned by the fullscreen player", async () => {
it("only promotes the directly-fullscreen video's controller", async () => {
bootInject();
await settleLifecycle();
@@ -680,7 +963,7 @@ describe("inject.js media/controller lifecycle regressions", () => {
Object.defineProperty(document, "fullscreenElement", {
configurable: true,
value: fullscreenPlayer
value: fullscreenVideo
});
window.syncControllerFullscreenMount(fullscreenVideo.vsc);
window.syncControllerFullscreenMount(otherVideo.vsc);
@@ -802,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();
@@ -825,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();
@@ -870,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({
@@ -985,9 +1816,13 @@ describe("inject.js media/controller lifecycle regressions", () => {
videoRect: makeRect(40, 40, 640, 360),
src: "https://example.org/visible.mp4"
}).video;
const offscreenLayoutRead = vi.spyOn(offscreen, "getBoundingClientRect");
const visibleLayoutRead = vi.spyOn(visible, "getBoundingClientRect");
expect(window.getPrimaryVideoElement()).toBe(visible);
expect(window.getPrimaryVideoElement()).not.toBe(offscreen);
expect(offscreenLayoutRead).not.toHaveBeenCalled();
expect(visibleLayoutRead).not.toHaveBeenCalled();
});
it("mounts a controller locally for video directly under an open ShadowRoot", async () => {
@@ -1061,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 () => {
@@ -1110,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();
+10 -1
View File
@@ -29,7 +29,10 @@ describe("lucide-client.js", () => {
<svg width="10" height="10" onclick="evil()">
<script>alert(1)</script>
<foreignObject>bad</foreignObject>
<path d="M0 0h10v10"></path>
<image href="https://tracking.example/icon.png"></image>
<use href="https://tracking.example/sprite.svg#icon"></use>
<animate attributeName="opacity" values="0;1"></animate>
<path style="fill: url(https://tracking.example/a)" fill="url(#paint)" d="M0 0h10v10"></path>
</svg>
`);
@@ -37,6 +40,12 @@ describe("lucide-client.js", () => {
expect(sanitized).not.toContain("onclick");
expect(sanitized).not.toContain("<script");
expect(sanitized).not.toContain("foreignObject");
expect(sanitized).not.toContain("tracking.example");
expect(sanitized).not.toContain("<image");
expect(sanitized).not.toContain("<use");
expect(sanitized).not.toContain("<animate");
expect(sanitized).not.toContain("style=");
expect(sanitized).not.toContain("url(");
expect(sanitized).toContain('width="100%"');
});
+36
View File
@@ -84,6 +84,42 @@ describe("options.js", () => {
expect(document.getElementById("status").textContent).toBe("Auto-saved");
});
it("copies privacy-safe diagnostics from settings", async () => {
bootOptions({
syncData: {
enabled: false,
rememberSpeed: true,
siteRules: [
{
title: "Private account",
pattern: "secret.example/private-token",
enabled: true
}
]
}
});
const writeText = vi.fn(() => Promise.resolve());
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText }
});
await flushAsyncWork(3);
document.getElementById("copyDiagnostics").click();
await flushAsyncWork(3);
const reportText = writeText.mock.calls[0][0];
const report = JSON.parse(reportText);
expect(report.globalSettings.enabled).toBe(false);
expect(report.globalSettings.rememberSpeed).toBe(true);
expect(report.page).toEqual({ protocol: null, hostname: null });
expect(report.matchedSiteRule.matched).toBe(false);
expect(report.frame).toBeNull();
expect(reportText).not.toContain("Private account");
expect(reportText).not.toContain("private-token");
expect(document.getElementById("status").textContent).toContain("copied");
});
it("does not partially save options when a required site shortcut is invalid", async () => {
const chrome = bootOptions({ syncData: { rememberSpeed: false } });
await flushAsyncWork(3);
+103 -1
View File
@@ -18,6 +18,7 @@ function bootPopup(options) {
manifest: { version: "9.9.9-test" },
syncData: config.syncData,
localData: config.localData,
runtimeSendMessageImpl: config.runtimeSendMessageImpl,
tabsQueryResult: [
config.activeTab || { id: 99, active: true, url: "https://example.com/" }
]
@@ -107,7 +108,7 @@ describe("popup.js", () => {
executeScriptImpl: (tabId, details, callback) => {
speedQueryCount += 1;
callback(
speedQueryCount <= 2
speedQueryCount === 1
? [
{ speed: 1.25, preferred: false },
{ speed: 1.5, frameToken: "playing-frame", preferred: true }
@@ -167,6 +168,107 @@ describe("popup.js", () => {
);
});
it("copies redacted diagnostics for the active media frame", async () => {
bootPopup({
activeTab: {
id: 12,
active: true,
url: "https://video.example/watch?private_token=secret"
},
executeScriptImpl: (tabId, details, callback) => {
callback([
{
speed: 1.5,
preferred: true,
diagnostics: {
mediaType: "video",
fullscreen: { active: true, element: "div", ownsMedia: true },
controller: { present: true, hidden: false }
}
}
]);
}
});
const writeText = vi.fn(() => Promise.resolve());
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText }
});
await flushAsyncWork();
document.querySelector("#copyDiagnostics").click();
await flushAsyncWork();
expect(writeText).toHaveBeenCalled();
const reportText = writeText.mock.calls.at(-1)[0];
const report = JSON.parse(reportText);
expect(report.page).toEqual({
protocol: "https:",
hostname: "video.example"
});
expect(report.frame.mediaType).toBe("video");
expect(reportText).not.toContain("private_token");
expect(reportText).not.toContain("secret");
expect(document.querySelector("#status").textContent).toContain("copied");
});
it("adds one safe origin rule for the current site", async () => {
const chrome = bootPopup({
activeTab: {
id: 18,
active: true,
url: "https://courses.example:8443/watch/lesson?token=private"
}
});
await flushAsyncWork();
document.querySelector("#addSiteRule").click();
await flushAsyncWork();
document.querySelector("#addSiteRule").click();
await flushAsyncWork();
const rules = window.vscExpandStoredSettings(
chrome.storage.sync._dump()
).siteRules.filter(function(rule) {
return rule.pattern === "https://courses.example:8443";
});
expect(rules).toEqual([
{
title: "courses.example:8443",
pattern: "https://courses.example:8443",
enabled: true
}
]);
expect(window.open).toHaveBeenCalledWith(
"moz-extension://options/options.html"
);
});
it("pauses only the active tab for the browser session", async () => {
let paused = false;
const chrome = bootPopup({
runtimeSendMessageImpl: (message, callback) => {
if (message.action === "set_tab_paused") paused = message.paused === true;
callback({ paused });
}
});
await flushAsyncWork();
document.querySelector("#pauseTab").click();
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith(
{ action: "set_tab_paused", tabId: 99, paused: true },
expect.any(Function)
);
expect(document.querySelector("#pauseTab").textContent).toBe(
"Resume on this tab"
);
expect(document.querySelector("#popupControlBar").style.display).toBe(
"none"
);
expect(document.querySelector("#status").textContent).toContain("paused");
});
it("toggles enablement and closes after a successful refresh", async () => {
const chrome = bootPopup({
syncData: {
+11
View File
@@ -89,6 +89,17 @@ describe("canonical settings storage", () => {
);
});
it("keeps every managed setting recognizable in legacy raw backups", () => {
evaluateScript("extension/shared/import-export.js");
const importExport = window.SpeederShared.importExport;
window.vscGetManagedSyncKeys().forEach(function (key) {
expect(importExport.isRecognizedRawSettingsObject({ [key]: null })).toBe(
true
);
});
});
it("provides titles for built-in video rules and round-trips title edits sparsely", () => {
const settings = window.vscExpandStoredSettings({});
expect(settings.siteRules.map((rule) => rule.title)).toEqual([
+18
View File
@@ -241,6 +241,24 @@ describe("shared helpers", () => {
localSettings: null
});
expect(
importExportUtils.extractImportSettings({
showAmbientLoopControls: true
})
).toEqual({
isWrappedBackup: false,
settings: { showAmbientLoopControls: true },
localSettings: null
});
expect(
importExportUtils.extractImportSettings({ shortcutTargetMode: "closest" })
).toEqual({
isWrappedBackup: false,
settings: { shortcutTargetMode: "closest" },
localSettings: null
});
expect(importExportUtils.isRecognizedRawSettingsObject({ wat: true })).toBe(
false
);
+2
View File
@@ -4,8 +4,10 @@ module.exports = defineConfig({
test: {
environment: "jsdom",
clearMocks: true,
fileParallelism: false,
globals: true,
restoreMocks: true,
testTimeout: 15000,
include: ["tests/**/*.test.js", "tests/**/*.spec.js"],
setupFiles: ["./tests/setup.js"]
}