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
joshpatra 5af0200008 chore(release): merge v6.0.6.0 beta 2026-08-02 00:05:56 -04:00
joshpatra 1d9f6f7e13 chore(release): bump version to 6.0.6.0 2026-08-02 00:05:24 -04:00
joshpatra 0f64fd5ab1 docs: complete implementation checklist 2026-07-31 22:40:19 -04:00
joshpatra e555c4c116 ci: run tests before extension packaging 2026-07-31 22:39:49 -04:00
joshpatra 91d530e29b docs: correct extension behavior guidance 2026-07-31 22:39:25 -04:00
joshpatra 34f2561b50 fix(background): synchronize toolbar icon state 2026-07-31 22:37:44 -04:00
joshpatra fd29883d84 fix(rules): reject malformed regex patterns 2026-07-31 22:36:53 -04:00
joshpatra e083fdcafe fix(import): report partial settings imports 2026-07-31 22:36:12 -04:00
joshpatra 2fa7d8963a fix(settings): confirm destructive defaults reset 2026-07-31 22:35:23 -04:00
joshpatra e4edf10440 fix(ui): improve focus and status accessibility 2026-07-31 22:34:25 -04:00
joshpatra d5c2d0e343 fix(options): label generated settings controls 2026-07-31 22:33:18 -04:00
joshpatra 5cd559b883 feat(options): add keyboard control bar editing 2026-07-31 22:32:04 -04:00
joshpatra 0c9a6a1a1b fix(controller): make overlay controls accessible 2026-07-31 22:29:44 -04:00
joshpatra 3ee970a54b fix(shortcuts): ignore editable control events 2026-07-31 22:28:18 -04:00
joshpatra e12375f2d2 fix(popup): target the displayed media frame 2026-07-31 22:27:33 -04:00
joshpatra 8d7d31a4db fix(controller): restore fullscreen overlay visibility 2026-07-31 22:25:38 -04:00
joshpatra ed21281fa3 docs: add implementation checklist 2026-07-31 22:24:02 -04:00
joshpatra 13350e5cea v6.0.4.1-beta.1 2026-07-26 11:46:49 -04:00
joshpatra 39b7ba27fb Bump version to 6.0.4.1 2026-07-26 11:46:48 -04:00
joshpatra ee346ee9d8 fix(controller): defer SPA initialization until settings load 2026-07-26 11:46:18 -04:00
joshpatra 461c8c448f v6.0.4-beta.1 2026-07-26 11:15:22 -04:00
joshpatra bff65230bb Bump version to 6.0.4 2026-07-26 11:15:20 -04:00
joshpatra 0afb56abe0 fix(options): align shortcuts and show autosave status 2026-07-26 11:14:55 -04:00
joshpatra a02b7b57f7 fix: restore native playback speed range 2026-07-26 11:14:55 -04:00
41 changed files with 3718 additions and 742 deletions

No files matched your search

+15 -4
View File
@@ -12,10 +12,21 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- name: Install deps - uses: actions/setup-node@v7
run: npm install -g web-ext with:
node-version: 22
cache: npm
- name: Install project dependencies
run: npm ci
- name: Test
run: npm test
- name: Install web-ext
run: npm install -g web-ext@10.6.0
- name: Lint - name: Lint
run: web-ext lint --source-dir extension run: web-ext lint --source-dir extension
@@ -43,7 +54,7 @@ jobs:
- name: Create GitHub Prerelease (beta) - name: Create GitHub Prerelease (beta)
if: contains(github.ref_name, '-beta') if: contains(github.ref_name, '-beta')
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v3
with: with:
tag_name: ${{ github.ref_name }} tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }} name: ${{ github.ref_name }}
+10 -14
View File
@@ -56,16 +56,14 @@ settings page, as well as add additional shortcut keys to match your
preferences. For example, you can assign multiple different "preferred speed" preferences. For example, you can assign multiple different "preferred speed"
shortcuts with different values, which will allow you to quickly toggle between shortcuts with different values, which will allow you to quickly toggle between
your most commonly used speeds. To add a new shortcut, open extension settings your most commonly used speeds. To add a new shortcut, open extension settings
and click "Add New". and choose an action from "Add shortcut…".
<img width="1760" height="1330" alt="image" src="https://github.com/user-attachments/assets/32e814dd-93ea-4943-8ec9-3eca735447ac" /> <img width="1760" height="1330" alt="image" src="https://github.com/user-attachments/assets/32e814dd-93ea-4943-8ec9-3eca735447ac" />
Some sites may assign other functionality to one of the assigned shortcut keys — Some sites may assign other functionality to one of the assigned shortcut keys.
these collisions are inevitable, unfortunately. As a workaround, the extension You can record `Shift+<shortcut>` as a separate, exact binding, or use a site
listens both for lower and upper case values (i.e. you can use rule to block the site from capturing a particular Speeder shortcut. Shift is
`Shift-<shortcut>`) if there is other functionality assigned to the lowercase not applied automatically to an unshifted binding.
key. This is not a perfect solution, as some sites may listen to both, but works
most of the time.
## Development ## Development
@@ -74,19 +72,17 @@ The unpacked extension root is `extension/`. Load that directory in
```sh ```sh
npm test npm test
npx --yes web-ext lint --source-dir extension npx --yes web-ext@10.6.0 lint --source-dir extension
``` ```
## FAQ ## FAQ
### The video controls are not showing up? ### The video controls are not showing up?
This extension is only compatible Speeder works with HTML5 video and, when enabled in settings, HTML5 audio. Check
with HTML5 video. If you don't see the controls showing up, chances are you are that Speeder is enabled for the current site, then use the popup's "Rescan page
viewing a Flash video. If you want to confirm, try right-clicking on the video for videos" action after a player loads dynamically. Browser-internal pages and
and inspect the menu: if it mentions flash, then that's the issue. That said, players that do not expose HTML5 media to extensions cannot be controlled.
most sites will fallback to HTML5 if they detect that Flash is not available.
You can try manually disabling Flash from the browser.
### What is this fork all about? ### What is this fork all about?
+65 -3
View File
@@ -1,5 +1,67 @@
chrome.runtime.onMessage.addListener(function (request) { function setToolbarIcon(enabled) {
if (request.action === "openOptions") { var suffix = enabled === false ? "_disabled" : "";
chrome.tabs.create({ url: chrome.runtime.getURL("options/options.html") }); chrome.browserAction.setIcon({
path: {
19: "assets/icons/icon19" + suffix + ".png",
38: "assets/icons/icon38" + suffix + ".png",
48: "assets/icons/icon48" + suffix + ".png"
}
});
}
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);
});
chrome.storage.onChanged.addListener(function(changes, areaName) {
if (areaName === "sync" && changes.enabled) {
setToolbarIcon(changes.enabled.newValue !== false);
} }
}); });
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);
});
@@ -8,6 +8,14 @@
if (!v) return null; if (!v) return null;
return { return {
speed: v.playbackRate, speed: v.playbackRate,
frameToken:
typeof tc === "object" && typeof tc.frameToken === "string"
? tc.frameToken
: null,
diagnostics:
typeof getDiagnosticsSnapshot === "function"
? getDiagnosticsSnapshot(v)
: null,
preferred: !v.paused, preferred: !v.paused,
forceLastSavedSpeed: Boolean( forceLastSavedSpeed: Boolean(
typeof tc === "object" && tc.settings && tc.settings.forceLastSavedSpeed typeof tc === "object" && tc.settings && tc.settings.forceLastSavedSpeed
-5
View File
@@ -1,10 +1,6 @@
/* Base styles for the controller wrapper (the shadow host) */ /* Base styles for the controller wrapper (the shadow host) */
.vsc-controller { .vsc-controller {
position: absolute !important; position: absolute !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
pointer-events: none !important; pointer-events: none !important;
/* Keep the interactive controller above player-owned click/pause panes. */ /* Keep the interactive controller above player-owned click/pause panes. */
z-index: 2147483647 !important; z-index: 2147483647 !important;
@@ -17,7 +13,6 @@
controller in the browser top layer without nesting it inside <video>. */ controller in the browser top layer without nesting it inside <video>. */
.vsc-controller.vsc-fullscreen-popover { .vsc-controller.vsc-fullscreen-popover {
position: fixed !important; position: fixed !important;
inset: auto !important;
margin: 0 !important; margin: 0 !important;
padding: 0 !important; padding: 0 !important;
border: 0 !important; border: 0 !important;
+1289 -339
View File
@@ -1,4 +1,3 @@
var isUserSeek = false; // Track if seek was user-initiated
var lastToggleSpeed = {}; // Store last toggle speeds per video var lastToggleSpeed = {}; // Store last toggle speeds per video
var speederShared = var speederShared =
typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {}; typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {};
@@ -28,6 +27,65 @@ function getSharedDefault(key, fallback) {
return 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) { function getPrimaryVideoElement(mediaElements) {
var candidates = Array.isArray(mediaElements) var candidates = Array.isArray(mediaElements)
? mediaElements ? mediaElements
@@ -39,10 +97,13 @@ function getPrimaryVideoElement(mediaElements) {
candidates.forEach(function(el, index) { candidates.forEach(function(el, index) {
if (!el || !el.vsc || !el.isConnected) return; if (!el || !el.vsc || !el.isConnected) return;
var rect = null; var rect = getCachedVideoRect(el);
try { var hasCachedRect = Boolean(rect);
rect = el.getBoundingClientRect(); if (!rect) {
} catch (_error) {} try {
rect = el.getBoundingClientRect();
} catch (_error) {}
}
var width = rect && Number(rect.width) > 0 ? Number(rect.width) : 0; var width = rect && Number(rect.width) > 0 ? Number(rect.width) : 0;
var height = rect && Number(rect.height) > 0 ? Number(rect.height) : 0; var height = rect && Number(rect.height) > 0 ? Number(rect.height) : 0;
@@ -57,17 +118,24 @@ function getPrimaryVideoElement(mediaElements) {
: 0; : 0;
var visibleArea = visibleWidth * visibleHeight; var visibleArea = visibleWidth * visibleHeight;
var visuallyAvailable = visibleArea > 0; var visuallyAvailable = visibleArea > 0;
try { if (
var computed = win && win.getComputedStyle(el); el.vsc.div &&
if ( el.vsc.div.classList.contains("vsc-geometry-hidden")
computed && ) {
(computed.display === "none" || visuallyAvailable = false;
computed.visibility === "hidden" || } else if (!hasCachedRect) {
Number(computed.opacity) === 0) try {
) { var computed = win && win.getComputedStyle(el);
visuallyAvailable = false; if (
} computed &&
} catch (_error) {} (computed.display === "none" ||
computed.visibility === "hidden" ||
Number(computed.opacity) === 0)
) {
visuallyAvailable = false;
}
} catch (_error) {}
}
var score = visibleArea; var score = visibleArea;
if (visuallyAvailable) score += 1e12; if (visuallyAvailable) score += 1e12;
@@ -90,6 +158,81 @@ function getPrimaryVideoElement(mediaElements) {
return best; 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 = { var tc = {
settings: { settings: {
lastSpeed: getSharedDefault("lastSpeed", 1.0), lastSpeed: getSharedDefault("lastSpeed", 1.0),
@@ -151,25 +294,62 @@ var tc = {
speedAccessTimes: {}, speedAccessTimes: {},
persistedLastSpeed: 1.0, persistedLastSpeed: 1.0,
activeSiteRule: null, activeSiteRule: null,
tabPaused: false,
siteRuleBase: null, siteRuleBase: null,
siteRuleAppliedHref: null,
runtimeSettingsHydrated: false, runtimeSettingsHydrated: false,
rawSyncSettingsSnapshot: null,
pendingMediaCandidates: [], pendingMediaCandidates: [],
pendingMediaCandidateNodes: new WeakSet(),
settingsReloadRetries: 0, settingsReloadRetries: 0,
lastPointerPosition: null, lastPointerPosition: null,
lastInteractedMedia: null lastInteractedMedia: null,
frameToken:
window.crypto && typeof window.crypto.randomUUID === "function"
? window.crypto.randomUUID()
: String(Date.now()) + "-" + Math.random().toString(36).slice(2)
}; };
var MIN_SPEED = Number(keyBindingUtils.MIN_SPEED) || 0.01; function isSpeederActiveForCurrentPage() {
var MAX_SPEED = Number(keyBindingUtils.MAX_SPEED) || 100; 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; var YT_NATIVE_MIN = 0.25;
var YT_NATIVE_MAX = 2.0; var YT_NATIVE_MAX = 2.0;
var YT_NATIVE_STEP = 0.05; var YT_NATIVE_STEP = 0.05;
var vscObservedRoots = new WeakSet(); var vscObservedRoots = new WeakSet();
var vscObservedRootList = []; var vscObservedRootList = [];
var vscSupportsWeakRootReferences = typeof WeakRef === "function";
var vscSuspendedObservedRoots = new WeakSet();
var vscSuspendedObservedRootList = [];
var vscConnectedScannedRoots = new WeakSet(); var vscConnectedScannedRoots = new WeakSet();
var vscInitializedDocuments = new WeakSet(); var vscInitializedDocuments = new WeakSet();
var vscSourceObjectIds = new WeakMap(); var vscSourceObjectIds = new WeakMap();
var vscNextSourceObjectId = 1; var vscNextSourceObjectId = 1;
var vscControllersByMount = new WeakMap();
var vscDeferredMediaCandidates = new Set();
var requestIdle = var requestIdle =
typeof window.requestIdleCallback === "function" typeof window.requestIdleCallback === "function"
? window.requestIdleCallback.bind(window) ? window.requestIdleCallback.bind(window)
@@ -235,21 +415,25 @@ var controllerLocationStyles = {
/* `label` fallback only when ui-icons has no path for the action. */ /* `label` fallback only when ui-icons has no path for the action. */
var controllerButtonDefs = { var controllerButtonDefs = {
rewind: { label: "", className: "rw" }, rewind: { label: "", name: "Rewind", className: "rw" },
slower: { label: "", className: "" }, slower: { label: "", name: "Decrease speed", className: "" },
faster: { label: "", className: "" }, faster: { label: "", name: "Increase speed", className: "" },
advance: { label: "", className: "rw" }, advance: { label: "", name: "Advance", className: "rw" },
display: { label: "", className: "hideButton" }, display: {
reset: { label: "\u21BB", className: "" }, label: "",
fast: { label: "", className: "" }, name: "Show or hide controller",
nudge: { label: "", className: "" }, className: "hideButton"
pause: { label: "", className: "" }, },
muted: { label: "", className: "" }, reset: { label: "\u21BB", name: "Reset speed", className: "" },
louder: { label: "", className: "" }, fast: { label: "", name: "Toggle preferred speed", className: "" },
softer: { label: "", className: "" }, nudge: { label: "", name: "Toggle subtitle nudge", className: "" },
mark: { label: "", className: "" }, pause: { label: "", name: "Play or pause", className: "" },
jump: { label: "", className: "" }, muted: { label: "", name: "Mute or unmute", className: "" },
settings: { label: "", className: "" } louder: { label: "", name: "Increase volume", className: "" },
softer: { label: "", name: "Decrease volume", className: "" },
mark: { label: "", name: "Mark position", className: "" },
jump: { label: "", name: "Jump to marked position", className: "" },
settings: { label: "", name: "Open Speeder settings", className: "" }
}; };
function createDefaultBinding(action, code, value) { function createDefaultBinding(action, code, value) {
@@ -484,6 +668,7 @@ function applyControllerLocation(videoController, location) {
} }
function captureSiteRuleBase() { function captureSiteRuleBase() {
tc.siteRuleAppliedHref = null;
tc.siteRuleBase = { tc.siteRuleBase = {
startHidden: tc.settings.startHidden, startHidden: tc.settings.startHidden,
hideWithControls: tc.settings.hideWithControls, hideWithControls: tc.settings.hideWithControls,
@@ -973,6 +1158,11 @@ function schedulePersistLastSpeed(speed) {
var MAX_PERSISTED_SOURCE_SPEEDS = 200; var MAX_PERSISTED_SOURCE_SPEEDS = 200;
function rememberToggleSpeed(sourceKey, speed) {
if (!sourceKey || !isValidSpeed(speed)) return;
lastToggleSpeed[sourceKey] = Number(speed);
}
function isPersistableSourceKey(sourceKey) { function isPersistableSourceKey(sourceKey) {
return Boolean( return Boolean(
sourceKey && sourceKey &&
@@ -1379,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) { function hasInteractivePlayerChrome(node) {
if (!node || node.nodeName !== "VIDEO") return false; if (!node || node.nodeName !== "VIDEO") return false;
if (node.controls === true) return true; if (node.controls === true) return true;
@@ -1469,12 +1716,12 @@ function hasUsableMediaSource(node) {
} }
if (node.querySelectorAll) { if (node.querySelectorAll) {
return Array.from(node.querySelectorAll("source[src]")).some(function( var sources = node.querySelectorAll("source[src]");
source for (var i = 0; i < sources.length; i += 1) {
) { var source = sources[i];
var src = source.getAttribute("src"); 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; return false;
@@ -1494,10 +1741,9 @@ function ensureController(node, parent) {
if ( if (
node && node &&
(node.nodeName === "VIDEO" || node.nodeName === "AUDIO") && (node.nodeName === "VIDEO" || node.nodeName === "AUDIO") &&
!tc.pendingMediaCandidates.some(function(candidate) { !tc.pendingMediaCandidateNodes.has(node)
return candidate.node === node;
})
) { ) {
tc.pendingMediaCandidateNodes.add(node);
tc.pendingMediaCandidates.push({ node: node, parent: parent }); tc.pendingMediaCandidates.push({ node: node, parent: parent });
} }
return null; return null;
@@ -1505,15 +1751,18 @@ function ensureController(node, parent) {
if (!isMediaElement(node)) return null; if (!isMediaElement(node)) return null;
if (typeof tc.videoController === "undefined") defineVideoController(); if (typeof tc.videoController === "undefined") defineVideoController();
// href selects site rules; re-run on every new/usable media so all runtime // The URL is the only page input to site-rule matching. Reapply once when
// paths agree on activation and effective settings. // it changes, while keeping every media discovery path synchronous.
applySiteRuleOverrides(); if (!reapplySiteRulesAndControllerGeometry()) {
if (!siteRuleUtils.isSpeederActiveForSite(tc.settings.enabled, tc.activeSiteRule)) { disconnectBootstrapMediaSourceObserver(node);
vscDeferredMediaCandidates.delete(node);
if (node.vsc) removeController(node); if (node.vsc) removeController(node);
return null; return null;
} }
if (isAmbientLoopMedia(node)) { if (isAmbientLoopMedia(node)) {
if (node.vsc) removeController(node); if (node.vsc) removeController(node);
disconnectBootstrapMediaSourceObserver(node);
vscDeferredMediaCandidates.add(node);
return null; return null;
} }
@@ -1529,6 +1778,8 @@ function ensureController(node, parent) {
} }
if (node.vsc) { if (node.vsc) {
vscDeferredMediaCandidates.delete(node);
disconnectBootstrapMediaSourceObserver(node);
var existingController = node.vsc; var existingController = node.vsc;
var hasSource = hasUsableMediaSource(node); var hasSource = hasUsableMediaSource(node);
if (existingController.div) { if (existingController.div) {
@@ -1555,6 +1806,8 @@ function ensureController(node, parent) {
} }
if (!hasUsableMediaSource(node)) { if (!hasUsableMediaSource(node)) {
vscDeferredMediaCandidates.add(node);
observeMediaSourceNodes(node);
log( log(
`Deferring controller creation for ${node.tagName}: no usable source yet`, `Deferring controller creation for ${node.tagName}: no usable source yet`,
5 5
@@ -1562,13 +1815,13 @@ function ensureController(node, parent) {
return null; return null;
} }
refreshAllControllerGeometry();
log( log(
`Creating controller for ${node.tagName}: ${node.src || node.currentSrc || "no src"}`, `Creating controller for ${node.tagName}: ${node.src || node.currentSrc || "no src"}`,
4 4
); );
try { try {
vscDeferredMediaCandidates.delete(node);
disconnectBootstrapMediaSourceObserver(node);
node.vsc = new tc.videoController( node.vsc = new tc.videoController(
node, node,
parent || node.parentElement || node.parentNode parent || node.parentElement || node.parentNode
@@ -1576,11 +1829,15 @@ function ensureController(node, parent) {
} catch (error) { } catch (error) {
log(`Unable to create media controller: ${error.message}`, 2); log(`Unable to create media controller: ${error.message}`, 2);
removeController(node); removeController(node);
vscDeferredMediaCandidates.add(node);
observeMediaSourceNodes(node);
return null; return null;
} }
if (!node.vsc || !node.vsc.div) { if (!node.vsc || !node.vsc.div) {
log("Controller initialization returned without a control host", 3); log("Controller initialization returned without a control host", 3);
removeController(node); removeController(node);
vscDeferredMediaCandidates.add(node);
observeMediaSourceNodes(node);
return null; return null;
} }
if ( if (
@@ -1596,6 +1853,7 @@ function ensureController(node, parent) {
function flushPendingMediaCandidates() { function flushPendingMediaCandidates() {
var pending = tc.pendingMediaCandidates.splice(0); var pending = tc.pendingMediaCandidates.splice(0);
pending.forEach(function(candidate) { pending.forEach(function(candidate) {
if (candidate.node) tc.pendingMediaCandidateNodes.delete(candidate.node);
if (!candidate.node || !candidate.node.isConnected) return; if (!candidate.node || !candidate.node.isConnected) return;
ensureController( ensureController(
candidate.node, candidate.node,
@@ -1614,7 +1872,10 @@ function ensureControllerForMediaChild(node) {
} }
function removeController(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; var controller = node.vsc;
if (typeof controller.remove === "function") { if (typeof controller.remove === "function") {
try { try {
@@ -1624,6 +1885,14 @@ function removeController(node) {
log(`Partial controller cleanup failed: ${error.message}`, 3); 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") { if (controller.div && typeof controller.div.remove === "function") {
controller.div.remove(); controller.div.remove();
} }
@@ -1647,8 +1916,9 @@ function scanNodeForMedia(node, parent, added) {
return; return;
} }
var ownerDocument = node.ownerDocument || document; // A removed-node record can represent a move. isConnected works across
if (!added && ownerDocument.body && ownerDocument.body.contains(node)) return; // shadow boundaries; document.body.contains() does not.
if (!added && node.isConnected) return;
// Check if the node itself is a media element // Check if the node itself is a media element
if (isMediaElement(node)) { if (isMediaElement(node)) {
@@ -1688,6 +1958,39 @@ function scanNodeForMedia(node, parent, added) {
observeRoot(node.shadowRoot); 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) { function getScanNodeForRoot(root) {
@@ -1739,52 +2042,208 @@ function rescanOpenShadowRoots(root, visited, rescanObserved) {
hosts.forEach(function(host) { hosts.forEach(function(host) {
var shadowRoot = host.shadowRoot; var shadowRoot = host.shadowRoot;
if (!shadowRoot || seen.has(shadowRoot)) return; if (
!shadowRoot ||
seen.has(shadowRoot) ||
isSpeederControllerShadowRoot(shadowRoot)
) {
return;
}
seen.add(shadowRoot); seen.add(shadowRoot);
if (rescanObserved !== false || !vscObservedRoots.has(shadowRoot)) { if (
rescanObserved !== false ||
!vscObservedRoots.has(shadowRoot) ||
!shadowRoot.vscMutationObserverAttached
) {
observeRoot(shadowRoot); observeRoot(shadowRoot);
} }
rescanOpenShadowRoots(shadowRoot, seen, rescanObserved); rescanOpenShadowRoots(shadowRoot, seen, rescanObserved);
}); });
} }
function rescanObservedMediaRoots(doc) { function isSpeederControllerHost(node) {
var retainedRoots = []; return Boolean(node && node.vscControllerHost === true);
vscObservedRootList.forEach(function(root) { }
if (!root || root.nodeType === Node.DOCUMENT_NODE) return;
var connected = Boolean(root.host ? root.host.isConnected : root.isConnected); function isSpeederControllerShadowRoot(root) {
if (!connected) return; return Boolean(
retainedRoots.push(root); root &&
if ( root.host &&
!doc || isSpeederControllerHost(root.host)
root.ownerDocument === doc || );
(root.host && root.host.ownerDocument === doc) }
) {
scanRootForMedia(root); 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) { function observeRoot(root) {
if (!root) return; if (!root || isSpeederControllerShadowRoot(root)) return;
var isConnected = false; var isConnected = isObservedRootConnected(root);
try {
isConnected = root.nodeType === Node.DOCUMENT_NODE ||
root.isConnected ||
(root.host && (root.host.isConnected || (root.host.ownerDocument && root.host.ownerDocument.contains(root.host)))) ||
(root.ownerDocument && root.ownerDocument.contains(root));
} catch (e) {
isConnected = true;
}
if (!vscObservedRoots.has(root)) { if (!vscObservedRoots.has(root)) {
vscObservedRoots.add(root); vscObservedRoots.add(root);
vscObservedRootList.push(root); }
trackObservedRoot(root);
if (isConnected) {
setupListener(root); setupListener(root);
attachMutationObserver(root);
attachMediaDetectionListeners(root); attachMediaDetectionListeners(root);
if (!root.vscMutationObserverAttached) attachMutationObserver(root);
vscSuspendedObservedRoots.delete(root);
} else {
trackSuspendedObservedRoot(root);
} }
if ( if (
@@ -1822,6 +2281,23 @@ function patchAttachShadow() {
window.vscAttachShadowPatched = true; 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() { function installPageShadowBridge() {
if (window.vscPageShadowBridgeRequested) return; if (window.vscPageShadowBridgeRequested) return;
@@ -1842,11 +2318,24 @@ function installPageShadowBridge() {
); );
document.addEventListener( document.addEventListener(
"speeder-location-changed", "speeder-location-changed",
function() { function(event) {
scheduleNavigationRescan(); scheduleNavigationRescan(event);
}, },
true 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() { function startBoundedShadowFallback() {
@@ -1859,7 +2348,7 @@ function installPageShadowBridge() {
function() { function() {
if ( if (
!document.body || !document.body ||
(delay !== 3000 && window.vscPageShadowBridgeLoaded) window.vscPageShadowBridgeLoaded
) { ) {
return; return;
} }
@@ -1901,14 +2390,19 @@ function installPageShadowBridge() {
return; return;
} }
try { 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"); var bridge = document.createElement("script");
bridge.src = chrome.runtime.getURL("content/shadow-bridge.js"); bridge.src = chrome.runtime.getURL("content/shadow-bridge.js");
bridge.async = false; bridge.async = false;
bridge.addEventListener("load", function() { bridge.addEventListener("load", function() {
bridge.remove(); bridge.remove();
window.vscPageShadowBridgeLoaded = true; if (!window.vscPageShadowBridgeLoaded) handleBridgeFailure();
window.vscPageShadowBridgeRetries = 0;
clearTimeout(window.vscPageShadowBridgeRetryTimer);
}); });
bridge.addEventListener("error", function() { bridge.addEventListener("error", function() {
bridge.remove(); bridge.remove();
@@ -1942,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) { function hydrateRuntimeSettings(rawStorage, options) {
var config = options || {}; var config = options || {};
tc.rawSyncSettingsSnapshot = cloneRuntimeStorageSnapshot(rawStorage || {});
var storage = var storage =
typeof vscExpandStoredSettings === "function" typeof vscExpandStoredSettings === "function"
? vscExpandStoredSettings(rawStorage || {}) ? vscExpandStoredSettings(rawStorage || {})
@@ -2253,6 +2770,7 @@ function scheduleRuntimeSettingsReload() {
clearTimeout(window.vscSettingsReloadTimer); clearTimeout(window.vscSettingsReloadTimer);
clearTimeout(window.vscSettingsReloadRetryTimer); clearTimeout(window.vscSettingsReloadRetryTimer);
window.vscSettingsReloadTimer = setTimeout(function() { window.vscSettingsReloadTimer = setTimeout(function() {
window.vscSettingsReloadTimer = null;
chrome.storage.sync.get(null, function(rawStorage) { chrome.storage.sync.get(null, function(rawStorage) {
if (reloadGeneration !== window.vscSettingsReloadGeneration) return; if (reloadGeneration !== window.vscSettingsReloadGeneration) return;
if (chrome.runtime.lastError) { if (chrome.runtime.lastError) {
@@ -2267,6 +2785,14 @@ function scheduleRuntimeSettingsReload() {
return; return;
} }
tc.settingsReloadRetries = 0; tc.settingsReloadRetries = 0;
if (
runtimeStorageSnapshotsEqual(
rawStorage || {},
tc.rawSyncSettingsSnapshot || {}
)
) {
return;
}
var wasForceEnabled = tc.settings.forceLastSavedSpeed === true; var wasForceEnabled = tc.settings.forceLastSavedSpeed === true;
var wasRememberEnabled = tc.settings.rememberSpeed === true; var wasRememberEnabled = tc.settings.rememberSpeed === true;
hydrateRuntimeSettings(rawStorage || {}, { hydrateRuntimeSettings(rawStorage || {}, {
@@ -2289,17 +2815,57 @@ function loadInitialLocalSettings(attempt, callback) {
chrome.storage.local.get( chrome.storage.local.get(
["customButtonIcons", "rememberedSpeeds", "rememberedSpeedsResetAt"], ["customButtonIcons", "rememberedSpeeds", "rememberedSpeedsResetAt"],
function(localStorage) { function(localStorage) {
if (chrome.runtime.lastError && attempt < 3) { var readFailed = Boolean(chrome.runtime.lastError);
if (readFailed && attempt < 3) {
setTimeout(function() { setTimeout(function() {
loadInitialLocalSettings(attempt + 1, callback); loadInitialLocalSettings(attempt + 1, callback);
}, 100 * (attempt + 1)); }, 100 * (attempt + 1));
return; 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 // Patch attachShadow immediately — before any async operations — so we
// catch shadow roots created while chrome.storage.sync.get is pending. // catch shadow roots created while chrome.storage.sync.get is pending.
// Sites like archive.org create Lit/LitElement shadow DOMs during page load; // Sites like archive.org create Lit/LitElement shadow DOMs during page load;
@@ -2309,19 +2875,32 @@ installPageShadowBridge();
function loadInitialRuntimeSettings(attempt) { function loadInitialRuntimeSettings(attempt) {
chrome.storage.sync.get(null, function(rawStorage) { 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() { setTimeout(function() {
loadInitialRuntimeSettings(attempt + 1); loadInitialRuntimeSettings(attempt + 1);
}, 100 * (attempt + 1)); }, 100 * (attempt + 1));
return; return;
} }
hydrateRuntimeSettings(rawStorage || {}); 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 // patchAttachShadow() is now called at top-level before this callback
// Add a listener for messages from the popup. // Add a listener for messages from the popup.
// We use a global flag to ensure the listener is only attached once. // We use a global flag to ensure the listener is only attached once.
if (!window.vscMessageListener) { if (!window.vscMessageListener) {
chrome.runtime.onMessage.addListener( chrome.runtime.onMessage.addListener(
function(request, sender, sendResponse) { 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") { if (request.action === "rescan_page") {
log("Re-scan command received from popup.", 4); log("Re-scan command received from popup.", 4);
initializeWhenReady(document, true); initializeWhenReady(document, true);
@@ -2335,6 +2914,8 @@ function loadInitialRuntimeSettings(attempt) {
if (!videoGs) return false; if (!videoGs) return false;
sendResponse({ sendResponse({
speed: videoGs.playbackRate, speed: videoGs.playbackRate,
frameToken: tc.frameToken,
diagnostics: getDiagnosticsSnapshot(videoGs),
forceLastSavedSpeed: tc.settings.forceLastSavedSpeed === true, forceLastSavedSpeed: tc.settings.forceLastSavedSpeed === true,
forceLastSavedSpeedControlledBySiteRule: Boolean( forceLastSavedSpeedControlledBySiteRule: Boolean(
tc.activeSiteRule && tc.activeSiteRule &&
@@ -2372,10 +2953,13 @@ function loadInitialRuntimeSettings(attempt) {
} }
if (request.action === "run_action") { if (request.action === "run_action") {
if ( if (
!siteRuleUtils.isSpeederActiveForSite( request.targetFrameToken &&
tc.settings.enabled, request.targetFrameToken !== tc.frameToken
tc.activeSiteRule ) {
) return false;
}
if (
!isSpeederActiveForCurrentPage()
) { ) {
return false; return false;
} }
@@ -2398,7 +2982,7 @@ function loadInitialRuntimeSettings(attempt) {
// Set the flag to prevent adding the listener again. // Set the flag to prevent adding the listener again.
window.vscMessageListener = true; window.vscMessageListener = true;
} }
loadInitialLocalSettings(0, function(loc) { loadInitialLocalSettings(0, function(loc, initialLocalReadFailed) {
applyCustomButtonIcons(loc && loc.customButtonIcons); applyCustomButtonIcons(loc && loc.customButtonIcons);
applyRememberedSpeedsResetMarker( applyRememberedSpeedsResetMarker(
@@ -2416,6 +3000,9 @@ function loadInitialRuntimeSettings(attempt) {
if (isLastSpeedOnly) { if (isLastSpeedOnly) {
var changedLastSpeed = Number(changes.lastSpeed.newValue); var changedLastSpeed = Number(changes.lastSpeed.newValue);
if (isValidSpeed(changedLastSpeed)) { if (isValidSpeed(changedLastSpeed)) {
if (tc.rawSyncSettingsSnapshot) {
tc.rawSyncSettingsSnapshot.lastSpeed = changedLastSpeed;
}
tc.persistedLastSpeed = changedLastSpeed; tc.persistedLastSpeed = changedLastSpeed;
var newerLocalLastSpeed = isValidSpeed(tc.pendingLastSpeedValue) var newerLocalLastSpeed = isValidSpeed(tc.pendingLastSpeedValue)
? Number(tc.pendingLastSpeedValue) ? Number(tc.pendingLastSpeedValue)
@@ -2518,48 +3105,65 @@ function loadInitialRuntimeSettings(attempt) {
applyCustomButtonIcons(nv); applyCustomButtonIcons(nv);
}); });
} }
// Close the small gap between the initial sync/local reads and listener // A temporary listener covered the gap before the durable listener above
// registration. A final read also converges if Options saved while this // was registered. Verify storage only when it actually changed during
// content script was starting. // startup instead of issuing two extra reads in every frame.
scheduleRuntimeSettingsReload(); var startupStorageChanges = finishStartupStorageWatch();
var customIconGeneration = if (initialSyncReadFailed) startupStorageChanges.sync = true;
Number(window.vscCustomIconGeneration) || 0; if (initialLocalReadFailed) startupStorageChanges.local = true;
var rememberedSpeedsGeneration = if (startupStorageChanges.sync) scheduleRuntimeSettingsReload();
Number(window.vscRememberedSpeedsGeneration) || 0; if (startupStorageChanges.local) {
chrome.storage.local.get( var customIconGeneration =
["customButtonIcons", "rememberedSpeeds", "rememberedSpeedsResetAt"], Number(window.vscCustomIconGeneration) || 0;
function(latestLocal) { var rememberedSpeedsGeneration =
if (chrome.runtime.lastError) return; Number(window.vscRememberedSpeedsGeneration) || 0;
if ( loadInitialLocalSettings(
customIconGeneration === 0,
(Number(window.vscCustomIconGeneration) || 0) function(latestLocal, latestLocalReadFailed) {
) { if (latestLocalReadFailed) return;
applyCustomButtonIcons(latestLocal && latestLocal.customButtonIcons);
}
if (
rememberedSpeedsGeneration ===
(Number(window.vscRememberedSpeedsGeneration) || 0)
) {
applyRememberedSpeedsResetMarker(
latestLocal && latestLocal.rememberedSpeedsResetAt
);
mergeRememberedSpeeds(latestLocal && latestLocal.rememberedSpeeds);
var latestRememberedSpeeds =
latestLocal && latestLocal.rememberedSpeeds;
if ( if (
!rememberedSpeedsPayloadMatches( customIconGeneration ===
latestRememberedSpeeds, (Number(window.vscCustomIconGeneration) || 0)
buildRememberedSpeedsPayload()
)
) { ) {
schedulePersistRememberedSpeeds(true); var latestCustomButtonIcons =
(latestLocal && latestLocal.customButtonIcons) || {};
if (
!runtimeStorageSnapshotsEqual(
latestCustomButtonIcons,
tc.settings.customButtonIcons || {}
)
) {
applyCustomButtonIcons(latestCustomButtonIcons);
}
} }
if (tc.settings.rememberSpeed || tc.settings.forceLastSavedSpeed) { if (
tc.mediaElements.slice().forEach(applyRememberedSpeedPolicy); 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; tc.runtimeSettingsHydrated = true;
initializeWhenReady(document); initializeWhenReady(document);
}); });
@@ -2569,6 +3173,7 @@ function loadInitialRuntimeSettings(attempt) {
// Install before async settings hydration so SPA-owned window capture handlers // Install before async settings hydration so SPA-owned window capture handlers
// cannot hide later key events from Speeder. // cannot hide later key events from Speeder.
attachKeydownListeners(document); attachKeydownListeners(document);
beginStartupStorageWatch();
loadInitialRuntimeSettings(0); loadInitialRuntimeSettings(0);
function getKeyBindings(action, what = "value") { function getKeyBindings(action, what = "value") {
@@ -2590,7 +3195,11 @@ function setKeyBindings(action, value) {
function createControllerButton(doc, action, label, className) { function createControllerButton(doc, action, label, className) {
var button = doc.createElement("button"); var button = doc.createElement("button");
var name = controllerButtonDefs[action] && controllerButtonDefs[action].name;
button.type = "button";
button.dataset.action = action; button.dataset.action = action;
button.setAttribute("aria-label", name || action);
button.title = name || action;
var custom = var custom =
tc.settings.customButtonIcons && tc.settings.customButtonIcons &&
tc.settings.customButtonIcons[action] && tc.settings.customButtonIcons[action] &&
@@ -2766,8 +3375,6 @@ function getControllerMount(video, boundary) {
return isShadowRootNode(directRoot) ? directRoot : null; return isShadowRootNode(directRoot) ? directRoot : null;
} }
var mountBoundary = null;
var videoRect = video.getBoundingClientRect(); var videoRect = video.getBoundingClientRect();
var mount = video.parentElement; var mount = video.parentElement;
var candidate = mount; var candidate = mount;
@@ -2785,15 +3392,7 @@ function getControllerMount(video, boundary) {
// Climb through tightly-sized wrappers so our host shares their stacking // Climb through tightly-sized wrappers so our host shares their stacking
// context, but stop before broad page-layout containers. // context, but stop before broad page-layout containers.
while (candidate && candidate.parentElement && depth < 5) { while (candidate && candidate.parentElement && depth < 5) {
if (mountBoundary && candidate === mountBoundary) break;
var next = candidate.parentElement; var next = candidate.parentElement;
if (
mountBoundary &&
next !== mountBoundary &&
!mountBoundary.contains(next)
) {
break;
}
var nextRect = next.getBoundingClientRect(); var nextRect = next.getBoundingClientRect();
var widthLimit = Math.max(videoRect.width * 1.35, videoRect.width + 80); var widthLimit = Math.max(videoRect.width * 1.35, videoRect.width + 80);
var heightLimit = Math.max(videoRect.height * 1.35, videoRect.height + 80); var heightLimit = Math.max(videoRect.height * 1.35, videoRect.height + 80);
@@ -2817,10 +3416,6 @@ function getControllerMount(video, boundary) {
candidate = next; candidate = next;
depth += 1; 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 // Never climb out of a player-owned stacking context. Doing so lets the
// controller's high local z-index escape above sticky page headers. // controller's high local z-index escape above sticky page headers.
if (createsControllerStackingContext(next)) break; if (createsControllerStackingContext(next)) break;
@@ -2848,6 +3443,7 @@ function positionControllerHost(wrapper, video, mount) {
return; return;
} }
var videoRect = video.getBoundingClientRect(); var videoRect = video.getBoundingClientRect();
cacheVideoRect(wrapper, video, videoRect);
if (wrapper.classList.contains("vsc-fullscreen-popover")) { if (wrapper.classList.contains("vsc-fullscreen-popover")) {
if (videoRect.width <= 0 || videoRect.height <= 0) { if (videoRect.width <= 0 || videoRect.height <= 0) {
wrapper.classList.add("vsc-geometry-hidden"); wrapper.classList.add("vsc-geometry-hidden");
@@ -2942,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) { function setupControllerHostTracking(videoController, wrapper, mount) {
if (!videoController || !wrapper || !mount) return; if (!videoController || !wrapper || !mount) return;
@@ -3016,25 +3663,41 @@ function setupControllerHostTracking(videoController, wrapper, mount) {
resizeObserver.observe(geometryMount); resizeObserver.observe(geometryMount);
} }
win.addEventListener("resize", schedule, { passive: true }); var mediaGeometryEvents = ["loadedmetadata", "play", "playing"];
doc.addEventListener("fullscreenchange", schedule, { passive: true }); mediaGeometryEvents.forEach(function(eventName) {
videoController.video.addEventListener(eventName, schedule, {
passive: true
});
});
attachSharedViewportScrollTracking(win);
attachSharedControllerResizeListener(win);
geometryMount.addEventListener("scroll", schedule, { passive: true }); geometryMount.addEventListener("scroll", schedule, { passive: true });
update(); update();
videoController.controllerHostMount = mount; videoController.controllerHostMount = mount;
registerControllerMount(videoController, mount);
videoController.controllerHostSchedule = schedule;
videoController.controllerHostCleanup = function(forceRestore) { videoController.controllerHostCleanup = function(forceRestore) {
unregisterControllerMount(videoController, mount);
if (resizeObserver) resizeObserver.disconnect(); if (resizeObserver) resizeObserver.disconnect();
if (frameId !== null) win.cancelAnimationFrame(frameId); if (frameId !== null) win.cancelAnimationFrame(frameId);
if (geometryRetryTimer !== null) win.clearTimeout(geometryRetryTimer); if (geometryRetryTimer !== null) win.clearTimeout(geometryRetryTimer);
win.removeEventListener("resize", schedule); mediaGeometryEvents.forEach(function(eventName) {
doc.removeEventListener("fullscreenchange", schedule); videoController.video.removeEventListener(eventName, schedule);
});
geometryMount.removeEventListener("scroll", schedule); geometryMount.removeEventListener("scroll", schedule);
if (videoController.controllerHostSchedule === schedule) {
videoController.controllerHostSchedule = null;
}
var hasOtherController = false; var hasOtherController = false;
try { try {
hasOtherController = Array.from( hasOtherController = Array.from(
mount.querySelectorAll(".vsc-controller") mount.querySelectorAll(".vsc-controller")
).some(function(controllerHost) { ).some(function(controllerHost) {
return controllerHost !== wrapper; return (
controllerHost !== wrapper &&
isSpeederControllerHost(controllerHost)
);
}); });
} catch (e) {} } catch (e) {}
if ( if (
@@ -3107,16 +3770,33 @@ function remountControllerHost(videoController, mount) {
videoController.div.isConnected && videoController.div.isConnected &&
videoController.div.parentNode === mount videoController.div.parentNode === mount
) { ) {
// Keep the max-z-index host last among equal-z-index player overlays. // Keep controller hosts after page-owned overlays, but do not make multiple
if (videoController.div.nextSibling) { // 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); mount.appendChild(videoController.div);
return true; return true;
} }
positionControllerHost( if (typeof videoController.controllerHostSchedule === "function") {
videoController.div, videoController.controllerHostSchedule();
videoController.video, } else {
mount positionControllerHost(
); videoController.div,
videoController.video,
mount
);
}
return false; return false;
} }
@@ -3149,14 +3829,14 @@ function disableDirectFullscreenPopover(videoController) {
wrapper.removeAttribute("popover"); wrapper.removeAttribute("popover");
} }
function enableFullscreenPopover(videoController, preferredMount) { function enableDirectFullscreenPopover(videoController) {
if (!videoController || !videoController.video || !videoController.div) { if (!videoController || !videoController.video || !videoController.div) {
return false; return false;
} }
var wrapper = videoController.div; var wrapper = videoController.div;
if (typeof wrapper.showPopover !== "function") return false; if (typeof wrapper.showPopover !== "function") return false;
var normalMount = preferredMount || videoController.normalControllerMount; var normalMount = videoController.normalControllerMount;
var normalMountIsConnected = Boolean( var normalMountIsConnected = Boolean(
normalMount && normalMount &&
(normalMount.isConnected || (normalMount.isConnected ||
@@ -3196,12 +3876,20 @@ function syncControllerFullscreenMount(videoController) {
var doc = video.ownerDocument; var doc = video.ownerDocument;
var fullscreenElement = getFullscreenElement(doc); var fullscreenElement = getFullscreenElement(doc);
var targetMount = videoController.normalControllerMount; var targetMount = videoController.normalControllerMount;
var ownsFullscreen = Boolean(
if (
fullscreenElement && fullscreenElement &&
(fullscreenElement === video || (fullscreenElement === video ||
isComposedDescendant(video, fullscreenElement)) isComposedDescendant(video, fullscreenElement))
) { );
var normalGeometryMount = getControllerGeometryMount(targetMount);
var normalMountIsAlreadyFullscreenVisible = Boolean(
fullscreenElement &&
fullscreenElement !== video &&
normalGeometryMount &&
isComposedDescendant(normalGeometryMount, fullscreenElement)
);
if (ownsFullscreen && !normalMountIsAlreadyFullscreenVisible) {
targetMount = getControllerMount(video, fullscreenElement); targetMount = getControllerMount(video, fullscreenElement);
} else if (!fullscreenElement && (!targetMount || !targetMount.isConnected)) { } else if (!fullscreenElement && (!targetMount || !targetMount.isConnected)) {
targetMount = getControllerMount(video); targetMount = getControllerMount(video);
@@ -3210,13 +3898,10 @@ function syncControllerFullscreenMount(videoController) {
if (!targetMount) return false; if (!targetMount) return false;
if (fullscreenElement) { if (fullscreenElement === video) {
// Fullscreen elements and popovers both participate in the browser's top // A replaced <video> cannot paint author children, so direct-media
// layer. Showing Speeder's host after the player enters fullscreen keeps it // fullscreen is the only case that needs a separate top-layer popover.
// above provider-owned surfaces even when the provider clips descendants or if (enableDirectFullscreenPopover(videoController)) return true;
// 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;
} else { } else {
disableDirectFullscreenPopover(videoController); disableDirectFullscreenPopover(videoController);
} }
@@ -3242,12 +3927,15 @@ function defineVideoController() {
this.suppressedRateChangeCount = 0; this.suppressedRateChangeCount = 0;
this.suppressedRateChangeUntil = 0; this.suppressedRateChangeUntil = 0;
this.visibilityResumeHandler = null; this.visibilityResumeHandler = null;
this.dragCleanup = null;
this.lifecycleReconcileFrame = null;
this.resetToggleArmed = false; this.resetToggleArmed = false;
this.resetButtonEl = null; this.resetButtonEl = null;
this.controllerLocation = normalizeControllerLocation( this.controllerLocation = normalizeControllerLocation(
tc.settings.controllerLocation tc.settings.controllerLocation
); );
this.structureSignature = getControllerStructureSignature(); this.structureSignature = getControllerStructureSignature();
attachFullscreenListeners(target.ownerDocument);
log(`Creating video controller for ${target.tagName} with src: ${target.src || target.currentSrc || "none"}`, 4); log(`Creating video controller for ${target.tagName} with src: ${target.src || target.currentSrc || "none"}`, 4);
@@ -3358,9 +4046,6 @@ function defineVideoController() {
setSpeed(event.target, expectedSpeed, false, false); setSpeed(event.target, expectedSpeed, false, false);
} }
if (isUserSeek) {
isUserSeek = false;
}
} }
}; };
@@ -3407,23 +4092,21 @@ function defineVideoController() {
var srcObserver = new MutationObserver((mutations) => { var srcObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => { mutations.forEach((mutation) => {
if ( if (isMediaSourceAttributeMutation(mutation, this.video)) {
mutation.type === "attributes" &&
(mutation.attributeName === "src" ||
mutation.attributeName === "currentSrc")
) {
log("mutation of A/V element", 5); log("mutation of A/V element", 5);
if (this.div) { if (this.div) {
this.stopSubtitleNudge(); this.stopSubtitleNudge();
if (!mutation.target.src && !mutation.target.currentSrc) { var sourceAvailable = hasUsableMediaSource(this.video);
this.div.classList.toggle( this.div.classList.toggle("vsc-nosource", !sourceAvailable);
"vsc-nosource", if (!sourceAvailable) {
!hasUsableMediaSource(mutation.target) this.mediaSourceKey = "unknown_src";
); this.targetSpeedSourceKey = "unknown_src";
} else { } else {
this.div.classList.remove("vsc-nosource"); applySourceTransitionPolicy(
applySourceTransitionPolicy(this.video, true); this.video,
if (!mutation.target.paused) this.startSubtitleNudge(); mutation.target === this.video
);
if (!this.video.paused) this.startSubtitleNudge();
} }
updateSubtitleNudgeIndicator(this.video); updateSubtitleNudgeIndicator(this.video);
} }
@@ -3431,7 +4114,11 @@ function defineVideoController() {
}); });
}); });
this.srcObserver = srcObserver; 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) if (!target.paused && target.playbackRate !== 1.0)
this.startSubtitleNudge(); this.startSubtitleNudge();
}; };
@@ -3439,6 +4126,18 @@ function defineVideoController() {
tc.videoController.prototype.remove = function() { tc.videoController.prototype.remove = function() {
this.stopSubtitleNudge(); this.stopSubtitleNudge();
disableDirectFullscreenPopover(this); 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 ( if (
this.visibilityResumeHandler && this.visibilityResumeHandler &&
this.video && this.video &&
@@ -3463,7 +4162,22 @@ function defineVideoController() {
this.genericAutoHideCleanup(); this.genericAutoHideCleanup();
this.genericAutoHideCleanup = null; 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) { if (this.controllerHostCleanup) {
this.controllerHostCleanup(); this.controllerHostCleanup();
this.controllerHostCleanup = null; this.controllerHostCleanup = null;
@@ -3754,11 +4468,6 @@ function defineVideoController() {
timer = setTimeout(() => { timer = setTimeout(() => {
timer = null; timer = null;
if (this.controllerInteractionActive) return; 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"); wrapper.classList.add("vsc-idle-hidden");
log("Generic hide: controller hidden due to inactivity", 5); log("Generic hide: controller hidden due to inactivity", 5);
}, tc.settings.hideWithControlsTimer * 1000); }, tc.settings.hideWithControlsTimer * 1000);
@@ -3781,8 +4490,8 @@ function defineVideoController() {
// Initial show/timer // Initial show/timer
resetTimer(); resetTimer();
// The wrapper covers the player area on most sites due to inject.css styles, // Players dispatch activity at different layers, so observe the media,
// but we listen on both the video and the wrapper for maximum coverage. // aligned controller host, and player mount.
const activityEvents = ["mousemove", "mousedown", "keydown", "touchstart"]; const activityEvents = ["mousemove", "mousedown", "keydown", "touchstart"];
const parentEl = const parentEl =
getControllerGeometryMount(this.controllerHostMount) || getControllerGeometryMount(this.controllerHostMount) ||
@@ -3824,6 +4533,10 @@ function defineVideoController() {
const speed = this.video.playbackRate.toFixed(2); const speed = this.video.playbackRate.toFixed(2);
var wrapper = doc.createElement("div"); var wrapper = doc.createElement("div");
wrapper.classList.add("vsc-controller"); 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)) if (!hasUsableMediaSource(this.video))
wrapper.classList.add("vsc-nosource"); wrapper.classList.add("vsc-nosource");
if (tc.settings.startHidden) wrapper.classList.add("vsc-hidden"); if (tc.settings.startHidden) wrapper.classList.add("vsc-hidden");
@@ -3861,11 +4574,14 @@ function defineVideoController() {
buttonConfig.forEach(function(btnId) { buttonConfig.forEach(function(btnId) {
if (btnId === "nudge") { if (btnId === "nudge") {
subtitleNudgeIndicator = doc.createElement("span"); subtitleNudgeIndicator = createControllerButton(
doc,
btnId,
controllerButtonDefs.nudge.label,
controllerButtonDefs.nudge.className
);
subtitleNudgeIndicator.id = "nudge-indicator"; subtitleNudgeIndicator.id = "nudge-indicator";
subtitleNudgeIndicator.setAttribute("role", "button");
subtitleNudgeIndicator.setAttribute("aria-live", "polite"); subtitleNudgeIndicator.setAttribute("aria-live", "polite");
subtitleNudgeIndicator.setAttribute("tabindex", "0");
controls.appendChild(subtitleNudgeIndicator); controls.appendChild(subtitleNudgeIndicator);
} else { } else {
var def = controllerButtonDefs[btnId]; var def = controllerButtonDefs[btnId];
@@ -3949,21 +4665,6 @@ function defineVideoController() {
true true
); );
}); });
if (subtitleNudgeIndicator) {
subtitleNudgeIndicator.addEventListener(
"click",
(e) => {
var video = this.video;
if (video) {
var newState = !isSubtitleNudgeEnabledForVideo(video);
setSubtitleNudgeEnabledForVideo(video, newState);
}
blurAfterPointerTap(subtitleNudgeIndicator, e);
e.stopPropagation();
},
true
);
}
controller.addEventListener("click", (e) => e.stopPropagation(), false); controller.addEventListener("click", (e) => e.stopPropagation(), false);
controller.addEventListener("mousedown", (e) => e.stopPropagation(), false); controller.addEventListener("mousedown", (e) => e.stopPropagation(), false);
@@ -4033,10 +4734,12 @@ function defineVideoController() {
} }
function applySiteRuleOverrides() { function applySiteRuleOverrides() {
var currentUrl = location.href;
var wasForceEnabled = tc.settings.forceLastSavedSpeed === true; var wasForceEnabled = tc.settings.forceLastSavedSpeed === true;
var wasRememberEnabled = tc.settings.rememberSpeed === true; var wasRememberEnabled = tc.settings.rememberSpeed === true;
function finishSiteRuleApplication(result) { function finishSiteRuleApplication(result) {
tc.siteRuleAppliedHref = currentUrl;
if ( if (
(wasForceEnabled && !tc.settings.forceLastSavedSpeed) || (wasForceEnabled && !tc.settings.forceLastSavedSpeed) ||
(wasRememberEnabled && !tc.settings.rememberSpeed) (wasRememberEnabled && !tc.settings.rememberSpeed)
@@ -4053,7 +4756,6 @@ function applySiteRuleOverrides() {
return finishSiteRuleApplication(false); return finishSiteRuleApplication(false);
} }
var currentUrl = location.href;
var matchedRule = siteRuleUtils.matchSiteRule(currentUrl, tc.settings.siteRules); var matchedRule = siteRuleUtils.matchSiteRule(currentUrl, tc.settings.siteRules);
if (!matchedRule) { if (!matchedRule) {
@@ -4129,12 +4831,14 @@ function applySiteRuleOverrides() {
function removeIneligibleMediaControllers() { function removeIneligibleMediaControllers() {
tc.mediaElements.slice().forEach(function(media) { tc.mediaElements.slice().forEach(function(media) {
if ( if (!media) return;
media && if (media.nodeName === "AUDIO" && !tc.settings.audioBoolean) {
((media.nodeName === "AUDIO" && !tc.settings.audioBoolean) ||
isAmbientLoopMedia(media))
) {
removeController(media); removeController(media);
return;
}
if (isAmbientLoopMedia(media)) {
removeController(media);
vscDeferredMediaCandidates.add(media);
} }
}); });
} }
@@ -4152,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() { function reapplySiteRulesAndControllerGeometry() {
if (tc.siteRuleAppliedHref === location.href) {
return isSpeederActiveForCurrentPage();
}
applySiteRuleOverrides(); applySiteRuleOverrides();
if (!siteRuleUtils.isSpeederActiveForSite(tc.settings.enabled, tc.activeSiteRule)) { if (!isSpeederActiveForCurrentPage()) {
tc.mediaElements.slice().forEach(function(video) { tc.mediaElements.slice().forEach(function(video) {
removeController(video); removeController(video);
}); });
@@ -4314,7 +5022,11 @@ function clearPendingInitialization(doc) {
function tryInitializeDocument(doc, forceReinit) { function tryInitializeDocument(doc, forceReinit) {
if (!doc) return false; if (!doc) return false;
if ((!forceReinit && vscInitializedDocuments.has(doc)) || !doc.body) { if (
!tc.runtimeSettingsHydrated ||
(!forceReinit && vscInitializedDocuments.has(doc)) ||
!doc.body
) {
return false; return false;
} }
@@ -4357,6 +5069,32 @@ function inIframe() {
} }
} }
function isEditableShortcutTarget(event) {
var path =
event && typeof event.composedPath === "function"
? event.composedPath()
: [event && event.target];
return path.some(function(target) {
if (!target || target.nodeType !== 1) return false;
var nodeName = target.nodeName;
var role = target.getAttribute && target.getAttribute("role");
var contentEditable =
target.getAttribute && target.getAttribute("contenteditable");
return (
nodeName === "INPUT" ||
nodeName === "TEXTAREA" ||
nodeName === "SELECT" ||
target.isContentEditable ||
(contentEditable !== null && contentEditable !== "false") ||
role === "textbox" ||
role === "searchbox" ||
role === "combobox" ||
role === "spinbutton"
);
});
}
function attachKeydownListeners(doc) { function attachKeydownListeners(doc) {
// Content scripts already run in every frame. Keeping each listener scoped // Content scripts already run in every frame. Keeping each listener scoped
// to its own frame avoids duplicate shortcuts and stale iframe ownership. // to its own frame avoids duplicate shortcuts and stale iframe ownership.
@@ -4368,6 +5106,7 @@ function attachKeydownListeners(doc) {
keyTarget.addEventListener( keyTarget.addEventListener(
"keydown", "keydown",
function(event) { function(event) {
if (!tc.runtimeSettingsHydrated || !tc.mediaElements.length) return;
if ( if (
!event.getModifierState || !event.getModifierState ||
event.getModifierState("Alt") || event.getModifierState("Alt") ||
@@ -4380,25 +5119,14 @@ function attachKeydownListeners(doc) {
return; return;
} }
if (isEditableShortcutTarget(event)) return;
if ( if (
event.target.nodeName === "INPUT" || !isSpeederActiveForCurrentPage()
event.target.nodeName === "TEXTAREA" ||
event.target.isContentEditable
) { ) {
return; return;
} }
if (
!siteRuleUtils.isSpeederActiveForSite(
tc.settings.enabled,
tc.activeSiteRule
)
) {
return;
}
if (!tc.mediaElements.length) return;
var item = tc.settings.keyBindings.find(function(binding) { var item = tc.settings.keyBindings.find(function(binding) {
return matchesKeyBinding(binding, event); return matchesKeyBinding(binding, event);
}); });
@@ -4429,11 +5157,14 @@ function attachMediaTargetTracking(doc) {
if (!Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) { if (!Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) {
return; return;
} }
tc.lastPointerPosition = { if (
document: doc, !tc.lastPointerPosition ||
x: event.clientX, tc.lastPointerPosition.document !== doc
y: event.clientY ) {
}; tc.lastPointerPosition = { document: doc, x: 0, y: 0 };
}
tc.lastPointerPosition.x = event.clientX;
tc.lastPointerPosition.y = event.clientY;
}, },
{ capture: true, passive: true } { capture: true, passive: true }
); );
@@ -4443,109 +5174,226 @@ function attachMediaTargetTracking(doc) {
function attachMutationObserver(root) { function attachMutationObserver(root) {
if (root.vscMutationObserverAttached) return; 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 mutationProcessingScheduled = false;
var observer = new MutationObserver(function(mutations) { 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; if (mutationProcessingScheduled) return;
mutationProcessingScheduled = true; mutationProcessingScheduled = true;
requestIdle( requestIdle(
function() { function() {
var mutationsToProcess = pendingMutations.splice(0);
mutationProcessingScheduled = false; 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) { // A detached shadow root can be suspended while this idle task is
if (mutation.type === "childList") { // queued. Ignore its stale summary; reconnecting the root performs a
controllerMountTargets.add(mutation.target); // fresh media scan.
mutation.addedNodes.forEach(function(node) { if (root.vscMutationObserver !== observer) return;
// 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);
});
return; compactMutationScanCandidates(removedCandidates).forEach(
function(candidate) {
scanNodeForMedia(
candidate.node,
candidate.node.parentNode || candidate.parent,
false
);
} }
);
if (mutation.type !== "attributes") return; compactMutationScanCandidates(addedCandidates).forEach(
function(candidate) {
var target = mutation.target; scanNodeForMedia(
if ( candidate.node,
isMediaElement(target) && candidate.node.parentNode || candidate.parent,
(mutation.attributeName === "src" || true
mutation.attributeName === "currentSrc") );
) {
ensureController(target, target.parentElement || target.parentNode);
return;
} }
);
ariaHiddenTargets.forEach(function(target) {
if ( if (
target.nodeName === "SOURCE" && target &&
mutation.attributeName === "src" target.getAttribute &&
) { target.getAttribute("aria-hidden") === "false"
ensureControllerForMediaChild(target);
return;
}
if (
mutation.attributeName === "aria-hidden" &&
target.attributes["aria-hidden"] &&
target.attributes["aria-hidden"].value === "false"
) { ) {
scanNodeForMedia( scanNodeForMedia(
target, target,
target.parentNode || root.host || mutation.target, target.parentNode || root.host || target,
true 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 // Reconcile once per batch, not once per mutation. Large news/social
// pages can emit hundreds of childList records in one render pass. // pages can emit hundreds of childList records in one render pass.
if (controllerMountTargets.size > 0) { if (controllerMountTargets.size > 0) {
tc.mediaElements.slice().forEach(function(video) { controllerMountTargets.forEach(function(mount) {
if (!video || !video.vsc || !video.vsc.div) return; var controllers = vscControllersByMount.get(mount);
var mount = video.vsc.controllerHostMount; if (!controllers) return;
if (!controllerMountTargets.has(mount)) return; Array.from(controllers).forEach(function(videoController) {
remountControllerHost(video.vsc, mount); if (!videoController || !videoController.div) return;
remountControllerHost(videoController, mount);
});
}); });
} }
// Document selectors do not cross shadow boundaries. A detached if (removedCandidates.length > 0) {
// custom-element host can therefore hide connected-looking media from // Document selectors do not cross shadow boundaries. A detached
// the removed subtree scan; prune by composed connectivity as a final // custom-element host can therefore hide media from the removed
// reconciliation step for every mutation batch. // subtree scan; prune once, then suspend detached-root observers.
tc.mediaElements.slice().forEach(function(video) { tc.mediaElements.slice().forEach(function(video) {
if (!video || video.isConnected) return; if (!video || video.isConnected) return;
removeController(video); removeController(video);
}); });
Array.from(vscDeferredMediaCandidates).forEach(function(media) {
if (!media || media.isConnected) return;
vscDeferredMediaCandidates.delete(media);
disconnectBootstrapMediaSourceObserver(media);
});
reconcileObservedRootConnections(
getObservedRootDocument(root),
false
);
}
}, },
{ timeout: 1000 } { timeout: 1000 }
); );
}); });
observer.observe(root, { observer.observe(root, {
attributeFilter: ["aria-hidden", "src", "currentSrc"], attributeFilter: ["aria-hidden"],
childList: true, childList: true,
subtree: true, subtree: true,
attributes: true attributes: true
}); });
root.vscMutationObserver = observer;
root.vscMutationObserverAttached = true; 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) { function attachMediaDetectionListeners(root) {
if (root.vscMediaEventListenersAttached) return; if (root.vscMediaEventListenersAttached) return;
var handleDetectedMedia = function(event) { var handleDetectedMedia = function(event) {
var target = event.target; var target = event.target;
if (!isMediaElement(target)) return; 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); ensureController(target, target.parentElement || target.parentNode);
}; };
@@ -4562,23 +5410,133 @@ function attachMediaDetectionListeners(root) {
root.vscMediaEventListenersAttached = true; root.vscMediaEventListenersAttached = true;
} }
function scheduleNavigationRescan() { function scheduleNavigationRescan(event) {
var nextHref = location.href; var nextHref = location.href;
if (window.vscLastObservedHref !== nextHref) { var urlChanged = window.vscLastObservedHref !== nextHref;
// Pointer and controller interaction state belongs to the previous route. var navigationSignaled = Boolean(event && event.type);
// YouTube keeps hover-preview and Shorts players connected across SPA var forceYouTubeReconcile = Boolean(
// navigations, so retaining either target can send shortcuts to an event && event.type === "yt-navigate-finish"
// off-screen/stale player even though the new watch controller works. );
tc.lastPointerPosition = null; 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; tc.lastInteractedMedia = null;
} }
window.vscLastObservedHref = nextHref; window.vscLastObservedHref = nextHref;
window.vscNavigationForceDiscovery =
window.vscNavigationForceDiscovery === true || forceYouTubeReconcile;
if (!urlChanged && window.vscNavigationRescanTimer) return;
clearTimeout(window.vscNavigationRescanTimer); clearTimeout(window.vscNavigationRescanTimer);
window.vscNavigationRescanTimer = setTimeout(function() { window.vscNavigationRescanTimer = setTimeout(function() {
initializeWhenReady(document, true); window.vscNavigationRescanTimer = null;
var forceDiscovery = window.vscNavigationForceDiscovery === true;
window.vscNavigationForceDiscovery = false;
reconcileAfterNavigation(document, forceDiscovery);
}, 300); }, 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() { function attachNavigationListeners() {
if (window.vscNavigationListenersAttached) return; if (window.vscNavigationListenersAttached) return;
@@ -4587,7 +5545,7 @@ function attachNavigationListeners() {
var original = history[method]; var original = history[method];
history[method] = function() { history[method] = function() {
var result = original.apply(this, arguments); var result = original.apply(this, arguments);
scheduleNavigationRescan(); scheduleNavigationRescan({ type: "history-state" });
return result; return result;
}; };
}); });
@@ -4599,13 +5557,7 @@ function attachNavigationListeners() {
document.addEventListener("yt-navigate-finish", scheduleNavigationRescan); document.addEventListener("yt-navigate-finish", scheduleNavigationRescan);
} }
window.vscLastObservedHref = location.href; window.vscLastObservedHref = location.href;
if (!window.vscLocationWatchTimer) { startLocationWatchFallback();
window.vscLocationWatchTimer = setInterval(function() {
if (window.vscLastObservedHref !== location.href) {
scheduleNavigationRescan();
}
}, 1000);
}
window.vscNavigationListenersAttached = true; window.vscNavigationListenersAttached = true;
} }
@@ -4616,16 +5568,15 @@ function initializeNow(doc, forceReinit = false) {
// later whitelisted SPA route can never reactivate the extension. // later whitelisted SPA route can never reactivate the extension.
attachNavigationListeners(); attachNavigationListeners();
if (typeof tc.videoController === "undefined") defineVideoController(); if (typeof tc.videoController === "undefined") defineVideoController();
applySiteRuleOverrides(); if (tc.siteRuleAppliedHref !== location.href) applySiteRuleOverrides();
var isActive = siteRuleUtils.isSpeederActiveForSite( var isActive = isSpeederActiveForCurrentPage();
tc.settings.enabled,
tc.activeSiteRule
);
// Keep observing while inactive so dynamically-created media/shadow roots // Keep observing while inactive so dynamically-created media/shadow roots
// are available to the next forced SPA rescan, but remove stale controls. // are available to the next forced SPA rescan, but remove stale controls.
observeRoot(doc); observeRoot(doc);
resumeSuspendedObservedRoots(doc);
if (!isActive) { if (!isActive) {
clearDeferredMediaCandidates();
tc.mediaElements.slice().forEach(function(video) { tc.mediaElements.slice().forEach(function(video) {
removeController(video); removeController(video);
}); });
@@ -4772,16 +5723,7 @@ function getClosestMediaToPointer(candidates, pointerPosition) {
candidates.forEach(function(video) { candidates.forEach(function(video) {
if (!video || !video.vsc || !video.isConnected) return; if (!video || !video.vsc || !video.isConnected) return;
var target = getControllerElement(video.vsc) || video; var rect = getCachedVideoRect(video);
var rect = null;
try {
rect = target.getBoundingClientRect();
if (!rect || rect.width <= 0 || rect.height <= 0) {
rect = video.getBoundingClientRect();
}
} catch (_error) {
return;
}
if (!rect || rect.width <= 0 || rect.height <= 0) return; if (!rect || rect.width <= 0 || rect.height <= 0) return;
var distance = distanceSquaredToRect( var distance = distanceSquaredToRect(
pointerPosition.x, pointerPosition.x,
@@ -4910,12 +5852,10 @@ function runAction(action, value, e) {
); );
switch (action) { switch (action) {
case "rewind": case "rewind":
isUserSeek = true;
extendSpeedRestoreWindow(v); extendSpeedRestoreWindow(v);
v.currentTime -= numValue; v.currentTime -= numValue;
break; break;
case "advance": case "advance":
isUserSeek = true;
extendSpeedRestoreWindow(v); extendSpeedRestoreWindow(v);
v.currentTime += numValue; v.currentTime += numValue;
break; break;
@@ -5055,7 +5995,7 @@ function resetSpeed(v, target, isFastKey = false) {
setSpeed(v, lastToggle, false, true); setSpeed(v, lastToggle, false, true);
} else { } else {
// Not at preferred speed, save current as toggle speed and go to preferred // Not at preferred speed, save current as toggle speed and go to preferred
lastToggleSpeed[videoId] = currentSpeed; rememberToggleSpeed(videoId, currentSpeed);
setSpeed(v, preferredSpeed, false, true); setSpeed(v, preferredSpeed, false, true);
} }
} else { } else {
@@ -5072,7 +6012,7 @@ function resetSpeed(v, target, isFastKey = false) {
setSpeed(v, speedToRestore, false, true, true); setSpeed(v, speedToRestore, false, true, true);
} else { } else {
// Not at 1.0, save current as toggle speed and go to 1.0 // Not at 1.0, save current as toggle speed and go to 1.0
lastToggleSpeed[videoId] = currentSpeed; rememberToggleSpeed(videoId, currentSpeed);
v.vsc.resetToggleArmed = true; v.vsc.resetToggleArmed = true;
setSpeed(v, resetSpeedValue, false, true, true); setSpeed(v, resetSpeedValue, false, true, true);
} }
@@ -5138,6 +6078,8 @@ function jumpToMark(v) {
function handleDrag(video, e) { function handleDrag(video, e) {
const c = video.vsc.div; const c = video.vsc.div;
const dragController = video.vsc;
if (dragController.dragCleanup) dragController.dragCleanup();
const sC = convertControllerToManualPosition(video.vsc); const sC = convertControllerToManualPosition(video.vsc);
if (!sC) return; if (!sC) return;
var pE = var pE =
@@ -5176,7 +6118,11 @@ function handleDrag(video, e) {
pE.removeEventListener("mouseleave", eD); pE.removeEventListener("mouseleave", eD);
sC.classList.remove("dragging"); sC.classList.remove("dragging");
video.classList.remove("vcs-dragging"); video.classList.remove("vcs-dragging");
if (dragController.dragCleanup === eD) {
dragController.dragCleanup = null;
}
}; };
dragController.dragCleanup = eD;
pE.addEventListener("mouseup", eD); pE.addEventListener("mouseup", eD);
pE.addEventListener("mouseleave", eD); pE.addEventListener("mouseleave", eD);
pE.addEventListener("mousemove", sD); pE.addEventListener("mousemove", sD);
@@ -5221,11 +6167,15 @@ function handleFullscreenControllerTransition() {
}); });
} }
[ function attachFullscreenListeners(doc) {
"fullscreenchange", if (!doc || doc.vscFullscreenListenersAttached) return;
"webkitfullscreenchange", [
"mozfullscreenchange", "fullscreenchange",
"MSFullscreenChange" "webkitfullscreenchange",
].forEach(function(eventName) { "mozfullscreenchange",
document.addEventListener(eventName, handleFullscreenControllerTransition); "MSFullscreenChange"
}); ].forEach(function(eventName) {
doc.addEventListener(eventName, handleFullscreenControllerTransition);
});
doc.vscFullscreenListenersAttached = true;
}
+40 -1
View File
@@ -1,7 +1,28 @@
(function() { (function() {
"use strict"; "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; window.__speederPageShadowBridgeInstalled = true;
@@ -46,4 +67,22 @@
return result; 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();
})(); })();
+23 -5
View File
@@ -1,15 +1,28 @@
:host { :host {
position: absolute !important; position: absolute !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
pointer-events: none !important; pointer-events: none !important;
z-index: 2147483647 !important; z-index: 2147483647 !important;
white-space: normal; white-space: normal;
overflow: visible !important; overflow: visible !important;
} }
/* Important declarations inside a shadow tree outrank important page styles.
Repeat the top-layer positioning here so fullscreen cannot fall back to the
base absolute host rule. */
:host(.vsc-fullscreen-popover) {
position: fixed !important;
margin: 0 !important;
padding: 0 !important;
border: 0 !important;
background: transparent !important;
overflow: visible !important;
}
:host(.vsc-fullscreen-popover)::backdrop {
pointer-events: none !important;
background: transparent !important;
}
:host(.vsc-nosource), :host(.vsc-nosource),
:host(.vsc-hidden) { :host(.vsc-hidden) {
display: none !important; display: none !important;
@@ -316,10 +329,15 @@ button .vsc-btn-icon svg {
transform: translateY(0.5px); transform: translateY(0.5px);
} }
button:focus { button:focus:not(:focus-visible) {
outline: 0; outline: 0;
} }
button:focus-visible {
outline: 2px solid #fff;
outline-offset: 2px;
}
button:hover { button:hover {
opacity: 1; opacity: 1;
} }
+1 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "Speeder", "name": "Speeder",
"short_name": "Speeder", "short_name": "Speeder",
"version": "6.0.3", "version": "6.0.9.0",
"manifest_version": 2, "manifest_version": 2,
"description": "Speed up, slow down, advance and rewind HTML5 audio/video with shortcuts (New and improved version of \"Video Speed Controller\")", "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", "homepage_url": "https://github.com/SoPat712/speeder",
@@ -71,7 +71,6 @@
} }
], ],
"web_accessible_resources": [ "web_accessible_resources": [
"content/inject.css",
"content/shadow-bridge.js", "content/shadow-bridge.js",
"content/shadow.css" "content/shadow.css"
] ]
+5 -4
View File
@@ -215,14 +215,15 @@ function importSettings() {
importLocalSettings(scopedLocalSettings, function (localError) { importLocalSettings(scopedLocalSettings, function (localError) {
if (localError) { if (localError) {
showStatus( showStatus(
"Error: Failed to save local extension data - " + "Settings imported, but custom icons could not be updated - " +
localError.message, localError.message +
". Reloading...",
true true
); );
return; } else {
showStatus("Settings imported successfully. Reloading...");
} }
showStatus("Settings imported successfully. Reloading...");
setTimeout(function () { setTimeout(function () {
if (typeof restore_options === "function") { if (typeof restore_options === "function") {
restore_options(); restore_options();
+29 -9
View File
@@ -7,6 +7,7 @@
--text: #17191c; --text: #17191c;
--muted: #626b76; --muted: #626b76;
--accent: #111827; --accent: #111827;
--focus-ring: #2563eb;
--switch-track-off: #c1cad6; --switch-track-off: #c1cad6;
--switch-track-off-border: #aeb8c5; --switch-track-off-border: #aeb8c5;
--switch-track-on: #111827; --switch-track-on: #111827;
@@ -177,9 +178,14 @@ a:visited {
text-underline-offset: 0.14em; text-underline-offset: 0.14em;
} }
a:hover, a:hover {
a:focus { color: var(--text);
color: #000; text-decoration-thickness: 2px;
}
a:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
} }
code { code {
@@ -225,7 +231,7 @@ input[type="checkbox"]:focus-visible,
input[type="text"]:focus, input[type="text"]:focus,
select:focus, select:focus,
textarea:focus { textarea:focus {
outline: 2px solid rgba(17, 24, 39, 0.14); outline: 2px solid var(--focus-ring);
outline-offset: 2px; outline-offset: 2px;
} }
@@ -322,7 +328,7 @@ label em {
.shortcut-row { .shortcut-row {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 120px 120px; grid-template-columns: minmax(0, 1fr) 120px 120px 38px;
gap: 12px; gap: 12px;
align-items: center; align-items: center;
padding: 10px 0; padding: 10px 0;
@@ -334,10 +340,6 @@ label em {
border-top: 0; border-top: 0;
} }
.shortcut-row.customs {
grid-template-columns: minmax(0, 1fr) 120px 120px 38px;
}
.shortcut-label { .shortcut-label {
color: var(--text); color: var(--text);
font-weight: 500; font-weight: 500;
@@ -628,6 +630,9 @@ label em {
cursor: grab; cursor: grab;
user-select: none; user-select: none;
transition: box-shadow 150ms ease, opacity 150ms ease; transition: box-shadow 150ms ease, opacity 150ms ease;
color: var(--text);
font-weight: 500;
text-align: left;
} }
.cb-block:hover { .cb-block:hover {
@@ -1061,6 +1066,20 @@ button.lucide-result-tile.lucide-picked {
font-weight: 500; font-weight: 500;
} }
#status:not(:empty) {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 1000;
min-height: 0;
margin: 0;
padding: 10px 14px;
border: 1px solid var(--border-strong);
border-radius: 999px;
background: var(--panel);
box-shadow: 0 8px 24px rgba(17, 24, 39, 0.16);
}
#status:empty { #status:empty {
display: none; display: none;
} }
@@ -1229,6 +1248,7 @@ button.lucide-result-tile.lucide-picked {
--text: #f2f4f6; --text: #f2f4f6;
--muted: #a0a8b2; --muted: #a0a8b2;
--accent: #f2f4f6; --accent: #f2f4f6;
--focus-ring: #93c5fd;
--switch-track-off: #374151; --switch-track-off: #374151;
--switch-track-off-border: #4b5563; --switch-track-off-border: #4b5563;
--switch-track-on: #aab7c6; --switch-track-on: #aab7c6;
+14 -13
View File
@@ -468,8 +468,8 @@
<div class="section-heading"> <div class="section-heading">
<h3>Hover control bar</h3> <h3>Hover control bar</h3>
<p class="section-intro"> <p class="section-intro">
Drag blocks to reorder. Move between Active and Available to Drag blocks or use arrow keys to reorder. Press a block to
show or hide buttons. move it between Active and Available.
</p> </p>
</div> </div>
<div class="cb-editor"> <div class="cb-editor">
@@ -591,7 +591,7 @@
<div <div
id="lucideIconResults" id="lucideIconResults"
class="lucide-icon-results" class="lucide-icon-results"
role="listbox" role="group"
aria-label="Matching Lucide icons" aria-label="Matching Lucide icons"
></div> ></div>
<p id="lucideIconStatus" class="lucide-icon-status" aria-live="polite"></p> <p id="lucideIconStatus" class="lucide-icon-status" aria-live="polite"></p>
@@ -843,7 +843,7 @@
</div> </div>
<div class="site-rule-option site-rule-option-field"> <div class="site-rule-option site-rule-option-field">
<label <label
>Preferred speed (0.01&ndash;100):<br /><em >Preferred speed (0.1&ndash;16):<br /><em
>Overrides the Preferred speed action for matching >Overrides the Preferred speed action for matching
URLs. It does not force every video to start at this URLs. It does not force every video to start at this
speed.</em speed.</em
@@ -943,8 +943,8 @@
<label class="site-override-lead"> <label class="site-override-lead">
<span <span
>Override in-player control bar for this site<br /><em >Override in-player control bar for this site<br /><em
>Same idea as Hover control bar: drag blocks between >Drag blocks or use the keyboard to arrange Active and
Active and Available for matching URLs only.</em Available buttons for matching URLs only.</em
></span ></span
> >
<input type="checkbox" class="override-controlbar" /> <input type="checkbox" class="override-controlbar" />
@@ -998,8 +998,8 @@
<label class="site-override-lead"> <label class="site-override-lead">
<span <span
>Override shortcuts for this site<br /><em >Override shortcuts for this site<br /><em
>Add shortcuts from the menu; none by default. Leave off >A site shortcut replaces global bindings for the same
to use global Shortcuts.</em action; other global shortcuts remain active.</em
></span ></span
> >
<input type="checkbox" class="override-shortcuts" /> <input type="checkbox" class="override-shortcuts" />
@@ -1029,6 +1029,7 @@
<button id="restore">Restore Defaults</button> <button id="restore">Restore Defaults</button>
<button id="exportSettings">Export Settings</button> <button id="exportSettings">Export Settings</button>
<button id="importSettings">Import Settings</button> <button id="importSettings">Import Settings</button>
<button id="copyDiagnostics">Copy Diagnostics</button>
</div> </div>
<div id="status" role="status" aria-live="polite"></div> <div id="status" role="status" aria-live="polite"></div>
@@ -1037,11 +1038,11 @@
<section id="faq" class="settings-card info-card"> <section id="faq" class="settings-card info-card">
<h4>Extension controls not appearing?</h4> <h4>Extension controls not appearing?</h4>
<p> <p>
This extension only works with HTML5 audio and video. If the Speeder works with HTML5 video and, when enabled above, HTML5 audio.
controls never appear, you may be looking at Flash content instead. Check that the current site is enabled, then use the popup&rsquo;s
Right-click the player to check: if the menu mentions Flash, that &ldquo;Rescan page for videos&rdquo; action after a player loads
is the issue. Most sites will fall back to HTML5 when Flash is not dynamically. Browser-internal pages and players that do not expose
available, so disabling Flash in the browser can help. HTML5 media to extensions cannot be controlled.
</p> </p>
</section> </section>
+177 -24
View File
@@ -165,6 +165,7 @@ function createDefaultBinding(action, code, value) {
var tcDefaults = vscGetSettingsDefaults(); var tcDefaults = vscGetSettingsDefaults();
var optionsSyncSettingsLoaded = false; var optionsSyncSettingsLoaded = false;
var autoSaveTimer = null; var autoSaveTimer = null;
var saveStatusTimer = null;
function scheduleAutoSave() { function scheduleAutoSave() {
if (!optionsSyncSettingsLoaded) return; if (!optionsSyncSettingsLoaded) return;
@@ -179,7 +180,7 @@ function scheduleAutoSave() {
return; return;
} }
autoSaveTimer = null; autoSaveTimer = null;
save_options(); save_options(true);
}, 300); }, 300);
autoSaveTimer = scheduledTimer; autoSaveTimer = scheduledTimer;
} }
@@ -671,6 +672,20 @@ function appendSelectOptions(select, options) {
}); });
} }
function labelShortcutRow(row) {
if (!row) return;
var action = row.dataset.action;
var name = actionLabels[action] || action || "Shortcut";
var keyInput = row.querySelector(".customKey");
var valueInput = row.querySelector(".customValue");
var removeButton = row.querySelector(".removeParent");
if (keyInput) keyInput.setAttribute("aria-label", name + " key");
if (valueInput) valueInput.setAttribute("aria-label", name + " value");
if (removeButton) {
removeButton.setAttribute("aria-label", "Remove " + name + " shortcut");
}
}
function add_shortcut(action, value) { function add_shortcut(action, value) {
if (!action) return; if (!action) return;
@@ -710,6 +725,7 @@ function add_shortcut(action, value) {
div.appendChild(keyInput); div.appendChild(keyInput);
div.appendChild(valueInput); div.appendChild(valueInput);
div.appendChild(removeButton); div.appendChild(removeButton);
labelShortcutRow(div);
var customsElement = document.querySelector(".shortcuts-grid"); var customsElement = document.querySelector(".shortcuts-grid");
customsElement.appendChild(div); customsElement.appendChild(div);
@@ -792,9 +808,8 @@ function validate() {
if (pattern.startsWith("/")) { if (pattern.startsWith("/")) {
try { try {
var lastSlash = pattern.lastIndexOf("/"); var lastSlash = pattern.lastIndexOf("/");
if (lastSlash > 0) { if (lastSlash === 0) throw new Error("Missing closing slash");
new RegExp(pattern.substring(1, lastSlash), pattern.substring(lastSlash + 1)); new RegExp(pattern.substring(1, lastSlash), pattern.substring(lastSlash + 1));
}
} catch (err) { } catch (err) {
status.textContent = status.textContent =
"Error: Invalid site rule regex: " + pattern + ". Unable to save"; "Error: Invalid site rule regex: " + pattern + ". Unable to save";
@@ -807,8 +822,9 @@ function validate() {
return valid; return valid;
} }
function save_options() { function save_options(isAutoSave) {
clearTimeout(autoSaveTimer); clearTimeout(autoSaveTimer);
clearTimeout(saveStatusTimer);
autoSaveTimer = null; autoSaveTimer = null;
var status = document.getElementById("status"); var status = document.getElementById("status");
if (!optionsSyncSettingsLoaded) { if (!optionsSyncSettingsLoaded) {
@@ -1121,8 +1137,8 @@ function save_options() {
status.textContent = "Error: Unable to save options - " + error.message; status.textContent = "Error: Unable to save options - " + error.message;
return; return;
} }
status.textContent = "Options saved"; status.textContent = isAutoSave === true ? "Auto-saved" : "Options saved";
setTimeout(function () { saveStatusTimer = setTimeout(function () {
status.textContent = ""; status.textContent = "";
}, 1000); }, 1000);
}); });
@@ -1143,15 +1159,7 @@ function addSiteRuleShortcut(rowsEl, action, binding, value, force) {
var actionLabel = document.createElement("div"); var actionLabel = document.createElement("div");
actionLabel.className = "shortcut-label"; actionLabel.className = "shortcut-label";
var actionLabelText = actionLabels[action] || action; actionLabel.textContent = actionLabels[action] || action;
if (action === "toggleSubtitleNudge") {
var ruleEl = rowsEl.closest(".site-rule");
var pattern = ruleEl ? ruleEl.querySelector(".site-pattern").value : "";
if (!pattern.toLowerCase().includes("youtube.com")) {
actionLabelText += " (only for YouTube embeds)";
}
}
actionLabel.textContent = actionLabelText;
var keyInput = document.createElement("input"); var keyInput = document.createElement("input");
keyInput.className = "customKey"; keyInput.className = "customKey";
@@ -1199,10 +1207,40 @@ function addSiteRuleShortcut(rowsEl, action, binding, value, force) {
div.appendChild(valueInput); div.appendChild(valueInput);
div.appendChild(forceLabel); div.appendChild(forceLabel);
div.appendChild(removeButton); div.appendChild(removeButton);
labelShortcutRow(div);
rowsEl.appendChild(div); rowsEl.appendChild(div);
} }
var siteRuleControlId = 0;
function associateSiteRuleLabels(ruleEl) {
ruleEl.querySelectorAll(".site-rule-option").forEach(function(option) {
var label = Array.from(option.children).find(function(child) {
return child.tagName === "LABEL";
});
var controls = option.querySelectorAll("input, select, textarea");
if (!label || controls.length !== 1 || label.contains(controls[0])) return;
controls[0].id = "site-rule-control-" + ++siteRuleControlId;
label.htmlFor = controls[0].id;
});
ruleEl.querySelectorAll(".margin-pad-cell").forEach(function(cell) {
var input = cell.querySelector("input");
var miniLabel = cell.querySelector(".margin-pad-mini");
if (input && miniLabel) {
input.setAttribute(
"aria-label",
"Controller margin " +
(miniLabel.textContent === "T" ? "top" : "bottom")
);
}
});
var removeRule = ruleEl.querySelector(".remove-site-rule");
if (removeRule) removeRule.setAttribute("aria-label", "Remove site rule");
}
function createSiteRule(rule) { function createSiteRule(rule) {
var template = document.getElementById("siteRuleTemplate"); var template = document.getElementById("siteRuleTemplate");
var clone = template.content.cloneNode(true); var clone = template.content.cloneNode(true);
@@ -1375,6 +1413,7 @@ function createSiteRule(rule) {
} }
applySiteRuleOverrideState(ruleEl, "override-shortcuts", "site-shortcuts-container"); applySiteRuleOverrideState(ruleEl, "override-shortcuts", "site-shortcuts-container");
refreshSiteRuleAddShortcutSelector(ruleEl); refreshSiteRuleAddShortcutSelector(ruleEl);
associateSiteRuleLabels(ruleEl);
document.getElementById("siteRulesContainer").appendChild(ruleEl); document.getElementById("siteRulesContainer").appendChild(ruleEl);
} }
@@ -1383,7 +1422,8 @@ function createControlBarBlock(buttonId) {
var def = controllerButtonDefs[buttonId]; var def = controllerButtonDefs[buttonId];
if (!def) return null; if (!def) return null;
var block = document.createElement("div"); var block = document.createElement("button");
block.type = "button";
block.className = "cb-block"; block.className = "cb-block";
block.dataset.buttonId = buttonId; block.dataset.buttonId = buttonId;
block.draggable = true; block.draggable = true;
@@ -1406,6 +1446,25 @@ function createControlBarBlock(buttonId) {
return block; return block;
} }
function updateControlBarBlockLabels(editor) {
if (!editor) return;
editor.querySelectorAll(".cb-dropzone").forEach(function(zone) {
var state = zone.classList.contains("cb-active-zone")
? "active"
: "available";
zone.querySelectorAll(".cb-block").forEach(function(block) {
var def = controllerButtonDefs[block.dataset.buttonId];
block.setAttribute(
"aria-label",
(def ? def.name : block.dataset.buttonId) +
", " +
state +
". Press Enter to move; use arrow keys to reorder."
);
});
});
}
function populateControlBarZones(activeZone, availableZone, activeIds, allowButtonId) { function populateControlBarZones(activeZone, availableZone, activeIds, allowButtonId) {
vscClearElement(activeZone); vscClearElement(activeZone);
vscClearElement(availableZone); vscClearElement(availableZone);
@@ -1428,6 +1487,8 @@ function populateControlBarZones(activeZone, availableZone, activeIds, allowButt
if (block) availableZone.appendChild(block); if (block) availableZone.appendChild(block);
} }
}); });
updateControlBarBlockLabels(activeZone.closest(".cb-editor"));
} }
function readControlBarOrder(activeZone) { function readControlBarOrder(activeZone) {
@@ -1471,11 +1532,11 @@ function updatePopupEditorDisabledState() {
var checkbox = document.getElementById("popupMatchHoverControls"); var checkbox = document.getElementById("popupMatchHoverControls");
var wrap = document.getElementById("popupCbEditorWrap"); var wrap = document.getElementById("popupCbEditorWrap");
if (!checkbox || !wrap) return; if (!checkbox || !wrap) return;
if (checkbox.checked) { wrap.classList.toggle("cb-editor-disabled", checkbox.checked);
wrap.classList.add("cb-editor-disabled"); wrap.querySelectorAll(".cb-block").forEach(function(block) {
} else { block.disabled = checkbox.checked;
wrap.classList.remove("cb-editor-disabled"); block.draggable = !checkbox.checked;
} });
} }
function getDragAfterElement(container, x, y) { function getDragAfterElement(container, x, y) {
@@ -1498,6 +1559,8 @@ function getDragAfterElement(container, x, y) {
} }
function initControlBarEditor() { function initControlBarEditor() {
if (document.vscControlBarEditorInitialized) return;
document.vscControlBarEditorInitialized = true;
var draggedBlock = null; var draggedBlock = null;
function clearControlBarDropTargets(activeZone) { function clearControlBarDropTargets(activeZone) {
@@ -1510,7 +1573,7 @@ function initControlBarEditor() {
document.addEventListener("dragstart", function (e) { document.addEventListener("dragstart", function (e) {
var block = e.target.closest(".cb-block"); var block = e.target.closest(".cb-block");
if (!block) return; if (!block || block.disabled) return;
draggedBlock = block; draggedBlock = block;
e.dataTransfer.effectAllowed = "move"; e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", block.dataset.buttonId); e.dataTransfer.setData("text/plain", block.dataset.buttonId);
@@ -1555,11 +1618,49 @@ function initControlBarEditor() {
var zone = e.target.closest(".cb-dropzone"); var zone = e.target.closest(".cb-dropzone");
if (zone) { if (zone) {
e.preventDefault(); e.preventDefault();
updateControlBarBlockLabels(zone.closest(".cb-editor"));
scheduleAutoSave(); scheduleAutoSave();
} }
clearControlBarDropTargets(null); clearControlBarDropTargets(null);
}); });
document.addEventListener("click", function(e) {
var block = e.target.closest ? e.target.closest(".cb-block") : null;
if (!block || block.disabled) return;
var editor = block.closest(".cb-editor");
var currentZone = block.closest(".cb-dropzone");
if (!editor || !currentZone) return;
var otherZone = editor.querySelector(
currentZone.classList.contains("cb-active-zone")
? ".cb-available-zone"
: ".cb-active-zone"
);
if (!otherZone) return;
otherZone.appendChild(block);
updateControlBarBlockLabels(editor);
block.focus();
scheduleAutoSave();
});
document.addEventListener("keydown", function(e) {
var block = e.target.closest ? e.target.closest(".cb-block") : null;
if (!block || block.disabled) return;
var previous = e.key === "ArrowLeft" || e.key === "ArrowUp";
var next = e.key === "ArrowRight" || e.key === "ArrowDown";
if (!previous && !next) return;
var sibling = previous ? block.previousElementSibling : block.nextElementSibling;
if (!sibling) return;
e.preventDefault();
if (previous) {
block.parentNode.insertBefore(block, sibling);
} else {
block.parentNode.insertBefore(block, sibling.nextElementSibling);
}
updateControlBarBlockLabels(block.closest(".cb-editor"));
block.focus();
scheduleAutoSave();
});
} }
var lucidePickerSelectedSlug = null; var lucidePickerSelectedSlug = null;
@@ -1631,6 +1732,10 @@ function initLucideButtonIconsUI() {
b.dataset.slug = slug; b.dataset.slug = slug;
b.title = slug; b.title = slug;
b.setAttribute("aria-label", slug); b.setAttribute("aria-label", slug);
b.setAttribute(
"aria-pressed",
slug === lucidePickerSelectedSlug ? "true" : "false"
);
if (slug === lucidePickerSelectedSlug) { if (slug === lucidePickerSelectedSlug) {
b.classList.add("lucide-picked"); b.classList.add("lucide-picked");
} }
@@ -1652,6 +1757,10 @@ function initLucideButtonIconsUI() {
resultsEl.querySelectorAll("button"), resultsEl.querySelectorAll("button"),
function (x) { function (x) {
x.classList.toggle("lucide-picked", x.dataset.slug === slug); x.classList.toggle("lucide-picked", x.dataset.slug === slug);
x.setAttribute(
"aria-pressed",
x.dataset.slug === slug ? "true" : "false"
);
} }
); );
fetchLucideSvg(slug) fetchLucideSvg(slug)
@@ -1717,7 +1826,7 @@ function initLucideButtonIconsUI() {
slug + slug +
" for " + " for " +
action + action +
". Reload pages for the hover bar." ". Open pages update automatically."
); );
}); });
}) })
@@ -1900,6 +2009,13 @@ function restore_options(callback) {
} }
function restore_defaults() { function restore_defaults() {
if (
!window.confirm(
"Restore all defaults? This removes saved preferences, remembered speeds, and custom icons."
)
) {
return;
}
var status = document.getElementById("status"); var status = document.getElementById("status");
var restoreButton = document.getElementById("restore"); var restoreButton = document.getElementById("restore");
setOptionsSyncSettingsLoaded(false); setOptionsSyncSettingsLoaded(false);
@@ -1979,6 +2095,8 @@ document.addEventListener("DOMContentLoaded", function () {
versionElement.textContent = manifest.version; versionElement.textContent = manifest.version;
} }
document.querySelectorAll("#customs .shortcut-row").forEach(labelShortcutRow);
restore_options(); restore_options();
initControlBarEditor(); initControlBarEditor();
@@ -1986,6 +2104,41 @@ document.addEventListener("DOMContentLoaded", function () {
.addEventListener("change", updatePopupEditorDisabledState); .addEventListener("change", updatePopupEditorDisabledState);
document.getElementById("save").addEventListener("click", save_options); 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"); const addSelector = document.getElementById("addShortcutSelector");
if (addSelector) { if (addSelector) {
+23 -10
View File
@@ -4,8 +4,9 @@
--border: #e2e5e9; --border: #e2e5e9;
--border-strong: #d4d9e0; --border-strong: #d4d9e0;
--text: #17191c; --text: #17191c;
--muted: #626b76; --muted: #626b76;
--accent: #111827; --accent: #111827;
--focus-ring: #2563eb;
} }
* { * {
@@ -83,10 +84,15 @@ button:active {
background: #f1f3f5; background: #f1f3f5;
} }
button:focus-visible { button:focus-visible {
outline: 2px solid rgba(17, 24, 39, 0.14); outline: 2px solid var(--focus-ring);
outline-offset: 2px; outline-offset: 2px;
} }
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
#refresh { #refresh {
background: var(--accent); background: var(--accent);
@@ -112,6 +118,12 @@ button:focus-visible {
border-color: #9fbd98; border-color: #9fbd98;
color: #285d21; color: #285d21;
} }
#pauseTab[aria-pressed="true"] {
background: #fff3d6;
border-color: #d8b66b;
color: #704f0d;
}
.popup-divider { .popup-divider {
height: 1px; height: 1px;
@@ -250,8 +262,8 @@ button:focus-visible {
background: #f1f3f5; background: #f1f3f5;
} }
.donate-icon-btn:focus-visible { .donate-icon-btn:focus-visible {
outline: 2px solid rgba(17, 24, 39, 0.14); outline: 2px solid var(--focus-ring);
outline-offset: 2px; outline-offset: 2px;
position: relative; position: relative;
z-index: 1; z-index: 1;
@@ -295,8 +307,9 @@ button:focus-visible {
--border: #2b3138; --border: #2b3138;
--border-strong: #3a414a; --border-strong: #3a414a;
--text: #f2f4f6; --text: #f2f4f6;
--muted: #a0a8b2; --muted: #a0a8b2;
--accent: #f2f4f6; --accent: #f2f4f6;
--focus-ring: #93c5fd;
} }
body { body {
+14 -1
View File
@@ -24,6 +24,12 @@
type="button" type="button"
aria-pressed="false" aria-pressed="false"
>Force last saved speed</button> >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 class="popup-divider"></div>
<div id="popupControlBar" class="popup-control-bar"> <div id="popupControlBar" class="popup-control-bar">
<span id="popupSpeed" class="popup-speed">1.00</span> <span id="popupSpeed" class="popup-speed">1.00</span>
@@ -32,10 +38,17 @@
<button id="enable" class="hide">Enable</button> <button id="enable" class="hide">Enable</button>
<button id="disable">Disable</button> <button id="disable">Disable</button>
</div> </div>
<div id="status" class="popup-status hide"></div> <div
id="status"
class="popup-status hide"
role="status"
aria-live="polite"
></div>
<div class="popup-links"> <div class="popup-links">
<button id="config">Settings</button> <button id="config">Settings</button>
<div class="popup-secondary"> <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="feedback" class="secondary">Feedback</button>
<button id="about" class="secondary">About</button> <button id="about" class="secondary">About</button>
</div> </div>
+208 -16
View File
@@ -1,4 +1,6 @@
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
if (window.vscPopupInitialized) return;
window.vscPopupInitialized = true;
var speederShared = var speederShared =
typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {}; typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {};
var siteRuleUtils = speederShared.siteRules || {}; var siteRuleUtils = speederShared.siteRules || {};
@@ -27,6 +29,63 @@ document.addEventListener("DOMContentLoaded", function () {
var popupExcludedButtonIds = new Set(["settings"]); var popupExcludedButtonIds = new Set(["settings"]);
var renderToken = 0; var renderToken = 0;
var forceLastSavedSpeedControlledBySiteRule = null; 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) { function persistExpandedSettings(rawStorage, settings, callback) {
var mutation = vscBuildManagedStorageMutation(rawStorage, settings); var mutation = vscBuildManagedStorageMutation(rawStorage, settings);
@@ -123,16 +182,23 @@ document.addEventListener("DOMContentLoaded", function () {
} }
function getActiveTabContext(callback) { 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) { chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
var activeTab = tabs && tabs[0] ? tabs[0] : null; var activeTab = tabs && tabs[0] ? tabs[0] : null;
if (!activeTab || !activeTab.id) { if (!activeTab || !activeTab.id) {
if (callback) callback({ tab: null, url: "" }); finish({ tab: null, url: "" });
return; return;
} }
var tabUrl = typeof activeTab.url === "string" ? activeTab.url : ""; var tabUrl = typeof activeTab.url === "string" ? activeTab.url : "";
if (tabUrl.length > 0) { if (tabUrl.length > 0) {
if (callback) callback({ tab: activeTab, url: tabUrl }); finish({ tab: activeTab, url: tabUrl });
return; return;
} }
@@ -141,13 +207,13 @@ document.addEventListener("DOMContentLoaded", function () {
{ action: "get_page_context" }, { action: "get_page_context" },
function (response) { function (response) {
if (chrome.runtime.lastError) { if (chrome.runtime.lastError) {
if (callback) callback({ tab: activeTab, url: "" }); finish({ tab: activeTab, url: "" });
return; return;
} }
var pageUrl = var pageUrl =
response && typeof response.url === "string" ? response.url : ""; response && typeof response.url === "string" ? response.url : "";
if (callback) callback({ tab: activeTab, url: pageUrl }); finish({ tab: activeTab, url: pageUrl });
} }
); );
}); });
@@ -162,6 +228,10 @@ document.addEventListener("DOMContentLoaded", function () {
if (response && response.speed != null) { if (response && response.speed != null) {
updateSpeedDisplay(response.speed); updateSpeedDisplay(response.speed);
} }
selectedFrameToken =
response && typeof response.frameToken === "string"
? response.frameToken
: null;
} }
function pickBestFrameSpeedResult(results) { function pickBestFrameSpeedResult(results) {
@@ -241,14 +311,19 @@ document.addEventListener("DOMContentLoaded", function () {
} }
if (def.className) btn.className = def.className; if (def.className) btn.className = def.className;
btn.title = btnId.charAt(0).toUpperCase() + btnId.slice(1); btn.title = btnId.charAt(0).toUpperCase() + btnId.slice(1);
btn.setAttribute("aria-label", btn.title);
btn.addEventListener("click", function () { btn.addEventListener("click", function () {
if (btnId === "settings") { if (btnId === "settings") {
window.open(chrome.runtime.getURL("options/options.html")); window.open(chrome.runtime.getURL("options/options.html"));
return; return;
} }
var message = { action: "run_action", actionName: btnId };
if (shortcutTargetMode !== "all" && selectedFrameToken) {
message.targetFrameToken = selectedFrameToken;
}
sendToActiveTab( sendToActiveTab(
{ action: "run_action", actionName: btnId }, message,
function () { function () {
querySpeed(); querySpeed();
} }
@@ -277,6 +352,104 @@ document.addEventListener("DOMContentLoaded", function () {
window.open("https://github.com/SoPat712/Speeder/issues"); 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 () { document.querySelector("#donate").addEventListener("click", function () {
this.classList.add("hide"); this.classList.add("hide");
document.querySelector("#donateOptions").classList.remove("hide"); document.querySelector("#donateOptions").classList.remove("hide");
@@ -372,6 +545,7 @@ document.addEventListener("DOMContentLoaded", function () {
function renderForActiveTab() { function renderForActiveTab() {
var currentRenderToken = ++renderToken; var currentRenderToken = ++renderToken;
forceLastSavedSpeedControlledBySiteRule = null; forceLastSavedSpeedControlledBySiteRule = null;
selectedFrameToken = null;
setForceButtonLoading(true); setForceButtonLoading(true);
chrome.storage.local.get(["customButtonIcons"], function (loc) { chrome.storage.local.get(["customButtonIcons"], function (loc) {
@@ -388,6 +562,9 @@ document.addEventListener("DOMContentLoaded", function () {
getActiveTabContext(function (context) { getActiveTabContext(function (context) {
if (currentRenderToken !== renderToken) return; if (currentRenderToken !== renderToken) return;
var tabPaused = context && context.tabPaused === true;
updateTabPauseButton(tabPaused);
var url = context && context.url ? context.url : ""; var url = context && context.url ? context.url : "";
var siteRule = matchSiteRule(url, storage.siteRules); var siteRule = matchSiteRule(url, storage.siteRules);
var siteDisabled = isSiteRuleDisabled(siteRule); var siteDisabled = isSiteRuleDisabled(siteRule);
@@ -395,6 +572,10 @@ document.addEventListener("DOMContentLoaded", function () {
storage.enabled, storage.enabled,
siteRule siteRule
); );
shortcutTargetMode =
siteRule && siteRule.shortcutTargetMode !== undefined
? siteRule.shortcutTargetMode
: storage.shortcutTargetMode;
var showBar = storage.showPopupControlBar !== false; var showBar = storage.showPopupControlBar !== false;
forceLastSavedSpeedControlledBySiteRule = Boolean( forceLastSavedSpeedControlledBySiteRule = Boolean(
siteRule && siteRule.forceLastSavedSpeed !== undefined siteRule && siteRule.forceLastSavedSpeed !== undefined
@@ -403,9 +584,22 @@ document.addEventListener("DOMContentLoaded", function () {
forceLastSavedSpeedControlledBySiteRule forceLastSavedSpeedControlledBySiteRule
? siteRule.forceLastSavedSpeed === true ? siteRule.forceLastSavedSpeed === true
: storage.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) { if (siteRule && siteRule.showPopupControlBar !== undefined) {
showBar = siteRule.showPopupControlBar; showBar = siteRule.showPopupControlBar;
diagnosticContext.showBar = showBar;
} }
toggleEnabledUI(storage.enabled !== false); toggleEnabledUI(storage.enabled !== false);
@@ -414,7 +608,13 @@ document.addEventListener("DOMContentLoaded", function () {
resolvePopupButtons(storage, siteRule), resolvePopupButtons(storage, siteRule),
customIconsMap customIconsMap
); );
setControlBarVisible(siteAvailable && showBar); setControlBarVisible(!tabPaused && siteAvailable && showBar);
if (tabPaused) {
setForceButtonLoading(true);
setStatusMessage("Speeder is paused for this tab.");
return;
}
if (siteDisabled) { if (siteDisabled) {
setForceButtonLoading(false); setForceButtonLoading(false);
@@ -427,6 +627,7 @@ document.addEventListener("DOMContentLoaded", function () {
if (siteAvailable) { if (siteAvailable) {
querySpeed(function(frameContext) { querySpeed(function(frameContext) {
if (currentRenderToken !== renderToken) return; if (currentRenderToken !== renderToken) return;
diagnosticContext.frame = frameContext || null;
if ( if (
frameContext && frameContext &&
typeof frameContext.forceLastSavedSpeed === "boolean" typeof frameContext.forceLastSavedSpeed === "boolean"
@@ -507,20 +708,11 @@ document.addEventListener("DOMContentLoaded", function () {
function toggleEnabledUI(enabled) { function toggleEnabledUI(enabled) {
document.querySelector("#enable").classList.toggle("hide", enabled); document.querySelector("#enable").classList.toggle("hide", enabled);
document.querySelector("#disable").classList.toggle("hide", !enabled); document.querySelector("#disable").classList.toggle("hide", !enabled);
const suffix = `${enabled ? "" : "_disabled"}.png`;
chrome.browserAction.setIcon({
path: {
19: "assets/icons/icon19" + suffix,
38: "assets/icons/icon38" + suffix,
48: "assets/icons/icon48" + suffix
}
});
} }
function settingsSavedReloadMessage(enabled) { function settingsSavedReloadMessage(enabled) {
setStatusMessage( setStatusMessage(
`${enabled ? "Enabled" : "Disabled"}. Reload page to see changes` `${enabled ? "Enabled" : "Disabled"}. Open pages update automatically.`
); );
} }
+2
View File
@@ -28,6 +28,8 @@
"popupControllerButtons", "popupControllerButtons",
"popupMatchHoverControls", "popupMatchHoverControls",
"rememberSpeed", "rememberSpeed",
"shortcutTargetMode",
"showAmbientLoopControls",
"showPopupControlBar", "showPopupControlBar",
"siteRules", "siteRules",
"siteRulesFormat", "siteRulesFormat",
+2 -2
View File
@@ -8,8 +8,8 @@
root.SpeederShared = root.SpeederShared || {}; root.SpeederShared = root.SpeederShared || {};
root.SpeederShared.keyBindings = exports; root.SpeederShared.keyBindings = exports;
})(typeof globalThis !== "undefined" ? globalThis : this, function() { })(typeof globalThis !== "undefined" ? globalThis : this, function() {
var MIN_SPEED = 0.01; var MIN_SPEED = 0.1;
var MAX_SPEED = 100; var MAX_SPEED = 16;
function normalizeBindingKey(key) { function normalizeBindingKey(key) {
if (typeof key !== "string" || key.length === 0) return null; if (typeof key !== "string" || key.length === 0) return null;
+60
View File
@@ -68,6 +68,12 @@
function normalizeResult(result) { function normalizeResult(result) {
var normalized = { speed: result.speed }; var normalized = { speed: result.speed };
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") { if (typeof result.forceLastSavedSpeed === "boolean") {
normalized.forceLastSavedSpeed = result.forceLastSavedSpeed; normalized.forceLastSavedSpeed = result.forceLastSavedSpeed;
} }
@@ -91,7 +97,61 @@
return fallback; 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 { return {
buildDiagnosticReport: buildDiagnosticReport,
pickBestFrameSpeedResult: pickBestFrameSpeedResult, pickBestFrameSpeedResult: pickBestFrameSpeedResult,
resolvePopupButtons: resolvePopupButtons, resolvePopupButtons: resolvePopupButtons,
sanitizeButtonOrder: sanitizeButtonOrder sanitizeButtonOrder: sanitizeButtonOrder
+5 -2
View File
@@ -4,8 +4,8 @@
var LEGACY_SITE_RULES_DIFF_FORMAT = "defaults-diff-v1"; var LEGACY_SITE_RULES_DIFF_FORMAT = "defaults-diff-v1";
var SITE_RULES_DIFF_FORMAT = "defaults-diff-v2"; var SITE_RULES_DIFF_FORMAT = "defaults-diff-v2";
var REMOVED_DEFAULT_RULE_KEYS_META = "removedDefaultRuleKeys"; var REMOVED_DEFAULT_RULE_KEYS_META = "removedDefaultRuleKeys";
var MIN_SPEED = 0.01; var MIN_SPEED = 0.1;
var MAX_SPEED = 100; var MAX_SPEED = 16;
var DEFAULT_BUTTONS = ["rewind", "slower", "faster", "advance", "display"]; var DEFAULT_BUTTONS = ["rewind", "slower", "faster", "advance", "display"];
var LEGACY_SYNC_KEYS = [ var LEGACY_SYNC_KEYS = [
"resetSpeed", "resetSpeed",
@@ -985,6 +985,9 @@
} }
var regex; var regex;
if (pattern.startsWith("/") && pattern.lastIndexOf("/") === 0) {
return false;
}
if (pattern.startsWith("/") && pattern.lastIndexOf("/") > 0) { if (pattern.startsWith("/") && pattern.lastIndexOf("/") > 0) {
try { try {
var lastSlash = pattern.lastIndexOf("/"); var lastSlash = pattern.lastIndexOf("/");
+7
View File
@@ -197,6 +197,13 @@
var normalizedPattern = pattern.replace(regStrip, ""); var normalizedPattern = pattern.replace(regStrip, "");
if (normalizedPattern.length === 0) return null; if (normalizedPattern.length === 0) return null;
if (
normalizedPattern.startsWith("/") &&
normalizedPattern.lastIndexOf("/") === 0
) {
return null;
}
if ( if (
normalizedPattern.startsWith("/") && normalizedPattern.startsWith("/") &&
normalizedPattern.lastIndexOf("/") > 0 normalizedPattern.lastIndexOf("/") > 0
+13 -9
View File
@@ -72,22 +72,26 @@ function vscClearElement(el) {
function vscSanitizeSvgTree(svg) { function vscSanitizeSvgTree(svg) {
if (!svg || String(svg.tagName).toLowerCase() !== "svg") return null; if (!svg || String(svg.tagName).toLowerCase() !== "svg") return null;
svg.querySelectorAll("script, style, foreignObject").forEach(function (n) { svg
n.remove(); .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) { [svg].concat(Array.from(svg.querySelectorAll("*"))).forEach(function (el) {
for (var i = el.attributes.length - 1; i >= 0; i--) { for (var i = el.attributes.length - 1; i >= 0; i--) {
var attr = el.attributes[i]; var attr = el.attributes[i];
var name = attr.name.toLowerCase(); var name = attr.name.toLowerCase();
var val = attr.value; var val = attr.value;
if (name.indexOf("on") === 0) {
el.removeAttribute(attr.name);
continue;
}
if ( if (
(name === "href" || name === "xlink:href") && name.indexOf("on") === 0 ||
/^\s*javascript:/i.test(val) name === "style" ||
name === "href" ||
name === "xlink:href" ||
/url\s*\(/i.test(val)
) { ) {
el.removeAttribute(attr.name); el.removeAttribute(attr.name);
} }
+161 -200
View File
@@ -7,7 +7,7 @@
"name": "speeder", "name": "speeder",
"devDependencies": { "devDependencies": {
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"vitest": "^3.2.4" "vitest": "^3.2.7"
} }
}, },
"node_modules/@asamuzakjp/css-color": { "node_modules/@asamuzakjp/css-color": {
@@ -140,9 +140,9 @@
} }
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -157,9 +157,9 @@
} }
}, },
"node_modules/@esbuild/android-arm": { "node_modules/@esbuild/android-arm": {
"version": "0.27.7", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -174,9 +174,9 @@
} }
}, },
"node_modules/@esbuild/android-arm64": { "node_modules/@esbuild/android-arm64": {
"version": "0.27.7", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -191,9 +191,9 @@
} }
}, },
"node_modules/@esbuild/android-x64": { "node_modules/@esbuild/android-x64": {
"version": "0.27.7", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -208,9 +208,9 @@
} }
}, },
"node_modules/@esbuild/darwin-arm64": { "node_modules/@esbuild/darwin-arm64": {
"version": "0.27.7", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -225,9 +225,9 @@
} }
}, },
"node_modules/@esbuild/darwin-x64": { "node_modules/@esbuild/darwin-x64": {
"version": "0.27.7", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -242,9 +242,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-arm64": { "node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.7", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -259,9 +259,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-x64": { "node_modules/@esbuild/freebsd-x64": {
"version": "0.27.7", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -276,9 +276,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm": { "node_modules/@esbuild/linux-arm": {
"version": "0.27.7", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -293,9 +293,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm64": { "node_modules/@esbuild/linux-arm64": {
"version": "0.27.7", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -310,9 +310,9 @@
} }
}, },
"node_modules/@esbuild/linux-ia32": { "node_modules/@esbuild/linux-ia32": {
"version": "0.27.7", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -327,9 +327,9 @@
} }
}, },
"node_modules/@esbuild/linux-loong64": { "node_modules/@esbuild/linux-loong64": {
"version": "0.27.7", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@@ -344,9 +344,9 @@
} }
}, },
"node_modules/@esbuild/linux-mips64el": { "node_modules/@esbuild/linux-mips64el": {
"version": "0.27.7", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
@@ -361,9 +361,9 @@
} }
}, },
"node_modules/@esbuild/linux-ppc64": { "node_modules/@esbuild/linux-ppc64": {
"version": "0.27.7", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -378,9 +378,9 @@
} }
}, },
"node_modules/@esbuild/linux-riscv64": { "node_modules/@esbuild/linux-riscv64": {
"version": "0.27.7", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -395,9 +395,9 @@
} }
}, },
"node_modules/@esbuild/linux-s390x": { "node_modules/@esbuild/linux-s390x": {
"version": "0.27.7", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -412,9 +412,9 @@
} }
}, },
"node_modules/@esbuild/linux-x64": { "node_modules/@esbuild/linux-x64": {
"version": "0.27.7", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -429,9 +429,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-arm64": { "node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.7", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -446,9 +446,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-x64": { "node_modules/@esbuild/netbsd-x64": {
"version": "0.27.7", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -463,9 +463,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-arm64": { "node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.7", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -480,9 +480,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-x64": { "node_modules/@esbuild/openbsd-x64": {
"version": "0.27.7", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -497,9 +497,9 @@
} }
}, },
"node_modules/@esbuild/openharmony-arm64": { "node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.7", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -514,9 +514,9 @@
} }
}, },
"node_modules/@esbuild/sunos-x64": { "node_modules/@esbuild/sunos-x64": {
"version": "0.27.7", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -531,9 +531,9 @@
} }
}, },
"node_modules/@esbuild/win32-arm64": { "node_modules/@esbuild/win32-arm64": {
"version": "0.27.7", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -548,9 +548,9 @@
} }
}, },
"node_modules/@esbuild/win32-ia32": { "node_modules/@esbuild/win32-ia32": {
"version": "0.27.7", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -565,9 +565,9 @@
} }
}, },
"node_modules/@esbuild/win32-x64": { "node_modules/@esbuild/win32-x64": {
"version": "0.27.7", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -680,9 +680,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -697,9 +694,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -714,9 +708,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -731,9 +722,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -748,9 +736,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -765,9 +750,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -782,9 +764,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -799,9 +778,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -816,9 +792,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -833,9 +806,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -850,9 +820,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -867,9 +834,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -884,9 +848,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1003,15 +964,15 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "3.2.4", "version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/chai": "^5.2.2", "@types/chai": "^5.2.2",
"@vitest/spy": "3.2.4", "@vitest/spy": "3.2.7",
"@vitest/utils": "3.2.4", "@vitest/utils": "3.2.7",
"chai": "^5.2.0", "chai": "^5.2.0",
"tinyrainbow": "^2.0.0" "tinyrainbow": "^2.0.0"
}, },
@@ -1020,13 +981,13 @@
} }
}, },
"node_modules/@vitest/mocker": { "node_modules/@vitest/mocker": {
"version": "3.2.4", "version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz",
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/spy": "3.2.4", "@vitest/spy": "3.2.7",
"estree-walker": "^3.0.3", "estree-walker": "^3.0.3",
"magic-string": "^0.30.17" "magic-string": "^0.30.17"
}, },
@@ -1047,9 +1008,9 @@
} }
}, },
"node_modules/@vitest/pretty-format": { "node_modules/@vitest/pretty-format": {
"version": "3.2.4", "version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz",
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1060,13 +1021,13 @@
} }
}, },
"node_modules/@vitest/runner": { "node_modules/@vitest/runner": {
"version": "3.2.4", "version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz",
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/utils": "3.2.4", "@vitest/utils": "3.2.7",
"pathe": "^2.0.3", "pathe": "^2.0.3",
"strip-literal": "^3.0.0" "strip-literal": "^3.0.0"
}, },
@@ -1075,13 +1036,13 @@
} }
}, },
"node_modules/@vitest/snapshot": { "node_modules/@vitest/snapshot": {
"version": "3.2.4", "version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz",
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/pretty-format": "3.2.4", "@vitest/pretty-format": "3.2.7",
"magic-string": "^0.30.17", "magic-string": "^0.30.17",
"pathe": "^2.0.3" "pathe": "^2.0.3"
}, },
@@ -1090,9 +1051,9 @@
} }
}, },
"node_modules/@vitest/spy": { "node_modules/@vitest/spy": {
"version": "3.2.4", "version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz",
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1103,13 +1064,13 @@
} }
}, },
"node_modules/@vitest/utils": { "node_modules/@vitest/utils": {
"version": "3.2.4", "version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz",
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/pretty-format": "3.2.4", "@vitest/pretty-format": "3.2.7",
"loupe": "^3.1.4", "loupe": "^3.1.4",
"tinyrainbow": "^2.0.0" "tinyrainbow": "^2.0.0"
}, },
@@ -1258,9 +1219,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.27.7", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
@@ -1271,32 +1232,32 @@
"node": ">=18" "node": ">=18"
}, },
"optionalDependencies": { "optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.7", "@esbuild/aix-ppc64": "0.28.2",
"@esbuild/android-arm": "0.27.7", "@esbuild/android-arm": "0.28.2",
"@esbuild/android-arm64": "0.27.7", "@esbuild/android-arm64": "0.28.2",
"@esbuild/android-x64": "0.27.7", "@esbuild/android-x64": "0.28.2",
"@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-arm64": "0.28.2",
"@esbuild/darwin-x64": "0.27.7", "@esbuild/darwin-x64": "0.28.2",
"@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-arm64": "0.28.2",
"@esbuild/freebsd-x64": "0.27.7", "@esbuild/freebsd-x64": "0.28.2",
"@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm": "0.28.2",
"@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-arm64": "0.28.2",
"@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-ia32": "0.28.2",
"@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-loong64": "0.28.2",
"@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-mips64el": "0.28.2",
"@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-ppc64": "0.28.2",
"@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-riscv64": "0.28.2",
"@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-s390x": "0.28.2",
"@esbuild/linux-x64": "0.27.7", "@esbuild/linux-x64": "0.28.2",
"@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-arm64": "0.28.2",
"@esbuild/netbsd-x64": "0.27.7", "@esbuild/netbsd-x64": "0.28.2",
"@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-arm64": "0.28.2",
"@esbuild/openbsd-x64": "0.27.7", "@esbuild/openbsd-x64": "0.28.2",
"@esbuild/openharmony-arm64": "0.27.7", "@esbuild/openharmony-arm64": "0.28.2",
"@esbuild/sunos-x64": "0.27.7", "@esbuild/sunos-x64": "0.28.2",
"@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-arm64": "0.28.2",
"@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-ia32": "0.28.2",
"@esbuild/win32-x64": "0.27.7" "@esbuild/win32-x64": "0.28.2"
} }
}, },
"node_modules/estree-walker": { "node_modules/estree-walker": {
@@ -1492,9 +1453,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -1568,9 +1529,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.8", "version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -1588,7 +1549,7 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.17",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
}, },
@@ -1837,13 +1798,13 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "7.3.1", "version": "7.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0 || ^0.28.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
"picomatch": "^4.0.3", "picomatch": "^4.0.3",
"postcss": "^8.5.6", "postcss": "^8.5.6",
@@ -1935,20 +1896,20 @@
} }
}, },
"node_modules/vitest": { "node_modules/vitest": {
"version": "3.2.4", "version": "3.2.7",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/chai": "^5.2.2", "@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4", "@vitest/expect": "3.2.7",
"@vitest/mocker": "3.2.4", "@vitest/mocker": "3.2.7",
"@vitest/pretty-format": "^3.2.4", "@vitest/pretty-format": "^3.2.7",
"@vitest/runner": "3.2.4", "@vitest/runner": "3.2.7",
"@vitest/snapshot": "3.2.4", "@vitest/snapshot": "3.2.7",
"@vitest/spy": "3.2.4", "@vitest/spy": "3.2.7",
"@vitest/utils": "3.2.4", "@vitest/utils": "3.2.7",
"chai": "^5.2.0", "chai": "^5.2.0",
"debug": "^4.4.1", "debug": "^4.4.1",
"expect-type": "^1.2.1", "expect-type": "^1.2.1",
@@ -1978,8 +1939,8 @@
"@edge-runtime/vm": "*", "@edge-runtime/vm": "*",
"@types/debug": "^4.1.12", "@types/debug": "^4.1.12",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"@vitest/browser": "3.2.4", "@vitest/browser": "3.2.7",
"@vitest/ui": "3.2.4", "@vitest/ui": "3.2.7",
"happy-dom": "*", "happy-dom": "*",
"jsdom": "*" "jsdom": "*"
}, },
@@ -2086,9 +2047,9 @@
} }
}, },
"node_modules/ws": { "node_modules/ws": {
"version": "8.20.0", "version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
+1 -1
View File
@@ -7,6 +7,6 @@
}, },
"devDependencies": { "devDependencies": {
"jsdom": "^26.1.0", "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 echo "Error: empty version." >&2
return 1 return 1
fi fi
if [[ ! "$s" =~ ^[0-9]+(\.[0-9]+){0,3}(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then if [[ ! "$s" =~ ^[0-9]+(\.[0-9]+){0,3}$ ]]; then
echo "Error: invalid version (use something like 5.0.4)." >&2 echo "Error: Firefox versions must contain only 1-4 numeric parts (for example, 6.0.8.1)." >&2
return 1 return 1
fi fi
} }
@@ -59,10 +59,15 @@ fi
git checkout beta git checkout beta
git pull origin 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 read -r -p "Release version for $MANIFEST_PATH + tag (e.g. 5.0.4): " SEMVER_IN
SEMVER="$(normalize_semver "$SEMVER_IN")" SEMVER="$(normalize_semver "$SEMVER_IN")"
validate_semver "$SEMVER" 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}" TAG="v${SEMVER}"
if [[ "$TAG" == *-beta* ]]; then if [[ "$TAG" == *-beta* ]]; then
@@ -70,6 +75,11 @@ if [[ "$TAG" == *-beta* ]]; then
read -r -p "Continue anyway? [y/N] " w read -r -p "Continue anyway? [y/N] " w
[[ "${w:-}" =~ ^[yY](es)?$ ]] || { echo "Aborted."; exit 1; } [[ "${w:-}" =~ ^[yY](es)?$ ]] || { echo "Aborted."; exit 1; }
fi 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
echo "This will:" echo "This will:"
@@ -87,11 +97,11 @@ git pull origin main
git merge --squash beta git merge --squash beta
bump_manifest "$SEMVER" bump_manifest "$SEMVER"
git add -A git add -A
git commit -m "Release $TAG" git commit -m "chore(release): prepare $TAG"
git push origin main git push origin main
git tag -a "$TAG" -m "$TAG" git tag -s "$TAG" -m "$TAG"
git push origin "$TAG" git push origin "$TAG"
git checkout dev git checkout dev
+16 -6
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/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. # Triggers .github/workflows/deploy.yml: unlisted AMO sign + GitHub prerelease.
set -euo pipefail set -euo pipefail
@@ -44,8 +44,8 @@ validate_semver() {
echo "Error: empty version." >&2 echo "Error: empty version." >&2
return 1 return 1
fi fi
if [[ ! "$s" =~ ^[0-9]+(\.[0-9]+){0,3}(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then if [[ ! "$s" =~ ^[0-9]+(\.[0-9]+){0,3}$ ]]; then
echo "Error: invalid version (use something like 5.0.4 or 5.0.4-beta.1)." >&2 echo "Error: Firefox versions must contain only 1-4 numeric parts (for example, 6.0.8.1)." >&2
return 1 return 1
fi fi
} }
@@ -58,10 +58,15 @@ fi
git checkout dev git checkout dev
git pull origin 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 read -r -p "New version for $MANIFEST_PATH (e.g. 5.0.4): " SEMVER_IN
SEMVER="$(normalize_semver "$SEMVER_IN")" SEMVER="$(normalize_semver "$SEMVER_IN")"
validate_semver "$SEMVER" 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)." echo "Beta git tag will include '-beta' (required by deploy.yml)."
read -r -p "Beta tag suffix [beta.1]: " SUFFIX_IN 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 echo "Error: beta tag must contain '-beta' for the workflow (got $TAG). Try suffix like beta.1." >&2
exit 1 exit 1
fi 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
echo "This will:" echo "This will:"
@@ -88,7 +98,7 @@ echo "🚀 Releasing beta $TAG"
bump_manifest "$SEMVER" bump_manifest "$SEMVER"
git add "$MANIFEST_PATH" 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 push origin dev
git checkout beta git checkout beta
@@ -96,7 +106,7 @@ git pull origin beta
git merge dev --no-ff -m "$TAG" git merge dev --no-ff -m "$TAG"
git push origin beta git push origin beta
git tag -a "$TAG" -m "$TAG" git tag -s "$TAG" -m "$TAG"
git push origin "$TAG" git push origin "$TAG"
git checkout dev git checkout dev
+66
View File
@@ -0,0 +1,66 @@
const {
createChromeMock,
evaluateScript,
loadHtmlString
} = require("./helpers/extension-test-utils");
describe("background toolbar state", () => {
afterEach(() => {
delete global.chrome;
});
it("initializes and follows the enabled storage setting", () => {
loadHtmlString("<!doctype html><html><body></body></html>");
const chrome = createChromeMock({ syncData: { enabled: false } });
global.chrome = chrome;
window.chrome = chrome;
evaluateScript("extension/background/background.js");
expect(chrome.browserAction.setIcon).toHaveBeenLastCalledWith({
path: {
19: "assets/icons/icon19_disabled.png",
38: "assets/icons/icon38_disabled.png",
48: "assets/icons/icon48_disabled.png"
}
});
chrome.storage.sync.set({ enabled: true });
expect(chrome.browserAction.setIcon).toHaveBeenLastCalledWith({
path: {
19: "assets/icons/icon19.png",
38: "assets/icons/icon38.png",
48: "assets/icons/icon48.png"
}
});
});
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 __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, "..", ".."); const repoRoot = path.resolve(__dirname, "..", "..");
let activeDom = null;
function readRepoFile(relPath) { function readRepoFile(relPath) {
return fs.readFileSync(path.join(repoRoot, relPath), "utf8"); return fs.readFileSync(path.join(repoRoot, relPath), "utf8");
@@ -18,11 +19,13 @@ function readRepoFile(relPath) {
* top-level `const` redeclaration errors (avoids document.write). * top-level `const` redeclaration errors (avoids document.write).
*/ */
export function loadHtmlString(html, options = {}) { export function loadHtmlString(html, options = {}) {
if (activeDom) activeDom.window.close();
const dom = new JSDOM(html, { const dom = new JSDOM(html, {
url: options.url || "https://example.org/", url: options.url || "https://example.org/",
pretendToBeVisual: true, pretendToBeVisual: true,
runScripts: "dangerously" runScripts: "dangerously"
}); });
activeDom = dom;
applyJSDOMWindow(dom.window); applyJSDOMWindow(dom.window);
} }
@@ -173,7 +176,13 @@ export function createChromeMock(options = {}) {
getManifest: vi.fn(() => ({ getManifest: vi.fn(() => ({
version: options.manifestVersion || "9.9.9" 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: { storage: {
sync: syncArea, sync: syncArea,
+13 -1
View File
@@ -4,6 +4,7 @@ const { JSDOM } = require("jsdom");
const vi = globalThis.vi; const vi = globalThis.vi;
const ROOT = path.resolve(__dirname, "..", ".."); const ROOT = path.resolve(__dirname, "..", "..");
let activeDom = null;
function clone(value) { function clone(value) {
if (value === undefined) return undefined; if (value === undefined) return undefined;
@@ -42,15 +43,18 @@ function applyJSDOMWindow(win) {
win.Date = globalThis.Date; win.Date = globalThis.Date;
win.open = vi.fn(); win.open = vi.fn();
win.close = vi.fn(); win.close = vi.fn();
win.confirm = vi.fn(() => true);
} }
function loadHtmlString(html, options) { function loadHtmlString(html, options) {
const config = options || {}; const config = options || {};
if (activeDom) activeDom.window.close();
const dom = new JSDOM(html, { const dom = new JSDOM(html, {
url: config.url || "https://example.org/", url: config.url || "https://example.org/",
pretendToBeVisual: true, pretendToBeVisual: true,
runScripts: "dangerously" runScripts: "dangerously"
}); });
activeDom = dom;
applyJSDOMWindow(dom.window); applyJSDOMWindow(dom.window);
} }
@@ -244,6 +248,7 @@ function createChromeMock(options) {
const storageOnChanged = createChromeEvent(); const storageOnChanged = createChromeEvent();
const tabsOnActivated = createChromeEvent(); const tabsOnActivated = createChromeEvent();
const tabsOnUpdated = createChromeEvent(); const tabsOnUpdated = createChromeEvent();
const tabsOnRemoved = createChromeEvent();
const runtimeOnMessage = createChromeEvent(); const runtimeOnMessage = createChromeEvent();
const chrome = { const chrome = {
@@ -251,6 +256,12 @@ function createChromeMock(options) {
lastError: null, lastError: null,
getManifest: vi.fn(() => clone(config.manifest) || { version: "0.0.0-test" }), getManifest: vi.fn(() => clone(config.manifest) || { version: "0.0.0-test" }),
getURL: vi.fn((relPath) => `moz-extension://${relPath}`), 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 onMessage: runtimeOnMessage
}, },
browserAction: { browserAction: {
@@ -271,7 +282,8 @@ function createChromeMock(options) {
}), }),
create: vi.fn(), create: vi.fn(),
onActivated: tabsOnActivated, onActivated: tabsOnActivated,
onUpdated: tabsOnUpdated onUpdated: tabsOnUpdated,
onRemoved: tabsOnRemoved
}, },
storage: { storage: {
onChanged: storageOnChanged, onChanged: storageOnChanged,
+1
View File
@@ -27,4 +27,5 @@ export function applyJSDOMWindow(win) {
win.open = vi.fn(); win.open = vi.fn();
win.close = vi.fn(); win.close = vi.fn();
win.confirm = vi.fn(() => true);
} }
+3 -2
View File
@@ -44,7 +44,8 @@ describe("import/export flows", () => {
it("exports sync and local settings as a JSON download", async () => { it("exports sync and local settings as a JSON download", async () => {
vi.useFakeTimers(); 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({ const chrome = await setupImportExport({
sync: { rememberSpeed: true, shortcutTargetMode: "all" }, sync: { rememberSpeed: true, shortcutTargetMode: "all" },
local: { customButtonIcons: { faster: { slug: "rocket" } } } local: { customButtonIcons: { faster: { slug: "rocket" } } }
@@ -89,7 +90,7 @@ describe("import/export flows", () => {
const blobText = await capturedBlob.text(); const blobText = await capturedBlob.text();
expect(JSON.parse(blobText)).toEqual({ expect(JSON.parse(blobText)).toEqual({
version: "1.1", version: "1.1",
exportDate: "2026-04-04T12:09:10.000Z", exportDate: exportTime.toISOString(),
settings: { settings: {
rememberSpeed: true, rememberSpeed: true,
shortcutTargetMode: "all", shortcutTargetMode: "all",
+13 -2
View File
@@ -70,7 +70,7 @@ describe("options/import-export.js", () => {
}); });
it("generates timestamped backup filenames", () => { 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(); bootImportExport();
expect(window.generateBackupFilename()).toBe( expect(window.generateBackupFilename()).toBe(
@@ -137,9 +137,14 @@ describe("options/import-export.js", () => {
expect(backup.localSettings.lucideTagsCacheV1At).toBeUndefined(); expect(backup.localSettings.lucideTagsCacheV1At).toBeUndefined();
}); });
it("imports wrapped backups, restores local data, and refreshes the options page", async () => { it("reports partial success and refreshes when custom icons fail to import", async () => {
const { chrome } = bootImportExport(); const { chrome } = bootImportExport();
window.restore_options = vi.fn(); window.restore_options = vi.fn();
chrome.storage.local.set.mockImplementationOnce(function(_items, callback) {
chrome.runtime.lastError = { message: "icon quota exceeded" };
callback();
chrome.runtime.lastError = null;
});
const realCreateElement = document.createElement.bind(document); const realCreateElement = document.createElement.bind(document);
const fakeInput = realCreateElement("input"); const fakeInput = realCreateElement("input");
@@ -197,6 +202,12 @@ describe("options/import-export.js", () => {
{ rememberSpeed: true, enabled: false }, { rememberSpeed: true, enabled: false },
expect.any(Function) expect.any(Function)
); );
expect(document.querySelector("#status").textContent).toContain(
"Settings imported, but custom icons could not be updated"
);
expect(document.querySelector("#status").textContent).toContain(
"icon quota exceeded"
);
vi.advanceTimersByTime(500); vi.advanceTimersByTime(500);
expect(window.restore_options).toHaveBeenCalled(); expect(window.restore_options).toHaveBeenCalled();
+1102 -36
View File
@@ -2,7 +2,8 @@ const {
createChromeMock, createChromeMock,
evaluateScript, evaluateScript,
flushAsyncWork, flushAsyncWork,
loadHtmlString loadHtmlString,
readWorkspaceFile
} = require("./helpers/extension-test-utils"); } = require("./helpers/extension-test-utils");
function bootInject(options) { function bootInject(options) {
@@ -12,11 +13,17 @@ function bootInject(options) {
url: config.url || "https://example.org/" url: config.url || "https://example.org/"
}); });
window.history.replaceState({}, "", config.path || "/"); window.history.replaceState({}, "", config.path || "/");
if (typeof config.configureWindow === "function") {
config.configureWindow(window);
}
const chrome = createChromeMock({ const chrome = createChromeMock({
syncData: config.syncData, syncData: config.syncData,
localData: config.localData localData: config.localData
}); });
if (typeof config.configureChrome === "function") {
config.configureChrome(chrome);
}
if (config.syncGetDelayMs) { if (config.syncGetDelayMs) {
chrome.storage.sync.get.mockImplementation((keys, callback) => { 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>"); loadHtmlString("<!doctype html><html><body></body></html>");
let locationEvents = 0; let locationEvents = 0;
let shadowEvents = 0; let shadowEvents = 0;
let readyEvents = 0;
document.addEventListener("speeder-location-changed", () => { document.addEventListener("speeder-location-changed", () => {
locationEvents += 1; locationEvents += 1;
}); });
document.addEventListener("speeder-shadow-root-attached", () => { document.addEventListener("speeder-shadow-root-attached", () => {
shadowEvents += 1; shadowEvents += 1;
}); });
document.addEventListener("speeder-page-bridge-ready", () => {
readyEvents += 1;
});
evaluateScript("extension/content/shadow-bridge.js");
evaluateScript("extension/content/shadow-bridge.js"); evaluateScript("extension/content/shadow-bridge.js");
window.history.pushState({}, "", "/new-route"); window.history.pushState({}, "", "/new-route");
window.history.replaceState({}, "", window.location.href);
const bridgePlayer = document.createElement("bridge-player"); const bridgePlayer = document.createElement("bridge-player");
document.body.appendChild(bridgePlayer); document.body.appendChild(bridgePlayer);
bridgePlayer.attachShadow({ mode: "open" }); bridgePlayer.attachShadow({ mode: "open" });
expect(locationEvents).toBe(1); expect(locationEvents).toBe(2);
expect(shadowEvents).toBe(1); 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 () => { 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"); 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 () => { it("targets the controller nearest the pointer unless change-all is selected", async () => {
bootInject(); bootInject();
await settleLifecycle(); await settleLifecycle();
@@ -292,16 +435,27 @@ describe("inject.js media/controller lifecycle regressions", () => {
src: "https://example.org/second.mp4", src: "https://example.org/second.mp4",
mountRect: makeRect(500, 0, 320, 180) mountRect: makeRect(500, 0, 320, 180)
}); });
setRect(window.getControllerElement(first.controller), makeRect(10, 10, 120, 30)); const firstLayoutRead = vi.fn(() => first.mount.getBoundingClientRect());
setRect(window.getControllerElement(second.controller), makeRect(510, 10, 120, 30)); const secondLayoutRead = vi.fn(() => second.mount.getBoundingClientRect());
first.video.getBoundingClientRect = firstLayoutRead;
second.video.getBoundingClientRect = secondLayoutRead;
document.dispatchEvent( document.dispatchEvent(
new MouseEvent("mousemove", { bubbles: true, clientX: 600, clientY: 20 }) 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(first.video.playbackRate).toBe(1);
expect(second.video.playbackRate).toBe(1.1); expect(second.video.playbackRate).toBe(1.1);
expect(firstLayoutRead).not.toHaveBeenCalled();
expect(secondLayoutRead).not.toHaveBeenCalled();
window.tc.settings.shortcutTargetMode = "all"; window.tc.settings.shortcutTargetMode = "all";
window.runAction("faster", 0.1); window.runAction("faster", 0.1);
@@ -309,6 +463,179 @@ describe("inject.js media/controller lifecycle regressions", () => {
expect(second.video.playbackRate).toBe(1.2); 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();
const { video } = createControlledVideo();
const listener = chrome.runtime.onMessage.listeners[0];
const initialSpeed = video.playbackRate;
listener(
{
action: "run_action",
actionName: "faster",
targetFrameToken: "another-frame"
},
{},
vi.fn()
);
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 () => { it("drops a stale hover-preview shortcut target after SPA navigation", async () => {
bootInject({ bootInject({
url: "https://www.youtube.com/", url: "https://www.youtube.com/",
@@ -394,6 +721,68 @@ describe("inject.js media/controller lifecycle regressions", () => {
expect(video.currentTime).toBe(63); expect(video.currentTime).toBe(63);
}); });
it("ignores shortcuts from selects and shadow-DOM edit fields", async () => {
bootInject();
await settleLifecycle();
const { video } = createControlledVideo();
const select = document.createElement("select");
const host = document.createElement("site-editor");
const input = document.createElement("input");
host.attachShadow({ mode: "open" }).appendChild(input);
document.body.append(select, host);
[select, input].forEach((target) => {
target.dispatchEvent(
new KeyboardEvent("keydown", {
bubbles: true,
composed: true,
code: "KeyD",
key: "d"
})
);
});
expect(video.playbackRate).toBe(1);
});
it("finishes a forced SPA initialization after settings hydration", async () => {
vi.useFakeTimers();
bootInject({
url: "https://www.youtube.com/",
syncGetDelayMs: 1000
});
const mount = document.createElement("div");
const video = document.createElement("video");
const rect = makeRect(0, 0, 1280, 720);
mount.id = "movie_player";
video.src = "blob:https://www.youtube.com/main-player";
setRect(mount, rect);
setBoxMetrics(mount, rect.width, rect.height);
setRect(video, rect);
mount.appendChild(video);
document.body.appendChild(mount);
document.dispatchEvent(new Event("speeder-location-changed"));
await vi.advanceTimersByTimeAsync(300);
expect(window.tc.runtimeSettingsHydrated).toBe(false);
expect(video.vsc).toBeUndefined();
await vi.advanceTimersByTimeAsync(700);
await settleLifecycle();
expect(window.tc.runtimeSettingsHydrated).toBe(true);
expect(video.vsc).toBeTruthy();
video.dispatchEvent(
new KeyboardEvent("keydown", {
bubbles: true,
code: "KeyD",
key: "d"
})
);
expect(video.playbackRate).toBe(1.1);
});
it("skips ambient loops by default and includes them when explicitly enabled", async () => { it("skips ambient loops by default and includes them when explicitly enabled", async () => {
bootInject(); bootInject();
await settleLifecycle(); await settleLifecycle();
@@ -508,6 +897,7 @@ describe("inject.js media/controller lifecycle regressions", () => {
div: wrapper, div: wrapper,
normalControllerMount: normalMount normalControllerMount: normalMount
}; };
wrapper.showPopover = vi.fn();
window.setupControllerHostTracking(controller, wrapper, normalMount); window.setupControllerHostTracking(controller, wrapper, normalMount);
Object.defineProperty(document, "fullscreenElement", { Object.defineProperty(document, "fullscreenElement", {
configurable: true, configurable: true,
@@ -516,6 +906,8 @@ describe("inject.js media/controller lifecycle regressions", () => {
window.syncControllerFullscreenMount(controller); window.syncControllerFullscreenMount(controller);
expect(fullscreenPlayer.contains(wrapper)).toBe(true); expect(fullscreenPlayer.contains(wrapper)).toBe(true);
expect(wrapper.showPopover).not.toHaveBeenCalled();
expect(wrapper.hasAttribute("popover")).toBe(false);
Object.defineProperty(document, "fullscreenElement", { Object.defineProperty(document, "fullscreenElement", {
configurable: true, configurable: true,
@@ -528,50 +920,59 @@ describe("inject.js media/controller lifecycle regressions", () => {
controller.controllerHostCleanup(); 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 { mount, controller, wrapper } = createControlledVideo();
setRect(document.documentElement, makeRect(0, 0, 0, 0));
Object.defineProperty(document, "fullscreenElement", {
configurable: true,
value: document.documentElement
});
window.syncControllerFullscreenMount(controller);
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 directly-fullscreen video's controller", async () => {
bootInject(); bootInject();
await settleLifecycle(); await settleLifecycle();
const fullscreenPlayer = document.createElement("div"); const fullscreenPlayer = document.createElement("div");
const video = document.createElement("video"); const fullscreenVideo = document.createElement("video");
const wrapper = document.createElement("div"); const otherVideo = document.createElement("video");
const rect = makeRect(0, 0, 1280, 720); const rect = makeRect(0, 0, 640, 360);
fullscreenPlayer.append(video, wrapper); fullscreenVideo.src = "https://example.org/fullscreen.mp4";
document.body.appendChild(fullscreenPlayer); otherVideo.src = "https://example.org/other.mp4";
[fullscreenPlayer, video].forEach((element) => { fullscreenPlayer.appendChild(fullscreenVideo);
document.body.append(fullscreenPlayer, otherVideo);
[fullscreenPlayer, fullscreenVideo, otherVideo].forEach((element) => {
setRect(element, rect); setRect(element, rect);
setBoxMetrics(element, rect.width, rect.height); setBoxMetrics(element, rect.width, rect.height);
}); });
wrapper.showPopover = vi.fn(); window.ensureController(fullscreenVideo, fullscreenPlayer);
wrapper.hidePopover = vi.fn(); window.ensureController(otherVideo, document.body);
fullscreenVideo.vsc.div.showPopover = vi.fn();
const controller = { otherVideo.vsc.div.showPopover = vi.fn();
video,
div: wrapper,
normalControllerMount: fullscreenPlayer
};
window.setupControllerHostTracking(controller, wrapper, fullscreenPlayer);
Object.defineProperty(document, "fullscreenElement", {
configurable: true,
value: fullscreenPlayer
});
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", { Object.defineProperty(document, "fullscreenElement", {
configurable: true, configurable: true,
value: null value: fullscreenVideo
}); });
window.syncControllerFullscreenMount(controller); window.syncControllerFullscreenMount(fullscreenVideo.vsc);
expect(wrapper.hidePopover).toHaveBeenCalledOnce(); window.syncControllerFullscreenMount(otherVideo.vsc);
wrapper.remove(); expect(fullscreenVideo.vsc.div.showPopover).toHaveBeenCalledOnce();
controller.controllerHostCleanup(); expect(otherVideo.vsc.div.showPopover).not.toHaveBeenCalled();
expect(
otherVideo.vsc.div.classList.contains("vsc-fullscreen-popover")
).toBe(false);
}); });
it("preserves direct-video requestFullscreen semantics and overlays with a popover", async () => { it("preserves direct-video requestFullscreen semantics and overlays with a popover", async () => {
@@ -684,6 +1085,25 @@ describe("inject.js media/controller lifecycle regressions", () => {
expect(video.vsc.div.parentElement).toBe(mount); 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 () => { it("still creates controls when a custom player throws from playbackRate", async () => {
bootInject(); bootInject();
await settleLifecycle(); await settleLifecycle();
@@ -707,6 +1127,36 @@ describe("inject.js media/controller lifecycle regressions", () => {
expect(video.vsc.div.isConnected).toBe(true); 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 () => { it("remounts an existing controller when its video moves between players", async () => {
bootInject(); bootInject();
await settleLifecycle(); await settleLifecycle();
@@ -752,6 +1202,505 @@ describe("inject.js media/controller lifecycle regressions", () => {
expect(mount.lastElementChild).toBe(wrapper); 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 () => { it("keeps expanded controls visible and disables auto-hide while hovered", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
bootInject({ bootInject({
@@ -778,6 +1727,21 @@ describe("inject.js media/controller lifecycle regressions", () => {
expect(wrapper.classList.contains("vsc-idle-hidden")).toBe(true); expect(wrapper.classList.contains("vsc-idle-hidden")).toBe(true);
}); });
it("creates named native buttons for in-player controls", async () => {
bootInject({
syncData: { controllerButtons: ["rewind", "nudge"] }
});
await settleLifecycle();
const { wrapper } = createControlledVideo();
const rewind = wrapper.shadowRoot.querySelector('[data-action="rewind"]');
const nudge = wrapper.shadowRoot.querySelector("#nudge-indicator");
expect(rewind.tagName).toBe("BUTTON");
expect(rewind.getAttribute("aria-label")).toBe("Rewind");
expect(nudge.tagName).toBe("BUTTON");
expect(nudge.getAttribute("aria-label")).toContain("Subtitle nudge");
});
it("does not let YouTube auto-hide collapse controls under the pointer", async () => { it("does not let YouTube auto-hide collapse controls under the pointer", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
bootInject({ bootInject({
@@ -852,9 +1816,13 @@ describe("inject.js media/controller lifecycle regressions", () => {
videoRect: makeRect(40, 40, 640, 360), videoRect: makeRect(40, 40, 640, 360),
src: "https://example.org/visible.mp4" src: "https://example.org/visible.mp4"
}).video; }).video;
const offscreenLayoutRead = vi.spyOn(offscreen, "getBoundingClientRect");
const visibleLayoutRead = vi.spyOn(visible, "getBoundingClientRect");
expect(window.getPrimaryVideoElement()).toBe(visible); expect(window.getPrimaryVideoElement()).toBe(visible);
expect(window.getPrimaryVideoElement()).not.toBe(offscreen); 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 () => { it("mounts a controller locally for video directly under an open ShadowRoot", async () => {
@@ -928,6 +1896,78 @@ describe("inject.js media/controller lifecycle regressions", () => {
expect(video.vsc).toBeUndefined(); expect(video.vsc).toBeUndefined();
expect(window.tc.mediaElements).not.toContain(video); expect(window.tc.mediaElements).not.toContain(video);
expect(wrapper.isConnected).toBe(false); 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 () => { it("starts and ends a controller drag mounted directly in an open ShadowRoot", async () => {
@@ -977,6 +2017,32 @@ describe("inject.js media/controller lifecycle regressions", () => {
expect(video.vsc.div.getRootNode()).toBe(shadow); 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 () => { it("reconciles source children and the emptied lifecycle on an existing controller", async () => {
bootInject(); bootInject();
await settleLifecycle(); await settleLifecycle();
+10 -1
View File
@@ -29,7 +29,10 @@ describe("lucide-client.js", () => {
<svg width="10" height="10" onclick="evil()"> <svg width="10" height="10" onclick="evil()">
<script>alert(1)</script> <script>alert(1)</script>
<foreignObject>bad</foreignObject> <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> </svg>
`); `);
@@ -37,6 +40,12 @@ describe("lucide-client.js", () => {
expect(sanitized).not.toContain("onclick"); expect(sanitized).not.toContain("onclick");
expect(sanitized).not.toContain("<script"); expect(sanitized).not.toContain("<script");
expect(sanitized).not.toContain("foreignObject"); 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%"'); expect(sanitized).toContain('width="100%"');
}); });
+65 -1
View File
@@ -91,12 +91,58 @@ describe("options page", () => {
expect(globalThis.getPopupControlBarOrder()).toEqual(["rewind", "advance"]); expect(globalThis.getPopupControlBarOrder()).toEqual(["rewind", "advance"]);
}); });
it("reorders and toggles control-bar buttons from the keyboard", async () => {
await setupOptions({
sync: { controllerButtons: ["rewind", "faster"] }
});
const active = document.getElementById("controlBarActive");
const rewind = active.querySelector('[data-button-id="rewind"]');
rewind.dispatchEvent(
new window.KeyboardEvent("keydown", {
key: "ArrowRight",
bubbles: true,
cancelable: true
})
);
expect(globalThis.getControlBarOrder()).toEqual(["faster", "rewind"]);
expect(rewind.disabled).toBe(false);
rewind.click();
expect(globalThis.getControlBarOrder()).toEqual(["faster"]);
expect(rewind.closest(".cb-available-zone")).not.toBeNull();
expect(rewind.getAttribute("aria-label")).toContain("available");
});
it("labels shortcut and generated site-rule controls", async () => {
await setupOptions();
expect(document.getElementById("lucideIconResults").getAttribute("role")).toBe(
"group"
);
expect(
document.querySelector('#display .customKey').getAttribute("aria-label")
).toBe("Show/hide controller key");
globalThis.createSiteRule(null);
const rule = document.querySelector(".site-rule");
const location = rule.querySelector(".site-controllerLocation");
const locationLabel = location.closest(".site-rule-option").querySelector("label");
expect(location.id).not.toBe("");
expect(locationLabel.htmlFor).toBe(location.id);
expect(
rule.querySelector(".site-controllerMarginTop").getAttribute("aria-label")
).toBe("Controller margin top");
expect(
rule.querySelector(".remove-site-rule").getAttribute("aria-label")
).toBe("Remove site rule");
});
it("validates site rule regexes before saving", async () => { it("validates site rule regexes before saving", async () => {
const chrome = await setupOptions(); const chrome = await setupOptions();
chrome.storage.sync.set.mockClear(); chrome.storage.sync.set.mockClear();
globalThis.createSiteRule(null); globalThis.createSiteRule(null);
const rule = document.querySelector(".site-rule"); const rule = document.querySelector(".site-rule");
rule.querySelector(".site-pattern").value = "/(/"; rule.querySelector(".site-pattern").value = "/youtube";
globalThis.save_options(); globalThis.save_options();
@@ -344,4 +390,22 @@ describe("options page", () => {
).toEqual(expect.any(Number)); ).toEqual(expect.any(Number));
expect(chrome.storage.local.__state.unrelatedLocalValue).toBe("keep"); expect(chrome.storage.local.__state.unrelatedLocalValue).toBe("keep");
}); });
it("leaves settings untouched when restoring defaults is cancelled", async () => {
const chrome = await setupOptions({ sync: { rememberSpeed: true } });
window.confirm.mockReturnValueOnce(false);
chrome.storage.sync.set.mockClear();
chrome.storage.sync.remove.mockClear();
chrome.storage.local.set.mockClear();
chrome.storage.local.remove.mockClear();
globalThis.restore_defaults();
expect(window.confirm).toHaveBeenCalledOnce();
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
expect(chrome.storage.sync.remove).not.toHaveBeenCalled();
expect(chrome.storage.local.set).not.toHaveBeenCalled();
expect(chrome.storage.local.remove).not.toHaveBeenCalled();
expect(chrome.storage.sync.__state.rememberSpeed).toBe(true);
});
}); });
+37
View File
@@ -81,6 +81,43 @@ describe("options.js", () => {
await vi.advanceTimersByTimeAsync(300); await vi.advanceTimersByTimeAsync(300);
expect(chrome.storage.sync._dump().rememberSpeed).toBe(true); expect(chrome.storage.sync._dump().rememberSpeed).toBe(true);
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 () => { it("does not partially save options when a required site shortcut is invalid", async () => {
+11 -8
View File
@@ -35,6 +35,12 @@ describe("popup UI", () => {
expect( expect(
document.querySelectorAll("#popupControlBar button").length document.querySelectorAll("#popupControlBar button").length
).toBeGreaterThan(0); ).toBeGreaterThan(0);
expect(document.getElementById("status").getAttribute("role")).toBe(
"status"
);
expect(
document.querySelector("#popupControlBar button").getAttribute("aria-label")
).not.toBe("");
}); });
it("shows controls when globally disabled but a whitelist site rule matches", async () => { it("shows controls when globally disabled but a whitelist site rule matches", async () => {
@@ -70,7 +76,7 @@ describe("popup UI", () => {
); );
}); });
it("toggles enabled state and updates the browser action icons", async () => { it("toggles enabled state without owning background icon state", async () => {
const chrome = await setupPopup(); const chrome = await setupPopup();
chrome.storage.sync.set.mockClear(); chrome.storage.sync.set.mockClear();
chrome.browserAction.setIcon.mockClear(); chrome.browserAction.setIcon.mockClear();
@@ -84,13 +90,10 @@ describe("popup UI", () => {
expect(document.getElementById("enable").classList.contains("hide")).toBe( expect(document.getElementById("enable").classList.contains("hide")).toBe(
false false
); );
expect(chrome.browserAction.setIcon).toHaveBeenCalledWith({ expect(document.getElementById("status").textContent).toBe(
path: { "Disabled. Open pages update automatically."
19: "assets/icons/icon19_disabled.png", );
38: "assets/icons/icon38_disabled.png", expect(chrome.browserAction.setIcon).not.toHaveBeenCalled();
48: "assets/icons/icon48_disabled.png"
}
});
}); });
it("handles refresh responses for unsupported and successful pages", async () => { it("handles refresh responses for unsupported and successful pages", async () => {
+129 -10
View File
@@ -18,6 +18,7 @@ function bootPopup(options) {
manifest: { version: "9.9.9-test" }, manifest: { version: "9.9.9-test" },
syncData: config.syncData, syncData: config.syncData,
localData: config.localData, localData: config.localData,
runtimeSendMessageImpl: config.runtimeSendMessageImpl,
tabsQueryResult: [ tabsQueryResult: [
config.activeTab || { id: 99, active: true, url: "https://example.com/" } config.activeTab || { id: 99, active: true, url: "https://example.com/" }
] ]
@@ -107,10 +108,10 @@ describe("popup.js", () => {
executeScriptImpl: (tabId, details, callback) => { executeScriptImpl: (tabId, details, callback) => {
speedQueryCount += 1; speedQueryCount += 1;
callback( callback(
speedQueryCount <= 2 speedQueryCount === 1
? [ ? [
{ speed: 1.25, preferred: false }, { speed: 1.25, preferred: false },
{ speed: 1.5, preferred: true } { speed: 1.5, frameToken: "playing-frame", preferred: true }
] ]
: [{ speed: 1.75, preferred: true }] : [{ speed: 1.75, preferred: true }]
); );
@@ -138,12 +139,136 @@ describe("popup.js", () => {
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith( expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(
99, 99,
{ action: "run_action", actionName: "faster" }, {
action: "run_action",
actionName: "faster",
targetFrameToken: "playing-frame"
},
expect.any(Function) expect.any(Function)
); );
expect(document.querySelector("#popupSpeed").textContent).toBe("1.75"); expect(document.querySelector("#popupSpeed").textContent).toBe("1.75");
}); });
it("keeps all-video popup actions intentionally untargeted", async () => {
const chrome = bootPopup({
syncData: { shortcutTargetMode: "all" },
executeScriptImpl: (tabId, details, callback) => {
callback([{ speed: 1.5, frameToken: "playing-frame", preferred: true }]);
}
});
await flushAsyncWork();
chrome.tabs.sendMessage.mockClear();
document.querySelector("#popupControlBar button").click();
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(
99,
{ action: "run_action", actionName: "rewind" },
expect.any(Function)
);
});
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 () => { it("toggles enablement and closes after a successful refresh", async () => {
const chrome = bootPopup({ const chrome = bootPopup({
syncData: { syncData: {
@@ -162,13 +287,7 @@ describe("popup.js", () => {
expect( expect(
window.vscExpandStoredSettings(chrome.storage.sync._dump()).enabled window.vscExpandStoredSettings(chrome.storage.sync._dump()).enabled
).toBe(true); ).toBe(true);
expect(chrome.browserAction.setIcon).toHaveBeenCalledWith({ expect(chrome.browserAction.setIcon).not.toHaveBeenCalled();
path: {
19: "assets/icons/icon19.png",
38: "assets/icons/icon38.png",
48: "assets/icons/icon48.png"
}
});
document.querySelector("#refresh").click(); document.querySelector("#refresh").click();
expect(document.querySelector("#status").textContent).toContain("Closing"); expect(document.querySelector("#status").textContent).toContain("Closing");
+12 -1
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", () => { it("provides titles for built-in video rules and round-trips title edits sparsely", () => {
const settings = window.vscExpandStoredSettings({}); const settings = window.vscExpandStoredSettings({});
expect(settings.siteRules.map((rule) => rule.title)).toEqual([ expect(settings.siteRules.map((rule) => rule.title)).toEqual([
@@ -257,7 +268,7 @@ describe("canonical settings storage", () => {
expect(rule.forceLastSavedSpeed).toBe(true); expect(rule.forceLastSavedSpeed).toBe(true);
expect(rule.controllerOpacity).toBe(1); expect(rule.controllerOpacity).toBe(1);
expect(rule.subtitleNudgeInterval).toBe(250); expect(rule.subtitleNudgeInterval).toBe(250);
expect(rule.preferredSpeed).toBe(100); expect(rule.preferredSpeed).toBe(16);
}); });
it("falls back for null and blank numeric settings instead of coercing zero", () => { it("falls back for null and blank numeric settings instead of coercing zero", () => {
+41 -4
View File
@@ -22,6 +22,13 @@ describe("shared helpers", () => {
]) ])
).toBeNull(); ).toBeNull();
expect(siteRules.isSiteRuleDisabled({ enabled: false })).toBe(true); expect(siteRules.isSiteRuleDisabled({ enabled: false })).toBe(true);
expect(siteRules.compileSiteRulePattern("/youtube")).toBeNull();
expect(
siteRules.siteRuleMatchesUrl(
{ pattern: "/youtube" },
"https://youtube.com/watch"
)
).toBe(false);
}); });
it("matches plain and regex rules against safely decoded URL text", () => { it("matches plain and regex rules against safely decoded URL text", () => {
@@ -134,6 +141,15 @@ describe("shared helpers", () => {
).toEqual(["advance"]); ).toEqual(["advance"]);
}); });
it("keeps the selected frame token with the displayed popup speed", () => {
expect(
popupControls.pickBestFrameSpeedResult([
{ speed: 1.25, frameToken: "background", preferred: false },
{ speed: 1.75, frameToken: "playing", preferred: true }
])
).toEqual({ speed: 1.75, frameToken: "playing" });
});
it("normalizes controller locations and margins", () => { it("normalizes controller locations and margins", () => {
expect(controllerUtils.normalizeControllerLocation("top-right")).toBe( expect(controllerUtils.normalizeControllerLocation("top-right")).toBe(
"top-right" "top-right"
@@ -158,10 +174,13 @@ describe("shared helpers", () => {
"NumpadAdd" "NumpadAdd"
); );
expect(keyBindingUtils.getLegacyKeyCode({ key: 65 })).toBe(65); expect(keyBindingUtils.getLegacyKeyCode({ key: 65 })).toBe(65);
expect(keyBindingUtils.getActionValueError("fast", 0.01)).toBeNull(); expect(keyBindingUtils.getActionValueError("fast", 0.1)).toBeNull();
expect(keyBindingUtils.getActionValueError("fast", 100)).toBeNull(); expect(keyBindingUtils.getActionValueError("fast", 16)).toBeNull();
expect(keyBindingUtils.getActionValueError("fast", 0.009)).toContain( expect(keyBindingUtils.getActionValueError("fast", 0.099)).toContain(
"between 0.01 and 100" "between 0.1 and 16"
);
expect(keyBindingUtils.getActionValueError("fast", 16.001)).toContain(
"between 0.1 and 16"
); );
}); });
@@ -222,6 +241,24 @@ describe("shared helpers", () => {
localSettings: null 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( expect(importExportUtils.isRecognizedRawSettingsObject({ wat: true })).toBe(
false false
); );
+2
View File
@@ -4,8 +4,10 @@ module.exports = defineConfig({
test: { test: {
environment: "jsdom", environment: "jsdom",
clearMocks: true, clearMocks: true,
fileParallelism: false,
globals: true, globals: true,
restoreMocks: true, restoreMocks: true,
testTimeout: 15000,
include: ["tests/**/*.test.js", "tests/**/*.spec.js"], include: ["tests/**/*.test.js", "tests/**/*.spec.js"],
setupFiles: ["./tests/setup.js"] setupFiles: ["./tests/setup.js"]
} }