mirror of
https://github.com/SoPat712/Speeder.git
synced 2026-08-21 20:32:07 -04:00
Compare commits
59
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5d697b7ea
|
||
|
|
7c9259e5ea
|
||
|
|
0872b66686
|
||
|
|
eede171ad9
|
||
|
|
15673b0a38
|
||
|
|
06b23b3a53
|
||
|
|
807bd0a29d
|
||
|
|
6d17f15bc0
|
||
|
|
8073014bce
|
||
|
|
f8cfbf05c8
|
||
|
|
b4234e0387
|
||
|
|
d55006dd0a
|
||
|
|
745af2f1b9
|
||
|
|
7e9ce436d3
|
||
|
|
7e3054f6fa
|
||
|
|
98546adf41
|
||
|
|
daeff63d2a
|
||
|
|
80aa00670c
|
||
|
|
aa4f1d15e7
|
||
|
|
5af0200008
|
||
|
|
1d9f6f7e13
|
||
|
|
0f64fd5ab1
|
||
|
|
e555c4c116
|
||
|
|
91d530e29b
|
||
|
|
34f2561b50
|
||
|
|
fd29883d84
|
||
|
|
e083fdcafe
|
||
|
|
2fa7d8963a
|
||
|
|
e4edf10440
|
||
|
|
d5c2d0e343
|
||
|
|
5cd559b883
|
||
|
|
0c9a6a1a1b
|
||
|
|
3ee970a54b
|
||
|
|
e12375f2d2
|
||
|
|
8d7d31a4db
|
||
|
|
ed21281fa3
|
||
|
|
13350e5cea
|
||
|
|
39b7ba27fb
|
||
|
|
ee346ee9d8
|
||
|
|
461c8c448f
|
||
|
|
bff65230bb
|
||
|
|
0afb56abe0
|
||
|
|
a02b7b57f7
|
||
|
|
39c1c76db1
|
||
|
|
9b6d1ccd3c
|
||
|
|
a2d7f0d9d6
|
||
|
|
f8cbcd4368
|
||
|
|
04f1414118
|
||
|
|
dad223d4ad
|
||
|
|
e5ae1ed4ba
|
||
|
|
cb892a36a4
|
||
|
|
722eb0754d
|
||
|
|
1ae7bbdee3
|
||
|
|
6b2a5ed472
|
||
|
|
29689ae4ec
|
||
|
|
9c10183ab6
|
||
|
|
f1e8f44f42
|
||
|
|
7073330916
|
||
|
|
f10818964c
|
@@ -12,10 +12,21 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install deps
|
||||
run: npm install -g web-ext
|
||||
- uses: actions/setup-node@v7
|
||||
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
|
||||
run: web-ext lint --source-dir extension
|
||||
@@ -43,7 +54,7 @@ jobs:
|
||||
|
||||
- name: Create GitHub Prerelease (beta)
|
||||
if: contains(github.ref_name, '-beta')
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
name: ${{ github.ref_name }}
|
||||
|
||||
@@ -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"
|
||||
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
|
||||
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" />
|
||||
|
||||
Some sites may assign other functionality to one of the assigned shortcut keys —
|
||||
these collisions are inevitable, unfortunately. As a workaround, the extension
|
||||
listens both for lower and upper case values (i.e. you can use
|
||||
`Shift-<shortcut>`) if there is other functionality assigned to the lowercase
|
||||
key. This is not a perfect solution, as some sites may listen to both, but works
|
||||
most of the time.
|
||||
Some sites may assign other functionality to one of the assigned shortcut keys.
|
||||
You can record `Shift+<shortcut>` as a separate, exact binding, or use a site
|
||||
rule to block the site from capturing a particular Speeder shortcut. Shift is
|
||||
not applied automatically to an unshifted binding.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -74,19 +72,17 @@ The unpacked extension root is `extension/`. Load that directory in
|
||||
|
||||
```sh
|
||||
npm test
|
||||
npx --yes web-ext lint --source-dir extension
|
||||
npx --yes web-ext@10.6.0 lint --source-dir extension
|
||||
```
|
||||
|
||||
## FAQ
|
||||
|
||||
### The video controls are not showing up?
|
||||
|
||||
This extension is only compatible
|
||||
with HTML5 video. If you don't see the controls showing up, chances are you are
|
||||
viewing a Flash video. If you want to confirm, try right-clicking on the video
|
||||
and inspect the menu: if it mentions flash, then that's the issue. That said,
|
||||
most sites will fallback to HTML5 if they detect that Flash is not available.
|
||||
You can try manually disabling Flash from the browser.
|
||||
Speeder works with HTML5 video and, when enabled in settings, HTML5 audio. Check
|
||||
that Speeder is enabled for the current site, then use the popup's "Rescan page
|
||||
for videos" action after a player loads dynamically. Browser-internal pages and
|
||||
players that do not expose HTML5 media to extensions cannot be controlled.
|
||||
|
||||
### What is this fork all about?
|
||||
|
||||
|
||||
@@ -1,5 +1,67 @@
|
||||
chrome.runtime.onMessage.addListener(function (request) {
|
||||
if (request.action === "openOptions") {
|
||||
chrome.tabs.create({ url: chrome.runtime.getURL("options/options.html") });
|
||||
function setToolbarIcon(enabled) {
|
||||
var suffix = enabled === false ? "_disabled" : "";
|
||||
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;
|
||||
return {
|
||||
speed: v.playbackRate,
|
||||
frameToken:
|
||||
typeof tc === "object" && typeof tc.frameToken === "string"
|
||||
? tc.frameToken
|
||||
: null,
|
||||
diagnostics:
|
||||
typeof getDiagnosticsSnapshot === "function"
|
||||
? getDiagnosticsSnapshot(v)
|
||||
: null,
|
||||
preferred: !v.paused,
|
||||
forceLastSavedSpeed: Boolean(
|
||||
typeof tc === "object" && tc.settings && tc.settings.forceLastSavedSpeed
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
/* Base styles for the controller wrapper (the shadow host) */
|
||||
.vsc-controller {
|
||||
position: absolute !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
pointer-events: none !important;
|
||||
/* Keep the interactive controller above player-owned click/pause panes. */
|
||||
z-index: 2147483647 !important;
|
||||
@@ -17,7 +13,6 @@
|
||||
controller in the browser top layer without nesting it inside <video>. */
|
||||
.vsc-controller.vsc-fullscreen-popover {
|
||||
position: fixed !important;
|
||||
inset: auto !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
|
||||
+454
-140
@@ -1,4 +1,3 @@
|
||||
var isUserSeek = false; // Track if seek was user-initiated
|
||||
var lastToggleSpeed = {}; // Store last toggle speeds per video
|
||||
var speederShared =
|
||||
typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {};
|
||||
@@ -28,6 +27,11 @@ function getSharedDefault(key, fallback) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function getCachedVideoRect(video) {
|
||||
var wrapper = video && video.vsc && video.vsc.div;
|
||||
return (wrapper && wrapper.vscVideoRect) || null;
|
||||
}
|
||||
|
||||
function getPrimaryVideoElement(mediaElements) {
|
||||
var candidates = Array.isArray(mediaElements)
|
||||
? mediaElements
|
||||
@@ -39,10 +43,13 @@ function getPrimaryVideoElement(mediaElements) {
|
||||
candidates.forEach(function(el, index) {
|
||||
if (!el || !el.vsc || !el.isConnected) return;
|
||||
|
||||
var rect = null;
|
||||
try {
|
||||
rect = el.getBoundingClientRect();
|
||||
} catch (_error) {}
|
||||
var rect = getCachedVideoRect(el);
|
||||
var hasCachedRect = Boolean(rect);
|
||||
if (!rect) {
|
||||
try {
|
||||
rect = el.getBoundingClientRect();
|
||||
} catch (_error) {}
|
||||
}
|
||||
|
||||
var width = rect && Number(rect.width) > 0 ? Number(rect.width) : 0;
|
||||
var height = rect && Number(rect.height) > 0 ? Number(rect.height) : 0;
|
||||
@@ -57,17 +64,24 @@ function getPrimaryVideoElement(mediaElements) {
|
||||
: 0;
|
||||
var visibleArea = visibleWidth * visibleHeight;
|
||||
var visuallyAvailable = visibleArea > 0;
|
||||
try {
|
||||
var computed = win && win.getComputedStyle(el);
|
||||
if (
|
||||
computed &&
|
||||
(computed.display === "none" ||
|
||||
computed.visibility === "hidden" ||
|
||||
Number(computed.opacity) === 0)
|
||||
) {
|
||||
visuallyAvailable = false;
|
||||
}
|
||||
} catch (_error) {}
|
||||
if (
|
||||
el.vsc.div &&
|
||||
el.vsc.div.classList.contains("vsc-geometry-hidden")
|
||||
) {
|
||||
visuallyAvailable = false;
|
||||
} else if (!hasCachedRect) {
|
||||
try {
|
||||
var computed = win && win.getComputedStyle(el);
|
||||
if (
|
||||
computed &&
|
||||
(computed.display === "none" ||
|
||||
computed.visibility === "hidden" ||
|
||||
Number(computed.opacity) === 0)
|
||||
) {
|
||||
visuallyAvailable = false;
|
||||
}
|
||||
} catch (_error) {}
|
||||
}
|
||||
|
||||
var score = visibleArea;
|
||||
if (visuallyAvailable) score += 1e12;
|
||||
@@ -90,6 +104,81 @@ function getPrimaryVideoElement(mediaElements) {
|
||||
return best;
|
||||
}
|
||||
|
||||
function getDiagnosticsSnapshot(media) {
|
||||
var primary = media || getPrimaryVideoElement();
|
||||
if (!primary) return null;
|
||||
|
||||
var controller = primary.vsc || null;
|
||||
var wrapper = controller && controller.div;
|
||||
var fullscreenElement = getFullscreenElement(primary.ownerDocument);
|
||||
var rect = null;
|
||||
try {
|
||||
rect = primary.getBoundingClientRect();
|
||||
} catch (_error) {}
|
||||
|
||||
return {
|
||||
mediaCount: tc.mediaElements.filter(function(item) {
|
||||
return item && item.isConnected;
|
||||
}).length,
|
||||
mediaType: String(primary.nodeName || "media").toLowerCase(),
|
||||
playbackRate: Number(primary.playbackRate),
|
||||
paused: primary.paused === true,
|
||||
ended: primary.ended === true,
|
||||
readyState: Number(primary.readyState) || 0,
|
||||
muted: primary.muted === true,
|
||||
volume: Number(primary.volume),
|
||||
dimensions: rect
|
||||
? [Math.round(Number(rect.width) || 0), Math.round(Number(rect.height) || 0)]
|
||||
: [0, 0],
|
||||
fullscreen: {
|
||||
active: Boolean(fullscreenElement),
|
||||
element: fullscreenElement
|
||||
? String(fullscreenElement.nodeName || "element").toLowerCase()
|
||||
: null,
|
||||
ownsMedia: Boolean(
|
||||
fullscreenElement &&
|
||||
(fullscreenElement === primary ||
|
||||
isComposedDescendant(primary, fullscreenElement))
|
||||
)
|
||||
},
|
||||
controller: {
|
||||
present: Boolean(controller && wrapper),
|
||||
connected: Boolean(wrapper && wrapper.isConnected),
|
||||
hidden: Boolean(wrapper && wrapper.classList.contains("vsc-hidden")),
|
||||
geometryHidden: Boolean(
|
||||
wrapper && wrapper.classList.contains("vsc-geometry-hidden")
|
||||
),
|
||||
fullscreenPopover: Boolean(
|
||||
wrapper && wrapper.classList.contains("vsc-fullscreen-popover")
|
||||
),
|
||||
location: controller ? controller.controllerLocation : null
|
||||
},
|
||||
effectiveSettings: {
|
||||
enabled: tc.settings.enabled !== false,
|
||||
tabPaused: tc.tabPaused === true,
|
||||
startHidden: tc.settings.startHidden === true,
|
||||
hideWithControls: tc.settings.hideWithControls === true,
|
||||
rememberSpeed: tc.settings.rememberSpeed === true,
|
||||
forceLastSavedSpeed: tc.settings.forceLastSavedSpeed === true,
|
||||
shortcutTargetMode: tc.settings.shortcutTargetMode
|
||||
},
|
||||
siteRule: {
|
||||
matched: Boolean(tc.activeSiteRule),
|
||||
disabled: Boolean(
|
||||
tc.activeSiteRule &&
|
||||
siteRuleUtils.isSiteRuleDisabled(tc.activeSiteRule)
|
||||
),
|
||||
overrideKeys: tc.activeSiteRule
|
||||
? Object.keys(tc.activeSiteRule)
|
||||
.filter(function(key) {
|
||||
return key !== "pattern" && key !== "title";
|
||||
})
|
||||
.sort()
|
||||
: []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
var tc = {
|
||||
settings: {
|
||||
lastSpeed: getSharedDefault("lastSpeed", 1.0),
|
||||
@@ -98,6 +187,10 @@ var tc = {
|
||||
rememberSpeed: getSharedDefault("rememberSpeed", false),
|
||||
forceLastSavedSpeed: getSharedDefault("forceLastSavedSpeed", false),
|
||||
audioBoolean: getSharedDefault("audioBoolean", false),
|
||||
showAmbientLoopControls: getSharedDefault(
|
||||
"showAmbientLoopControls",
|
||||
false
|
||||
),
|
||||
startHidden: getSharedDefault("startHidden", false),
|
||||
hideWithYouTubeControls: getSharedDefault(
|
||||
"hideWithYouTubeControls",
|
||||
@@ -147,16 +240,44 @@ var tc = {
|
||||
speedAccessTimes: {},
|
||||
persistedLastSpeed: 1.0,
|
||||
activeSiteRule: null,
|
||||
tabPaused: false,
|
||||
siteRuleBase: null,
|
||||
runtimeSettingsHydrated: false,
|
||||
pendingMediaCandidates: [],
|
||||
settingsReloadRetries: 0,
|
||||
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 = 0.0625;
|
||||
var MAX_SPEED = 16;
|
||||
function isSpeederActiveForCurrentPage() {
|
||||
return (
|
||||
tc.tabPaused !== true &&
|
||||
siteRuleUtils.isSpeederActiveForSite(
|
||||
tc.settings.enabled,
|
||||
tc.activeSiteRule
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function applyTabPausedState(paused) {
|
||||
tc.tabPaused = paused === true;
|
||||
if (!tc.runtimeSettingsHydrated) return;
|
||||
if (tc.tabPaused) {
|
||||
clearAllSpeedRestoreEnforcement();
|
||||
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_MAX = 2.0;
|
||||
var YT_NATIVE_STEP = 0.05;
|
||||
@@ -231,21 +352,25 @@ var controllerLocationStyles = {
|
||||
|
||||
/* `label` fallback only when ui-icons has no path for the action. */
|
||||
var controllerButtonDefs = {
|
||||
rewind: { label: "", className: "rw" },
|
||||
slower: { label: "", className: "" },
|
||||
faster: { label: "", className: "" },
|
||||
advance: { label: "", className: "rw" },
|
||||
display: { label: "", className: "hideButton" },
|
||||
reset: { label: "\u21BB", className: "" },
|
||||
fast: { label: "", className: "" },
|
||||
nudge: { label: "", className: "" },
|
||||
pause: { label: "", className: "" },
|
||||
muted: { label: "", className: "" },
|
||||
louder: { label: "", className: "" },
|
||||
softer: { label: "", className: "" },
|
||||
mark: { label: "", className: "" },
|
||||
jump: { label: "", className: "" },
|
||||
settings: { label: "", className: "" }
|
||||
rewind: { label: "", name: "Rewind", className: "rw" },
|
||||
slower: { label: "", name: "Decrease speed", className: "" },
|
||||
faster: { label: "", name: "Increase speed", className: "" },
|
||||
advance: { label: "", name: "Advance", className: "rw" },
|
||||
display: {
|
||||
label: "",
|
||||
name: "Show or hide controller",
|
||||
className: "hideButton"
|
||||
},
|
||||
reset: { label: "\u21BB", name: "Reset speed", className: "" },
|
||||
fast: { label: "", name: "Toggle preferred speed", className: "" },
|
||||
nudge: { label: "", name: "Toggle subtitle nudge", className: "" },
|
||||
pause: { label: "", name: "Play or pause", className: "" },
|
||||
muted: { label: "", name: "Mute or unmute", 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) {
|
||||
@@ -488,6 +613,7 @@ function captureSiteRuleBase() {
|
||||
rememberSpeed: tc.settings.rememberSpeed,
|
||||
forceLastSavedSpeed: tc.settings.forceLastSavedSpeed,
|
||||
audioBoolean: tc.settings.audioBoolean,
|
||||
showAmbientLoopControls: tc.settings.showAmbientLoopControls,
|
||||
controllerOpacity: tc.settings.controllerOpacity,
|
||||
controllerMarginTop: tc.settings.controllerMarginTop,
|
||||
controllerMarginBottom: tc.settings.controllerMarginBottom,
|
||||
@@ -516,6 +642,7 @@ function resetSettingsFromSiteRuleBase() {
|
||||
tc.settings.rememberSpeed = base.rememberSpeed;
|
||||
tc.settings.forceLastSavedSpeed = base.forceLastSavedSpeed;
|
||||
tc.settings.audioBoolean = base.audioBoolean;
|
||||
tc.settings.showAmbientLoopControls = base.showAmbientLoopControls;
|
||||
tc.settings.controllerOpacity = base.controllerOpacity;
|
||||
tc.settings.controllerMarginTop = base.controllerMarginTop;
|
||||
tc.settings.controllerMarginBottom = base.controllerMarginBottom;
|
||||
@@ -646,6 +773,7 @@ function normalizeStoredBinding(binding, fallbackCode) {
|
||||
var normalized = {
|
||||
action: binding.action,
|
||||
code: normalizedCode,
|
||||
shiftKey: binding.shiftKey === true,
|
||||
disabled: false,
|
||||
value: keyBindingUtils.sanitizeActionValue(
|
||||
binding.action,
|
||||
@@ -727,6 +855,13 @@ function getRememberedSpeed(video) {
|
||||
var videoSpeed = tc.settings.speeds[sourceKey];
|
||||
if (isValidSpeed(videoSpeed)) return videoSpeed;
|
||||
}
|
||||
|
||||
// No per-source entry yet — fall back to the most recently used speed.
|
||||
// This ensures e.g. YouTube Shorts carry the user's chosen speed to the
|
||||
// next Short even when that exact source URL hasn't been visited before.
|
||||
if (isValidSpeed(tc.settings.lastSpeed)) {
|
||||
return tc.settings.lastSpeed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1347,7 +1482,8 @@ function matchesKeyBinding(binding, event) {
|
||||
binding.disabled !== true &&
|
||||
typeof binding.code === "string" &&
|
||||
binding.code.length > 0 &&
|
||||
binding.code === event.code
|
||||
binding.code === event.code &&
|
||||
(binding.shiftKey === true) === event.shiftKey
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1364,8 +1500,70 @@ function isMediaElement(node) {
|
||||
);
|
||||
}
|
||||
|
||||
function hasInteractivePlayerChrome(node) {
|
||||
if (!node || node.nodeName !== "VIDEO") return false;
|
||||
if (node.controls === true) return true;
|
||||
|
||||
if (typeof node.closest === "function") {
|
||||
var knownPlayer = node.closest(
|
||||
".html5-video-player, .video-js, .jwplayer, .plyr, " +
|
||||
"[class~='video-player'], [data-testid*='video-player'], " +
|
||||
"media-player, amp-video"
|
||||
);
|
||||
if (knownPlayer) return true;
|
||||
}
|
||||
|
||||
var ancestor = node.parentElement;
|
||||
var videoRect = node.getBoundingClientRect();
|
||||
var depth = 0;
|
||||
while (ancestor && depth < 4) {
|
||||
var ancestorRect = ancestor.getBoundingClientRect();
|
||||
if (
|
||||
videoRect.width > 0 &&
|
||||
videoRect.height > 0 &&
|
||||
(ancestorRect.width > Math.max(videoRect.width * 1.75, videoRect.width + 160) ||
|
||||
ancestorRect.height > Math.max(videoRect.height * 1.75, videoRect.height + 160))
|
||||
) {
|
||||
break;
|
||||
}
|
||||
if (
|
||||
(videoRect.width <= 0 || videoRect.height <= 0) &&
|
||||
depth > 1
|
||||
) {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
var controls = ancestor.querySelectorAll(
|
||||
"button, [role='button'], input[type='range'], [role='slider']"
|
||||
);
|
||||
for (var i = 0; i < Math.min(controls.length, 40); i += 1) {
|
||||
var control = controls[i];
|
||||
var description = [
|
||||
control.getAttribute("aria-label"),
|
||||
control.getAttribute("title"),
|
||||
control.getAttribute("data-title-no-tooltip"),
|
||||
control.className
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
if (
|
||||
/(^|\b)(play|pause|mute|volume|seek|captions?|subtitles?|fullscreen|full screen)(\b|$)/i.test(
|
||||
description
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (_error) {}
|
||||
ancestor = ancestor.parentElement;
|
||||
depth += 1;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isAmbientLoopMedia(node) {
|
||||
return Boolean(
|
||||
var hasAmbientSignature = Boolean(
|
||||
node &&
|
||||
node.nodeName === "VIDEO" &&
|
||||
node.autoplay === true &&
|
||||
@@ -1374,6 +1572,8 @@ function isAmbientLoopMedia(node) {
|
||||
node.playsInline === true &&
|
||||
node.controls !== true
|
||||
);
|
||||
if (!hasAmbientSignature || tc.settings.showAmbientLoopControls) return false;
|
||||
return !hasInteractivePlayerChrome(node);
|
||||
}
|
||||
|
||||
function hasUsableMediaSource(node) {
|
||||
@@ -1424,16 +1624,16 @@ function ensureController(node, parent) {
|
||||
return null;
|
||||
}
|
||||
if (!isMediaElement(node)) return null;
|
||||
if (isAmbientLoopMedia(node)) {
|
||||
if (node.vsc) removeController(node);
|
||||
return null;
|
||||
}
|
||||
if (typeof tc.videoController === "undefined") defineVideoController();
|
||||
|
||||
// href selects site rules; re-run on every new/usable media so all runtime
|
||||
// paths agree on activation and effective settings.
|
||||
applySiteRuleOverrides();
|
||||
if (!siteRuleUtils.isSpeederActiveForSite(tc.settings.enabled, tc.activeSiteRule)) {
|
||||
if (!isSpeederActiveForCurrentPage()) {
|
||||
if (node.vsc) removeController(node);
|
||||
return null;
|
||||
}
|
||||
if (isAmbientLoopMedia(node)) {
|
||||
if (node.vsc) removeController(node);
|
||||
return null;
|
||||
}
|
||||
@@ -1913,6 +2113,8 @@ function hydrateRuntimeSettings(rawStorage, options) {
|
||||
tc.settings.rememberSpeed = storage.rememberSpeed === true;
|
||||
tc.settings.forceLastSavedSpeed = storage.forceLastSavedSpeed === true;
|
||||
tc.settings.audioBoolean = storage.audioBoolean === true;
|
||||
tc.settings.showAmbientLoopControls =
|
||||
storage.showAmbientLoopControls === true;
|
||||
tc.settings.enabled = storage.enabled !== false;
|
||||
tc.settings.startHidden = storage.startHidden === true;
|
||||
tc.settings.hideWithControls =
|
||||
@@ -2235,12 +2437,24 @@ function loadInitialRuntimeSettings(attempt) {
|
||||
return;
|
||||
}
|
||||
hydrateRuntimeSettings(rawStorage || {});
|
||||
if (chrome.runtime && typeof chrome.runtime.sendMessage === "function") {
|
||||
chrome.runtime.sendMessage({ action: "get_tab_pause_state" }, function(response) {
|
||||
if (!chrome.runtime.lastError && response) {
|
||||
applyTabPausedState(response.paused === true);
|
||||
}
|
||||
});
|
||||
}
|
||||
// patchAttachShadow() is now called at top-level before this callback
|
||||
// Add a listener for messages from the popup.
|
||||
// We use a global flag to ensure the listener is only attached once.
|
||||
if (!window.vscMessageListener) {
|
||||
chrome.runtime.onMessage.addListener(
|
||||
function(request, sender, sendResponse) {
|
||||
if (request.action === "set_tab_paused") {
|
||||
applyTabPausedState(request.paused === true);
|
||||
sendResponse({ paused: tc.tabPaused });
|
||||
return false;
|
||||
}
|
||||
if (request.action === "rescan_page") {
|
||||
log("Re-scan command received from popup.", 4);
|
||||
initializeWhenReady(document, true);
|
||||
@@ -2254,6 +2468,8 @@ function loadInitialRuntimeSettings(attempt) {
|
||||
if (!videoGs) return false;
|
||||
sendResponse({
|
||||
speed: videoGs.playbackRate,
|
||||
frameToken: tc.frameToken,
|
||||
diagnostics: getDiagnosticsSnapshot(videoGs),
|
||||
forceLastSavedSpeed: tc.settings.forceLastSavedSpeed === true,
|
||||
forceLastSavedSpeedControlledBySiteRule: Boolean(
|
||||
tc.activeSiteRule &&
|
||||
@@ -2291,10 +2507,13 @@ function loadInitialRuntimeSettings(attempt) {
|
||||
}
|
||||
if (request.action === "run_action") {
|
||||
if (
|
||||
!siteRuleUtils.isSpeederActiveForSite(
|
||||
tc.settings.enabled,
|
||||
tc.activeSiteRule
|
||||
)
|
||||
request.targetFrameToken &&
|
||||
request.targetFrameToken !== tc.frameToken
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!isSpeederActiveForCurrentPage()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -2485,6 +2704,9 @@ function loadInitialRuntimeSettings(attempt) {
|
||||
});
|
||||
}
|
||||
|
||||
// Install before async settings hydration so SPA-owned window capture handlers
|
||||
// cannot hide later key events from Speeder.
|
||||
attachKeydownListeners(document);
|
||||
loadInitialRuntimeSettings(0);
|
||||
|
||||
function getKeyBindings(action, what = "value") {
|
||||
@@ -2506,7 +2728,11 @@ function setKeyBindings(action, value) {
|
||||
|
||||
function createControllerButton(doc, action, label, className) {
|
||||
var button = doc.createElement("button");
|
||||
var name = controllerButtonDefs[action] && controllerButtonDefs[action].name;
|
||||
button.type = "button";
|
||||
button.dataset.action = action;
|
||||
button.setAttribute("aria-label", name || action);
|
||||
button.title = name || action;
|
||||
var custom =
|
||||
tc.settings.customButtonIcons &&
|
||||
tc.settings.customButtonIcons[action] &&
|
||||
@@ -2682,8 +2908,6 @@ function getControllerMount(video, boundary) {
|
||||
return isShadowRootNode(directRoot) ? directRoot : null;
|
||||
}
|
||||
|
||||
var mountBoundary = null;
|
||||
|
||||
var videoRect = video.getBoundingClientRect();
|
||||
var mount = video.parentElement;
|
||||
var candidate = mount;
|
||||
@@ -2701,15 +2925,7 @@ function getControllerMount(video, boundary) {
|
||||
// Climb through tightly-sized wrappers so our host shares their stacking
|
||||
// context, but stop before broad page-layout containers.
|
||||
while (candidate && candidate.parentElement && depth < 5) {
|
||||
if (mountBoundary && candidate === mountBoundary) break;
|
||||
var next = candidate.parentElement;
|
||||
if (
|
||||
mountBoundary &&
|
||||
next !== mountBoundary &&
|
||||
!mountBoundary.contains(next)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
var nextRect = next.getBoundingClientRect();
|
||||
var widthLimit = Math.max(videoRect.width * 1.35, videoRect.width + 80);
|
||||
var heightLimit = Math.max(videoRect.height * 1.35, videoRect.height + 80);
|
||||
@@ -2733,10 +2949,6 @@ function getControllerMount(video, boundary) {
|
||||
candidate = next;
|
||||
depth += 1;
|
||||
|
||||
// In fullscreen, the wrapper must remain inside the exact subtree the
|
||||
// browser promotes to its top layer.
|
||||
if (mountBoundary && next === mountBoundary) break;
|
||||
|
||||
// Never climb out of a player-owned stacking context. Doing so lets the
|
||||
// controller's high local z-index escape above sticky page headers.
|
||||
if (createsControllerStackingContext(next)) break;
|
||||
@@ -2764,6 +2976,14 @@ function positionControllerHost(wrapper, video, mount) {
|
||||
return;
|
||||
}
|
||||
var videoRect = video.getBoundingClientRect();
|
||||
wrapper.vscVideoRect = {
|
||||
left: Number(videoRect.left) || 0,
|
||||
top: Number(videoRect.top) || 0,
|
||||
right: Number(videoRect.right) || 0,
|
||||
bottom: Number(videoRect.bottom) || 0,
|
||||
width: Number(videoRect.width) || 0,
|
||||
height: Number(videoRect.height) || 0
|
||||
};
|
||||
if (wrapper.classList.contains("vsc-fullscreen-popover")) {
|
||||
if (videoRect.width <= 0 || videoRect.height <= 0) {
|
||||
wrapper.classList.add("vsc-geometry-hidden");
|
||||
@@ -2932,6 +3152,12 @@ function setupControllerHostTracking(videoController, wrapper, mount) {
|
||||
resizeObserver.observe(geometryMount);
|
||||
}
|
||||
|
||||
var mediaGeometryEvents = ["loadedmetadata", "play", "playing"];
|
||||
mediaGeometryEvents.forEach(function(eventName) {
|
||||
videoController.video.addEventListener(eventName, schedule, {
|
||||
passive: true
|
||||
});
|
||||
});
|
||||
win.addEventListener("resize", schedule, { passive: true });
|
||||
doc.addEventListener("fullscreenchange", schedule, { passive: true });
|
||||
geometryMount.addEventListener("scroll", schedule, { passive: true });
|
||||
@@ -2942,6 +3168,9 @@ function setupControllerHostTracking(videoController, wrapper, mount) {
|
||||
if (resizeObserver) resizeObserver.disconnect();
|
||||
if (frameId !== null) win.cancelAnimationFrame(frameId);
|
||||
if (geometryRetryTimer !== null) win.clearTimeout(geometryRetryTimer);
|
||||
mediaGeometryEvents.forEach(function(eventName) {
|
||||
videoController.video.removeEventListener(eventName, schedule);
|
||||
});
|
||||
win.removeEventListener("resize", schedule);
|
||||
doc.removeEventListener("fullscreenchange", schedule);
|
||||
geometryMount.removeEventListener("scroll", schedule);
|
||||
@@ -3112,18 +3341,20 @@ function syncControllerFullscreenMount(videoController) {
|
||||
var doc = video.ownerDocument;
|
||||
var fullscreenElement = getFullscreenElement(doc);
|
||||
var targetMount = videoController.normalControllerMount;
|
||||
|
||||
if (fullscreenElement === video) {
|
||||
if (enableDirectFullscreenPopover(videoController)) return true;
|
||||
} else {
|
||||
disableDirectFullscreenPopover(videoController);
|
||||
}
|
||||
|
||||
if (
|
||||
var ownsFullscreen = Boolean(
|
||||
fullscreenElement &&
|
||||
(fullscreenElement === video ||
|
||||
isComposedDescendant(video, fullscreenElement))
|
||||
) {
|
||||
(fullscreenElement === video ||
|
||||
isComposedDescendant(video, fullscreenElement))
|
||||
);
|
||||
var normalGeometryMount = getControllerGeometryMount(targetMount);
|
||||
var normalMountIsAlreadyFullscreenVisible = Boolean(
|
||||
fullscreenElement &&
|
||||
fullscreenElement !== video &&
|
||||
normalGeometryMount &&
|
||||
isComposedDescendant(normalGeometryMount, fullscreenElement)
|
||||
);
|
||||
|
||||
if (ownsFullscreen && !normalMountIsAlreadyFullscreenVisible) {
|
||||
targetMount = getControllerMount(video, fullscreenElement);
|
||||
} else if (!fullscreenElement && (!targetMount || !targetMount.isConnected)) {
|
||||
targetMount = getControllerMount(video);
|
||||
@@ -3131,6 +3362,15 @@ function syncControllerFullscreenMount(videoController) {
|
||||
}
|
||||
|
||||
if (!targetMount) return false;
|
||||
|
||||
if (fullscreenElement === video) {
|
||||
// A replaced <video> cannot paint author children, so direct-media
|
||||
// fullscreen is the only case that needs a separate top-layer popover.
|
||||
if (enableDirectFullscreenPopover(videoController)) return true;
|
||||
} else {
|
||||
disableDirectFullscreenPopover(videoController);
|
||||
}
|
||||
|
||||
return remountControllerHost(videoController, targetMount);
|
||||
}
|
||||
|
||||
@@ -3268,9 +3508,6 @@ function defineVideoController() {
|
||||
setSpeed(event.target, expectedSpeed, false, false);
|
||||
}
|
||||
|
||||
if (isUserSeek) {
|
||||
isUserSeek = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3567,12 +3804,19 @@ function defineVideoController() {
|
||||
// YouTube adds ytp-autohide class to the player when controls should be hidden
|
||||
// We mirror this class state to enable CSS-based hiding
|
||||
// The vsc-hidden class (from V key) takes precedence via CSS specificity
|
||||
if (ytPlayer.classList.contains("ytp-autohide")) {
|
||||
if (
|
||||
ytPlayer.classList.contains("ytp-autohide") &&
|
||||
!this.controllerInteractionActive
|
||||
) {
|
||||
wrapper.classList.add("ytp-autohide");
|
||||
|
||||
// Immediately end any temporary "vsc-show" state to hide with YouTube
|
||||
// UNLESS it was forced by a shortcut (vsc-forced-show)
|
||||
if (!wrapper.classList.contains("vsc-forced-show")) {
|
||||
// Preserve an active pointer reveal or shortcut-forced reveal. The
|
||||
// ytp-autohide class remains in place, so CSS hides the host as soon as
|
||||
// vsc-show's bounded timer expires.
|
||||
if (
|
||||
!wrapper.classList.contains("vsc-forced-show") &&
|
||||
!wrapper.classList.contains("vsc-show")
|
||||
) {
|
||||
wrapper.classList.remove("vsc-show");
|
||||
if (wrapper.showTimeOut) {
|
||||
clearTimeout(wrapper.showTimeOut);
|
||||
@@ -3612,6 +3856,16 @@ function defineVideoController() {
|
||||
showController(wrapper, tc.settings.hideWithControlsTimer * 1000);
|
||||
};
|
||||
|
||||
const handleControllerInteractionChange = (active) => {
|
||||
if (active) {
|
||||
wrapper.classList.remove("ytp-autohide");
|
||||
showController(wrapper, tc.settings.hideWithControlsTimer * 1000);
|
||||
} else {
|
||||
syncControllerVisibility();
|
||||
}
|
||||
};
|
||||
this.controllerInteractionChanged = handleControllerInteractionChange;
|
||||
|
||||
const activityEvents = ["mousemove", "mousedown", "touchstart"];
|
||||
activityEvents.forEach((type) => {
|
||||
video.addEventListener(type, resetTimer, { passive: true });
|
||||
@@ -3626,6 +3880,9 @@ function defineVideoController() {
|
||||
wrapper.removeEventListener(type, resetTimer);
|
||||
ytPlayer.removeEventListener(type, resetTimer);
|
||||
});
|
||||
if (this.controllerInteractionChanged === handleControllerInteractionChange) {
|
||||
this.controllerInteractionChanged = null;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3642,21 +3899,32 @@ function defineVideoController() {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
timer = setTimeout(() => {
|
||||
// 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.
|
||||
timer = null;
|
||||
if (this.controllerInteractionActive) return;
|
||||
wrapper.classList.add("vsc-idle-hidden");
|
||||
log("Generic hide: controller hidden due to inactivity", 5);
|
||||
}, tc.settings.hideWithControlsTimer * 1000);
|
||||
};
|
||||
|
||||
const handleControllerInteractionChange = (active) => {
|
||||
if (active) {
|
||||
wrapper.classList.remove("vsc-idle-hidden");
|
||||
showController(wrapper, tc.settings.hideWithControlsTimer * 1000);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
} else {
|
||||
resetTimer();
|
||||
}
|
||||
};
|
||||
this.controllerInteractionChanged = handleControllerInteractionChange;
|
||||
|
||||
// Initial show/timer
|
||||
resetTimer();
|
||||
|
||||
// The wrapper covers the player area on most sites due to inject.css styles,
|
||||
// but we listen on both the video and the wrapper for maximum coverage.
|
||||
// Players dispatch activity at different layers, so observe the media,
|
||||
// aligned controller host, and player mount.
|
||||
const activityEvents = ["mousemove", "mousedown", "keydown", "touchstart"];
|
||||
const parentEl =
|
||||
getControllerGeometryMount(this.controllerHostMount) ||
|
||||
@@ -3685,6 +3953,9 @@ function defineVideoController() {
|
||||
});
|
||||
video.removeEventListener("play", resetTimer);
|
||||
video.removeEventListener("pause", resetTimer);
|
||||
if (this.controllerInteractionChanged === handleControllerInteractionChange) {
|
||||
this.controllerInteractionChanged = null;
|
||||
}
|
||||
};
|
||||
|
||||
log(`Generic auto-hide setup complete with ${tc.settings.hideWithControlsTimer}s timer`, 4);
|
||||
@@ -3695,6 +3966,9 @@ function defineVideoController() {
|
||||
const speed = this.video.playbackRate.toFixed(2);
|
||||
var wrapper = doc.createElement("div");
|
||||
wrapper.classList.add("vsc-controller");
|
||||
// Keep the host out of player layout while its shadow stylesheet loads.
|
||||
wrapper.style.position = "absolute";
|
||||
wrapper.style.pointerEvents = "none";
|
||||
if (!hasUsableMediaSource(this.video))
|
||||
wrapper.classList.add("vsc-nosource");
|
||||
if (tc.settings.startHidden) wrapper.classList.add("vsc-hidden");
|
||||
@@ -3732,11 +4006,14 @@ function defineVideoController() {
|
||||
|
||||
buttonConfig.forEach(function(btnId) {
|
||||
if (btnId === "nudge") {
|
||||
subtitleNudgeIndicator = doc.createElement("span");
|
||||
subtitleNudgeIndicator = createControllerButton(
|
||||
doc,
|
||||
btnId,
|
||||
controllerButtonDefs.nudge.label,
|
||||
controllerButtonDefs.nudge.className
|
||||
);
|
||||
subtitleNudgeIndicator.id = "nudge-indicator";
|
||||
subtitleNudgeIndicator.setAttribute("role", "button");
|
||||
subtitleNudgeIndicator.setAttribute("aria-live", "polite");
|
||||
subtitleNudgeIndicator.setAttribute("tabindex", "0");
|
||||
controls.appendChild(subtitleNudgeIndicator);
|
||||
} else {
|
||||
var def = controllerButtonDefs[btnId];
|
||||
@@ -3758,6 +4035,27 @@ function defineVideoController() {
|
||||
controller.appendChild(nudgeFlashIndicator);
|
||||
shadow.appendChild(controller);
|
||||
|
||||
const setControllerInteractionActive = (active) => {
|
||||
this.controllerInteractionActive = active === true;
|
||||
controller.classList.toggle(
|
||||
"vsc-controls-hovered",
|
||||
this.controllerInteractionActive
|
||||
);
|
||||
wrapper.classList.toggle(
|
||||
"vsc-controls-hovered",
|
||||
this.controllerInteractionActive
|
||||
);
|
||||
if (typeof this.controllerInteractionChanged === "function") {
|
||||
this.controllerInteractionChanged(this.controllerInteractionActive);
|
||||
}
|
||||
};
|
||||
controller.addEventListener("pointerenter", () => {
|
||||
setControllerInteractionActive(true);
|
||||
});
|
||||
controller.addEventListener("pointerleave", () => {
|
||||
setControllerInteractionActive(false);
|
||||
});
|
||||
|
||||
this.speedIndicator = dragHandle;
|
||||
this.subtitleNudgeIndicator = subtitleNudgeIndicator;
|
||||
this.nudgeFlashIndicator = nudgeFlashIndicator;
|
||||
@@ -3799,21 +4097,6 @@ function defineVideoController() {
|
||||
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("mousedown", (e) => e.stopPropagation(), false);
|
||||
|
||||
@@ -3928,6 +4211,7 @@ function applySiteRuleOverrides() {
|
||||
"rememberSpeed",
|
||||
"forceLastSavedSpeed",
|
||||
"audioBoolean",
|
||||
"showAmbientLoopControls",
|
||||
"controllerOpacity",
|
||||
"controllerMarginTop",
|
||||
"controllerMarginBottom",
|
||||
@@ -3977,9 +4261,12 @@ function applySiteRuleOverrides() {
|
||||
}
|
||||
|
||||
function removeIneligibleMediaControllers() {
|
||||
if (tc.settings.audioBoolean) return;
|
||||
tc.mediaElements.slice().forEach(function(media) {
|
||||
if (media && media.nodeName === "AUDIO") {
|
||||
if (
|
||||
media &&
|
||||
((media.nodeName === "AUDIO" && !tc.settings.audioBoolean) ||
|
||||
isAmbientLoopMedia(media))
|
||||
) {
|
||||
removeController(media);
|
||||
}
|
||||
});
|
||||
@@ -4001,7 +4288,7 @@ function refreshAllControllerGeometry() {
|
||||
/** Re-match site rules for current URL and refresh controller position/opacity on every video. */
|
||||
function reapplySiteRulesAndControllerGeometry() {
|
||||
applySiteRuleOverrides();
|
||||
if (!siteRuleUtils.isSpeederActiveForSite(tc.settings.enabled, tc.activeSiteRule)) {
|
||||
if (!isSpeederActiveForCurrentPage()) {
|
||||
tc.mediaElements.slice().forEach(function(video) {
|
||||
removeController(video);
|
||||
});
|
||||
@@ -4059,7 +4346,7 @@ function setupListener(root) {
|
||||
if (config.skipResetDisarm !== true) {
|
||||
video.vsc.resetToggleArmed = false;
|
||||
}
|
||||
var speed = video.playbackRate; // Preserve full precision (e.g. 0.0625)
|
||||
var speed = video.playbackRate; // Preserve full precision (e.g. 0.01)
|
||||
video.vsc.speedIndicator.textContent = speed.toFixed(2);
|
||||
video.vsc.targetSpeed = speed;
|
||||
video.vsc.targetSpeedSourceKey = getVideoSourceKey(video);
|
||||
@@ -4091,7 +4378,7 @@ function setupListener(root) {
|
||||
if (!video || typeof video.playbackRate === "undefined" || !video.vsc)
|
||||
return;
|
||||
if (shouldIgnoreSuppressedRateChange(video)) return;
|
||||
var currentSpeed = video.playbackRate; // Preserve full precision (e.g. 0.0625)
|
||||
var currentSpeed = video.playbackRate; // Preserve full precision (e.g. 0.01)
|
||||
var pendingRateChange = takePendingRateChange(video, currentSpeed);
|
||||
if (tc.settings.forceLastSavedSpeed) {
|
||||
if (pendingRateChange) {
|
||||
@@ -4160,7 +4447,11 @@ function clearPendingInitialization(doc) {
|
||||
|
||||
function tryInitializeDocument(doc, forceReinit) {
|
||||
if (!doc) return false;
|
||||
if ((!forceReinit && vscInitializedDocuments.has(doc)) || !doc.body) {
|
||||
if (
|
||||
!tc.runtimeSettingsHydrated ||
|
||||
(!forceReinit && vscInitializedDocuments.has(doc)) ||
|
||||
!doc.body
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -4203,14 +4494,41 @@ 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) {
|
||||
// Content scripts already run in every frame. Keeping each listener scoped
|
||||
// to its own document avoids duplicate shortcuts and stale iframe ownership.
|
||||
// to its own frame avoids duplicate shortcuts and stale iframe ownership.
|
||||
var docs = [doc];
|
||||
|
||||
docs.forEach(function(keyDoc) {
|
||||
if (keyDoc.vscKeydownListenerAttached) return;
|
||||
keyDoc.addEventListener(
|
||||
var keyTarget = keyDoc.defaultView || keyDoc;
|
||||
if (keyTarget.vscKeydownListenerAttached) return;
|
||||
keyTarget.addEventListener(
|
||||
"keydown",
|
||||
function(event) {
|
||||
if (
|
||||
@@ -4225,19 +4543,10 @@ function attachKeydownListeners(doc) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event.target.nodeName === "INPUT" ||
|
||||
event.target.nodeName === "TEXTAREA" ||
|
||||
event.target.isContentEditable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (isEditableShortcutTarget(event)) return;
|
||||
|
||||
if (
|
||||
!siteRuleUtils.isSpeederActiveForSite(
|
||||
tc.settings.enabled,
|
||||
tc.activeSiteRule
|
||||
)
|
||||
!isSpeederActiveForCurrentPage()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -4262,7 +4571,7 @@ function attachKeydownListeners(doc) {
|
||||
},
|
||||
true
|
||||
);
|
||||
keyDoc.vscKeydownListenerAttached = true;
|
||||
keyTarget.vscKeydownListenerAttached = true;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4408,7 +4717,16 @@ function attachMediaDetectionListeners(root) {
|
||||
}
|
||||
|
||||
function scheduleNavigationRescan() {
|
||||
window.vscLastObservedHref = location.href;
|
||||
var nextHref = location.href;
|
||||
if (window.vscLastObservedHref !== nextHref) {
|
||||
// Pointer and controller interaction state belongs to the previous route.
|
||||
// YouTube keeps hover-preview and Shorts players connected across SPA
|
||||
// navigations, so retaining either target can send shortcuts to an
|
||||
// off-screen/stale player even though the new watch controller works.
|
||||
tc.lastPointerPosition = null;
|
||||
tc.lastInteractedMedia = null;
|
||||
}
|
||||
window.vscLastObservedHref = nextHref;
|
||||
clearTimeout(window.vscNavigationRescanTimer);
|
||||
window.vscNavigationRescanTimer = setTimeout(function() {
|
||||
initializeWhenReady(document, true);
|
||||
@@ -4453,10 +4771,7 @@ function initializeNow(doc, forceReinit = false) {
|
||||
attachNavigationListeners();
|
||||
if (typeof tc.videoController === "undefined") defineVideoController();
|
||||
applySiteRuleOverrides();
|
||||
var isActive = siteRuleUtils.isSpeederActiveForSite(
|
||||
tc.settings.enabled,
|
||||
tc.activeSiteRule
|
||||
);
|
||||
var isActive = isSpeederActiveForCurrentPage();
|
||||
|
||||
// Keep observing while inactive so dynamically-created media/shadow roots
|
||||
// are available to the next forced SPA rescan, but remove stale controls.
|
||||
@@ -4608,16 +4923,7 @@ function getClosestMediaToPointer(candidates, pointerPosition) {
|
||||
|
||||
candidates.forEach(function(video) {
|
||||
if (!video || !video.vsc || !video.isConnected) return;
|
||||
var target = getControllerElement(video.vsc) || video;
|
||||
var rect = null;
|
||||
try {
|
||||
rect = target.getBoundingClientRect();
|
||||
if (!rect || rect.width <= 0 || rect.height <= 0) {
|
||||
rect = video.getBoundingClientRect();
|
||||
}
|
||||
} catch (_error) {
|
||||
return;
|
||||
}
|
||||
var rect = getCachedVideoRect(video);
|
||||
if (!rect || rect.width <= 0 || rect.height <= 0) return;
|
||||
var distance = distanceSquaredToRect(
|
||||
pointerPosition.x,
|
||||
@@ -4658,6 +4964,16 @@ function resolveActionMediaTargets(event, specificVideo) {
|
||||
|
||||
if (tc.settings.shortcutTargetMode === "all") return candidates;
|
||||
|
||||
if (
|
||||
isOnYouTube() &&
|
||||
/^\/(watch|live)(\/|$)/.test(location.pathname)
|
||||
) {
|
||||
var youtubeMain = candidates.find(function(video) {
|
||||
return video.closest && video.closest("#movie_player");
|
||||
});
|
||||
if (youtubeMain) return [youtubeMain];
|
||||
}
|
||||
|
||||
var pointer =
|
||||
tc.lastPointerPosition && tc.lastPointerPosition.document === docContext
|
||||
? tc.lastPointerPosition
|
||||
@@ -4736,12 +5052,10 @@ function runAction(action, value, e) {
|
||||
);
|
||||
switch (action) {
|
||||
case "rewind":
|
||||
isUserSeek = true;
|
||||
extendSpeedRestoreWindow(v);
|
||||
v.currentTime -= numValue;
|
||||
break;
|
||||
case "advance":
|
||||
isUserSeek = true;
|
||||
extendSpeedRestoreWindow(v);
|
||||
v.currentTime += numValue;
|
||||
break;
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
:host {
|
||||
position: absolute !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
pointer-events: none !important;
|
||||
z-index: 2147483647 !important;
|
||||
white-space: normal;
|
||||
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-hidden) {
|
||||
display: none !important;
|
||||
@@ -44,6 +57,7 @@
|
||||
after a mouse click kept #controls visible while hover-only rules (e.g. draggable
|
||||
margin) turned off when the pointer left the bar. */
|
||||
#controller:hover #controls,
|
||||
#controller.vsc-controls-hovered #controls,
|
||||
#controller:focus-within:has(:focus-visible) #controls,
|
||||
:host(:hover) #controls {
|
||||
display: inline-flex;
|
||||
@@ -85,6 +99,10 @@
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
#controller.vsc-controls-hovered > .draggable {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
/* Center presets: midpoint between left- and right-preset inset lines; center bar on that X. */
|
||||
#controller[data-location="top-center"]:not([data-position-mode="manual"]) {
|
||||
transform: translate(-50%, 0) !important;
|
||||
@@ -311,10 +329,15 @@ button .vsc-btn-icon svg {
|
||||
transform: translateY(0.5px);
|
||||
}
|
||||
|
||||
button:focus {
|
||||
button:focus:not(:focus-visible) {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "Speeder",
|
||||
"short_name": "Speeder",
|
||||
"version": "5.3.6.0",
|
||||
"version": "6.0.8.2",
|
||||
"manifest_version": 2,
|
||||
"description": "Speed up, slow down, advance and rewind HTML5 audio/video with shortcuts (New and improved version of \"Video Speed Controller\")",
|
||||
"homepage_url": "https://github.com/SoPat712/speeder",
|
||||
@@ -71,7 +71,6 @@
|
||||
}
|
||||
],
|
||||
"web_accessible_resources": [
|
||||
"content/inject.css",
|
||||
"content/shadow-bridge.js",
|
||||
"content/shadow.css"
|
||||
]
|
||||
|
||||
@@ -54,11 +54,7 @@ function normalizedSettingsForExport(rawStorage) {
|
||||
// lastSpeed is useful user data, but it is intentionally outside the
|
||||
// managed options diff because content scripts update it at runtime.
|
||||
if (Object.prototype.hasOwnProperty.call(raw, "lastSpeed")) {
|
||||
if (
|
||||
Number.isFinite(expanded.lastSpeed) &&
|
||||
expanded.lastSpeed >= 0.0625 &&
|
||||
expanded.lastSpeed <= 16
|
||||
) {
|
||||
if (Number.isFinite(expanded.lastSpeed)) {
|
||||
normalized.lastSpeed = expanded.lastSpeed;
|
||||
}
|
||||
}
|
||||
@@ -117,11 +113,7 @@ function persistImportedSyncSettings(currentRaw, importedRaw, callback) {
|
||||
importedRaw &&
|
||||
Object.prototype.hasOwnProperty.call(importedRaw, "lastSpeed")
|
||||
) {
|
||||
if (
|
||||
Number.isFinite(expanded.lastSpeed) &&
|
||||
expanded.lastSpeed >= 0.0625 &&
|
||||
expanded.lastSpeed <= 16
|
||||
) {
|
||||
if (Number.isFinite(expanded.lastSpeed)) {
|
||||
mutation.set.lastSpeed = expanded.lastSpeed;
|
||||
}
|
||||
}
|
||||
@@ -223,14 +215,15 @@ function importSettings() {
|
||||
importLocalSettings(scopedLocalSettings, function (localError) {
|
||||
if (localError) {
|
||||
showStatus(
|
||||
"Error: Failed to save local extension data - " +
|
||||
localError.message,
|
||||
"Settings imported, but custom icons could not be updated - " +
|
||||
localError.message +
|
||||
". Reloading...",
|
||||
true
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
showStatus("Settings imported successfully. Reloading...");
|
||||
}
|
||||
|
||||
showStatus("Settings imported successfully. Reloading...");
|
||||
setTimeout(function () {
|
||||
if (typeof restore_options === "function") {
|
||||
restore_options();
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
--text: #17191c;
|
||||
--muted: #626b76;
|
||||
--accent: #111827;
|
||||
--focus-ring: #2563eb;
|
||||
--switch-track-off: #c1cad6;
|
||||
--switch-track-off-border: #aeb8c5;
|
||||
--switch-track-on: #111827;
|
||||
@@ -177,9 +178,14 @@ a:visited {
|
||||
text-underline-offset: 0.14em;
|
||||
}
|
||||
|
||||
a:hover,
|
||||
a:focus {
|
||||
color: #000;
|
||||
a:hover {
|
||||
color: var(--text);
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
||||
a:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
code {
|
||||
@@ -225,7 +231,7 @@ input[type="checkbox"]:focus-visible,
|
||||
input[type="text"]:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: 2px solid rgba(17, 24, 39, 0.14);
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -322,7 +328,7 @@ label em {
|
||||
|
||||
.shortcut-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 120px 120px;
|
||||
grid-template-columns: minmax(0, 1fr) 120px 120px 38px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
@@ -334,10 +340,6 @@ label em {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.shortcut-row.customs {
|
||||
grid-template-columns: minmax(0, 1fr) 120px 120px 38px;
|
||||
}
|
||||
|
||||
.shortcut-label {
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
@@ -628,6 +630,9 @@ label em {
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
transition: box-shadow 150ms ease, opacity 150ms ease;
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.cb-block:hover {
|
||||
@@ -1061,6 +1066,20 @@ button.lucide-result-tile.lucide-picked {
|
||||
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 {
|
||||
display: none;
|
||||
}
|
||||
@@ -1229,6 +1248,7 @@ button.lucide-result-tile.lucide-picked {
|
||||
--text: #f2f4f6;
|
||||
--muted: #a0a8b2;
|
||||
--accent: #f2f4f6;
|
||||
--focus-ring: #93c5fd;
|
||||
--switch-track-off: #374151;
|
||||
--switch-track-off-border: #4b5563;
|
||||
--switch-track-on: #aab7c6;
|
||||
|
||||
@@ -250,7 +250,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<select id="addShortcutSelector">
|
||||
<select id="addShortcutSelector" aria-label="Add another shortcut">
|
||||
<option value="">Add shortcut…</option>
|
||||
</select>
|
||||
</section>
|
||||
@@ -282,6 +282,17 @@
|
||||
</label>
|
||||
<input id="audioBoolean" type="checkbox" />
|
||||
</div>
|
||||
<div class="row row-checkbox">
|
||||
<label for="showAmbientLoopControls"
|
||||
>Show controls on ambient/slideshow videos<br />
|
||||
<em
|
||||
>Also shows Speeder on muted autoplay loops used as animated
|
||||
cards or slideshow artwork. Genuine video players are always
|
||||
controlled regardless of this setting.</em
|
||||
>
|
||||
</label>
|
||||
<input id="showAmbientLoopControls" type="checkbox" />
|
||||
</div>
|
||||
|
||||
<div class="defaults-divider"></div>
|
||||
<h4 class="defaults-sub-heading">Playback</h4>
|
||||
@@ -457,8 +468,8 @@
|
||||
<div class="section-heading">
|
||||
<h3>Hover control bar</h3>
|
||||
<p class="section-intro">
|
||||
Drag blocks to reorder. Move between Active and Available to
|
||||
show or hide buttons.
|
||||
Drag blocks or use arrow keys to reorder. Press a block to
|
||||
move it between Active and Available.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cb-editor">
|
||||
@@ -580,7 +591,7 @@
|
||||
<div
|
||||
id="lucideIconResults"
|
||||
class="lucide-icon-results"
|
||||
role="listbox"
|
||||
role="group"
|
||||
aria-label="Matching Lucide icons"
|
||||
></div>
|
||||
<p id="lucideIconStatus" class="lucide-icon-status" aria-live="polite"></p>
|
||||
@@ -803,8 +814,8 @@
|
||||
<label class="site-override-lead">
|
||||
<span
|
||||
>Override playback for this site<br /><em
|
||||
>When on, remember speed / force / audio below replace
|
||||
general defaults for matching URLs.</em
|
||||
>When on, remember speed / force / media eligibility
|
||||
below replace general defaults for matching URLs.</em
|
||||
></span
|
||||
>
|
||||
<input type="checkbox" class="override-playback" />
|
||||
@@ -832,7 +843,7 @@
|
||||
</div>
|
||||
<div class="site-rule-option site-rule-option-field">
|
||||
<label
|
||||
>Preferred speed (0.0625–16):<br /><em
|
||||
>Preferred speed (0.1–16):<br /><em
|
||||
>Overrides the Preferred speed action for matching
|
||||
URLs. It does not force every video to start at this
|
||||
speed.</em
|
||||
@@ -854,6 +865,16 @@
|
||||
>
|
||||
<input type="checkbox" class="site-audioBoolean" />
|
||||
</div>
|
||||
<div class="site-rule-option site-rule-option-checkbox">
|
||||
<label
|
||||
>Show controls on ambient/slideshow videos:<br /><em
|
||||
>Also includes muted autoplay loops used as animated
|
||||
cards or slideshow artwork. Genuine video players are
|
||||
always included.</em
|
||||
></label
|
||||
>
|
||||
<input type="checkbox" class="site-showAmbientLoopControls" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="site-rule-override-section">
|
||||
@@ -922,8 +943,8 @@
|
||||
<label class="site-override-lead">
|
||||
<span
|
||||
>Override in-player control bar for this site<br /><em
|
||||
>Same idea as Hover control bar: drag blocks between
|
||||
Active and Available for matching URLs only.</em
|
||||
>Drag blocks or use the keyboard to arrange Active and
|
||||
Available buttons for matching URLs only.</em
|
||||
></span
|
||||
>
|
||||
<input type="checkbox" class="override-controlbar" />
|
||||
@@ -977,8 +998,8 @@
|
||||
<label class="site-override-lead">
|
||||
<span
|
||||
>Override shortcuts for this site<br /><em
|
||||
>Add shortcuts from the menu; none by default. Leave off
|
||||
to use global Shortcuts.</em
|
||||
>A site shortcut replaces global bindings for the same
|
||||
action; other global shortcuts remain active.</em
|
||||
></span
|
||||
>
|
||||
<input type="checkbox" class="override-shortcuts" />
|
||||
@@ -1001,13 +1022,14 @@
|
||||
<section class="settings-card action-card">
|
||||
<div class="section-heading">
|
||||
<h3>Actions</h3>
|
||||
<p class="section-intro">Save, restore, export, or import settings.</p>
|
||||
<p class="section-intro">Changes save automatically.</p>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button id="save">Save Changes</button>
|
||||
<button id="save">Save Now</button>
|
||||
<button id="restore">Restore Defaults</button>
|
||||
<button id="exportSettings">Export Settings</button>
|
||||
<button id="importSettings">Import Settings</button>
|
||||
<button id="copyDiagnostics">Copy Diagnostics</button>
|
||||
</div>
|
||||
|
||||
<div id="status" role="status" aria-live="polite"></div>
|
||||
@@ -1016,11 +1038,11 @@
|
||||
<section id="faq" class="settings-card info-card">
|
||||
<h4>Extension controls not appearing?</h4>
|
||||
<p>
|
||||
This extension only works with HTML5 audio and video. If the
|
||||
controls never appear, you may be looking at Flash content instead.
|
||||
Right-click the player to check: if the menu mentions Flash, that
|
||||
is the issue. Most sites will fall back to HTML5 when Flash is not
|
||||
available, so disabling Flash in the browser can help.
|
||||
Speeder works with HTML5 video and, when enabled above, HTML5 audio.
|
||||
Check that the current site is enabled, then use the popup’s
|
||||
“Rescan page for videos” action after a player loads
|
||||
dynamically. Browser-internal pages and players that do not expose
|
||||
HTML5 media to extensions cannot be controlled.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
+251
-70
@@ -164,9 +164,33 @@ function createDefaultBinding(action, code, value) {
|
||||
|
||||
var tcDefaults = vscGetSettingsDefaults();
|
||||
var optionsSyncSettingsLoaded = false;
|
||||
var autoSaveTimer = null;
|
||||
var saveStatusTimer = null;
|
||||
|
||||
function scheduleAutoSave() {
|
||||
if (!optionsSyncSettingsLoaded) return;
|
||||
clearTimeout(autoSaveTimer);
|
||||
var sourceSaveButton = document.getElementById("save");
|
||||
var scheduledTimer = setTimeout(function () {
|
||||
if (
|
||||
autoSaveTimer !== scheduledTimer ||
|
||||
!sourceSaveButton ||
|
||||
!sourceSaveButton.isConnected
|
||||
) {
|
||||
return;
|
||||
}
|
||||
autoSaveTimer = null;
|
||||
save_options(true);
|
||||
}, 300);
|
||||
autoSaveTimer = scheduledTimer;
|
||||
}
|
||||
|
||||
function setOptionsSyncSettingsLoaded(loaded) {
|
||||
optionsSyncSettingsLoaded = loaded === true;
|
||||
if (!optionsSyncSettingsLoaded) {
|
||||
clearTimeout(autoSaveTimer);
|
||||
autoSaveTimer = null;
|
||||
}
|
||||
var saveButton = document.getElementById("save");
|
||||
if (saveButton) {
|
||||
saveButton.disabled = !optionsSyncSettingsLoaded;
|
||||
@@ -302,33 +326,15 @@ function refreshAddShortcutSelector() {
|
||||
selector.remove(1);
|
||||
}
|
||||
|
||||
// Find all currently used actions
|
||||
const usedActions = new Set();
|
||||
document.querySelectorAll(".shortcut-row").forEach((row) => {
|
||||
const action = row.dataset.action;
|
||||
if (action) {
|
||||
usedActions.add(action);
|
||||
}
|
||||
});
|
||||
|
||||
// Add all unused actions
|
||||
Object.keys(actionLabels).forEach((action) => {
|
||||
if (!usedActions.has(action)) {
|
||||
const option = document.createElement("option");
|
||||
option.value = action;
|
||||
option.text = actionLabels[action];
|
||||
selector.appendChild(option);
|
||||
}
|
||||
const option = document.createElement("option");
|
||||
option.value = action;
|
||||
option.text = actionLabels[action];
|
||||
selector.appendChild(option);
|
||||
});
|
||||
|
||||
// If no available actions, hide or disable the selector
|
||||
if (selector.options.length === 1) {
|
||||
selector.disabled = true;
|
||||
selector.options[0].text = "All shortcuts added";
|
||||
} else {
|
||||
selector.disabled = false;
|
||||
selector.options[0].text = "Add shortcut\u2026";
|
||||
}
|
||||
selector.disabled = false;
|
||||
selector.options[0].text = "Add shortcut\u2026";
|
||||
}
|
||||
|
||||
function refreshSiteRuleAddShortcutSelector(ruleEl) {
|
||||
@@ -340,32 +346,19 @@ function refreshSiteRuleAddShortcutSelector(ruleEl) {
|
||||
selector.remove(1);
|
||||
}
|
||||
|
||||
var usedActions = new Set();
|
||||
ruleEl.querySelectorAll(".site-shortcuts-rows .shortcut-row.customs").forEach(function (row) {
|
||||
var action = row.dataset.action;
|
||||
if (action) usedActions.add(action);
|
||||
});
|
||||
|
||||
Object.keys(actionLabels).forEach(function (action) {
|
||||
if (!usedActions.has(action)) {
|
||||
var option = document.createElement("option");
|
||||
option.value = action;
|
||||
option.textContent = actionLabels[action];
|
||||
selector.appendChild(option);
|
||||
}
|
||||
var option = document.createElement("option");
|
||||
option.value = action;
|
||||
option.textContent = actionLabels[action];
|
||||
selector.appendChild(option);
|
||||
});
|
||||
|
||||
var overrideShortcutsOn =
|
||||
ruleEl.querySelector(".override-shortcuts") &&
|
||||
ruleEl.querySelector(".override-shortcuts").checked;
|
||||
|
||||
if (selector.options.length === 1) {
|
||||
selector.disabled = true;
|
||||
selector.options[0].text = "All shortcuts added";
|
||||
} else {
|
||||
selector.disabled = !overrideShortcutsOn;
|
||||
selector.options[0].text = "Add shortcut\u2026";
|
||||
}
|
||||
selector.disabled = !overrideShortcutsOn;
|
||||
selector.options[0].text = "Add shortcut\u2026";
|
||||
}
|
||||
|
||||
function getGlobalBindingSnapshotForSiteShortcut(action) {
|
||||
@@ -424,7 +417,10 @@ function readOptionalPreferredSpeedInput(input) {
|
||||
if (!rawValue) return undefined;
|
||||
var parsed = Number(rawValue);
|
||||
if (!Number.isFinite(parsed)) return undefined;
|
||||
return Math.min(16, Math.max(0.0625, parsed));
|
||||
return Math.min(
|
||||
keyBindingUtils.MAX_SPEED,
|
||||
Math.max(keyBindingUtils.MIN_SPEED, parsed)
|
||||
);
|
||||
}
|
||||
|
||||
function updateSiteRuleToggleIcon(toggleButton, action) {
|
||||
@@ -560,6 +556,7 @@ function normalizeStoredBinding(binding, fallbackCode) {
|
||||
|
||||
var normalized = {
|
||||
code: normalizedCode,
|
||||
shiftKey: binding.shiftKey === true,
|
||||
disabled: false
|
||||
};
|
||||
|
||||
@@ -578,7 +575,7 @@ function formatBindingCode(code) {
|
||||
function getBindingLabel(binding) {
|
||||
if (!binding) return "";
|
||||
if (binding.disabled) return "";
|
||||
return formatBindingCode(binding.code);
|
||||
return (binding.shiftKey ? "Shift+" : "") + formatBindingCode(binding.code);
|
||||
}
|
||||
|
||||
function setShortcutInputBinding(input, binding) {
|
||||
@@ -591,6 +588,7 @@ function captureBindingFromEvent(event) {
|
||||
if (typeof event.code !== "string" || event.code.length === 0) return null;
|
||||
return {
|
||||
code: event.code,
|
||||
shiftKey: event.shiftKey === true,
|
||||
disabled: false
|
||||
};
|
||||
}
|
||||
@@ -600,6 +598,7 @@ function recordKeyPress(event) {
|
||||
|
||||
if (event.key === "Backspace") {
|
||||
setShortcutInputBinding(event.target, null);
|
||||
scheduleAutoSave();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
@@ -607,6 +606,7 @@ function recordKeyPress(event) {
|
||||
|
||||
if (event.key === "Escape") {
|
||||
setShortcutInputBinding(event.target, createDisabledBinding());
|
||||
scheduleAutoSave();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
@@ -616,6 +616,7 @@ function recordKeyPress(event) {
|
||||
if (!binding) return;
|
||||
|
||||
setShortcutInputBinding(event.target, binding);
|
||||
scheduleAutoSave();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
@@ -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) {
|
||||
if (!action) return;
|
||||
|
||||
@@ -710,6 +725,7 @@ function add_shortcut(action, value) {
|
||||
div.appendChild(keyInput);
|
||||
div.appendChild(valueInput);
|
||||
div.appendChild(removeButton);
|
||||
labelShortcutRow(div);
|
||||
|
||||
var customsElement = document.querySelector(".shortcuts-grid");
|
||||
customsElement.appendChild(div);
|
||||
@@ -770,6 +786,7 @@ function createKeyBindings(item) {
|
||||
keyBindings.push({
|
||||
action: action,
|
||||
code: binding.code,
|
||||
shiftKey: binding.shiftKey === true,
|
||||
disabled: binding.disabled === true,
|
||||
value: bindingValue,
|
||||
force: false,
|
||||
@@ -791,9 +808,8 @@ function validate() {
|
||||
if (pattern.startsWith("/")) {
|
||||
try {
|
||||
var lastSlash = pattern.lastIndexOf("/");
|
||||
if (lastSlash > 0) {
|
||||
new RegExp(pattern.substring(1, lastSlash), pattern.substring(lastSlash + 1));
|
||||
}
|
||||
if (lastSlash === 0) throw new Error("Missing closing slash");
|
||||
new RegExp(pattern.substring(1, lastSlash), pattern.substring(lastSlash + 1));
|
||||
} catch (err) {
|
||||
status.textContent =
|
||||
"Error: Invalid site rule regex: " + pattern + ". Unable to save";
|
||||
@@ -806,7 +822,10 @@ function validate() {
|
||||
return valid;
|
||||
}
|
||||
|
||||
function save_options() {
|
||||
function save_options(isAutoSave) {
|
||||
clearTimeout(autoSaveTimer);
|
||||
clearTimeout(saveStatusTimer);
|
||||
autoSaveTimer = null;
|
||||
var status = document.getElementById("status");
|
||||
if (!optionsSyncSettingsLoaded) {
|
||||
status.textContent =
|
||||
@@ -835,6 +854,8 @@ function save_options() {
|
||||
settings.forceLastSavedSpeed =
|
||||
document.getElementById("forceLastSavedSpeed").checked;
|
||||
settings.audioBoolean = document.getElementById("audioBoolean").checked;
|
||||
settings.showAmbientLoopControls =
|
||||
document.getElementById("showAmbientLoopControls").checked;
|
||||
settings.enabled = document.getElementById("enabled").checked;
|
||||
settings.startHidden = document.getElementById("startHidden").checked;
|
||||
settings.shortcutTargetMode =
|
||||
@@ -974,11 +995,15 @@ function save_options() {
|
||||
rule.preferredSpeed = preferredSpeed;
|
||||
}
|
||||
rule.audioBoolean = ruleEl.querySelector(".site-audioBoolean").checked;
|
||||
rule.showAmbientLoopControls = ruleEl.querySelector(
|
||||
".site-showAmbientLoopControls"
|
||||
).checked;
|
||||
} else {
|
||||
delete rule.rememberSpeed;
|
||||
delete rule.forceLastSavedSpeed;
|
||||
delete rule.preferredSpeed;
|
||||
delete rule.audioBoolean;
|
||||
delete rule.showAmbientLoopControls;
|
||||
}
|
||||
|
||||
if (ruleEl.querySelector(".override-opacity").checked) {
|
||||
@@ -1086,6 +1111,7 @@ function save_options() {
|
||||
shortcuts.push({
|
||||
action: action,
|
||||
code: binding.code,
|
||||
shiftKey: binding.shiftKey === true,
|
||||
disabled: binding.disabled === true,
|
||||
value: shortcutValue,
|
||||
force: forceCheckbox ? forceCheckbox.checked : false
|
||||
@@ -1111,8 +1137,8 @@ function save_options() {
|
||||
status.textContent = "Error: Unable to save options - " + error.message;
|
||||
return;
|
||||
}
|
||||
status.textContent = "Options saved";
|
||||
setTimeout(function () {
|
||||
status.textContent = isAutoSave === true ? "Auto-saved" : "Options saved";
|
||||
saveStatusTimer = setTimeout(function () {
|
||||
status.textContent = "";
|
||||
}, 1000);
|
||||
});
|
||||
@@ -1133,15 +1159,7 @@ function addSiteRuleShortcut(rowsEl, action, binding, value, force) {
|
||||
|
||||
var actionLabel = document.createElement("div");
|
||||
actionLabel.className = "shortcut-label";
|
||||
var actionLabelText = 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;
|
||||
actionLabel.textContent = actionLabels[action] || action;
|
||||
|
||||
var keyInput = document.createElement("input");
|
||||
keyInput.className = "customKey";
|
||||
@@ -1189,10 +1207,40 @@ function addSiteRuleShortcut(rowsEl, action, binding, value, force) {
|
||||
div.appendChild(valueInput);
|
||||
div.appendChild(forceLabel);
|
||||
div.appendChild(removeButton);
|
||||
labelShortcutRow(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) {
|
||||
var template = document.getElementById("siteRuleTemplate");
|
||||
var clone = template.content.cloneNode(true);
|
||||
@@ -1278,7 +1326,8 @@ function createSiteRule(rule) {
|
||||
(rule.rememberSpeed !== undefined ||
|
||||
rule.forceLastSavedSpeed !== undefined ||
|
||||
rule.preferredSpeed !== undefined ||
|
||||
rule.audioBoolean !== undefined)
|
||||
rule.audioBoolean !== undefined ||
|
||||
rule.showAmbientLoopControls !== undefined)
|
||||
);
|
||||
ruleEl.querySelector(".override-playback").checked = hasPlaybackOverride;
|
||||
syncSiteRuleField(ruleEl, rule, "rememberSpeed", true);
|
||||
@@ -1288,6 +1337,7 @@ function createSiteRule(rule) {
|
||||
? String(rule.preferredSpeed)
|
||||
: "";
|
||||
syncSiteRuleField(ruleEl, rule, "audioBoolean", true);
|
||||
syncSiteRuleField(ruleEl, rule, "showAmbientLoopControls", true);
|
||||
applySiteRuleOverrideState(ruleEl, "override-playback", "site-playback-container");
|
||||
|
||||
ruleEl.querySelector(".override-opacity").checked = Boolean(
|
||||
@@ -1363,6 +1413,7 @@ function createSiteRule(rule) {
|
||||
}
|
||||
applySiteRuleOverrideState(ruleEl, "override-shortcuts", "site-shortcuts-container");
|
||||
refreshSiteRuleAddShortcutSelector(ruleEl);
|
||||
associateSiteRuleLabels(ruleEl);
|
||||
|
||||
document.getElementById("siteRulesContainer").appendChild(ruleEl);
|
||||
}
|
||||
@@ -1371,7 +1422,8 @@ function createControlBarBlock(buttonId) {
|
||||
var def = controllerButtonDefs[buttonId];
|
||||
if (!def) return null;
|
||||
|
||||
var block = document.createElement("div");
|
||||
var block = document.createElement("button");
|
||||
block.type = "button";
|
||||
block.className = "cb-block";
|
||||
block.dataset.buttonId = buttonId;
|
||||
block.draggable = true;
|
||||
@@ -1394,6 +1446,25 @@ function createControlBarBlock(buttonId) {
|
||||
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) {
|
||||
vscClearElement(activeZone);
|
||||
vscClearElement(availableZone);
|
||||
@@ -1416,6 +1487,8 @@ function populateControlBarZones(activeZone, availableZone, activeIds, allowButt
|
||||
if (block) availableZone.appendChild(block);
|
||||
}
|
||||
});
|
||||
|
||||
updateControlBarBlockLabels(activeZone.closest(".cb-editor"));
|
||||
}
|
||||
|
||||
function readControlBarOrder(activeZone) {
|
||||
@@ -1459,11 +1532,11 @@ function updatePopupEditorDisabledState() {
|
||||
var checkbox = document.getElementById("popupMatchHoverControls");
|
||||
var wrap = document.getElementById("popupCbEditorWrap");
|
||||
if (!checkbox || !wrap) return;
|
||||
if (checkbox.checked) {
|
||||
wrap.classList.add("cb-editor-disabled");
|
||||
} else {
|
||||
wrap.classList.remove("cb-editor-disabled");
|
||||
}
|
||||
wrap.classList.toggle("cb-editor-disabled", checkbox.checked);
|
||||
wrap.querySelectorAll(".cb-block").forEach(function(block) {
|
||||
block.disabled = checkbox.checked;
|
||||
block.draggable = !checkbox.checked;
|
||||
});
|
||||
}
|
||||
|
||||
function getDragAfterElement(container, x, y) {
|
||||
@@ -1486,6 +1559,8 @@ function getDragAfterElement(container, x, y) {
|
||||
}
|
||||
|
||||
function initControlBarEditor() {
|
||||
if (document.vscControlBarEditorInitialized) return;
|
||||
document.vscControlBarEditorInitialized = true;
|
||||
var draggedBlock = null;
|
||||
|
||||
function clearControlBarDropTargets(activeZone) {
|
||||
@@ -1498,7 +1573,7 @@ function initControlBarEditor() {
|
||||
|
||||
document.addEventListener("dragstart", function (e) {
|
||||
var block = e.target.closest(".cb-block");
|
||||
if (!block) return;
|
||||
if (!block || block.disabled) return;
|
||||
draggedBlock = block;
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", block.dataset.buttonId);
|
||||
@@ -1543,10 +1618,49 @@ function initControlBarEditor() {
|
||||
var zone = e.target.closest(".cb-dropzone");
|
||||
if (zone) {
|
||||
e.preventDefault();
|
||||
updateControlBarBlockLabels(zone.closest(".cb-editor"));
|
||||
scheduleAutoSave();
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -1618,6 +1732,10 @@ function initLucideButtonIconsUI() {
|
||||
b.dataset.slug = slug;
|
||||
b.title = slug;
|
||||
b.setAttribute("aria-label", slug);
|
||||
b.setAttribute(
|
||||
"aria-pressed",
|
||||
slug === lucidePickerSelectedSlug ? "true" : "false"
|
||||
);
|
||||
if (slug === lucidePickerSelectedSlug) {
|
||||
b.classList.add("lucide-picked");
|
||||
}
|
||||
@@ -1639,6 +1757,10 @@ function initLucideButtonIconsUI() {
|
||||
resultsEl.querySelectorAll("button"),
|
||||
function (x) {
|
||||
x.classList.toggle("lucide-picked", x.dataset.slug === slug);
|
||||
x.setAttribute(
|
||||
"aria-pressed",
|
||||
x.dataset.slug === slug ? "true" : "false"
|
||||
);
|
||||
}
|
||||
);
|
||||
fetchLucideSvg(slug)
|
||||
@@ -1704,7 +1826,7 @@ function initLucideButtonIconsUI() {
|
||||
slug +
|
||||
" for " +
|
||||
action +
|
||||
". Reload pages for the hover bar."
|
||||
". Open pages update automatically."
|
||||
);
|
||||
});
|
||||
})
|
||||
@@ -1780,6 +1902,8 @@ function restore_options(callback) {
|
||||
document.getElementById("forceLastSavedSpeed").checked =
|
||||
storage.forceLastSavedSpeed;
|
||||
document.getElementById("audioBoolean").checked = storage.audioBoolean;
|
||||
document.getElementById("showAmbientLoopControls").checked =
|
||||
storage.showAmbientLoopControls;
|
||||
document.getElementById("enabled").checked = storage.enabled;
|
||||
document.getElementById("startHidden").checked = storage.startHidden;
|
||||
document.getElementById("shortcutTargetMode").value =
|
||||
@@ -1815,13 +1939,16 @@ function restore_options(callback) {
|
||||
|
||||
document.querySelectorAll(".customs:not([id])").forEach((row) => row.remove());
|
||||
|
||||
var usedDefaultShortcutRows = new Set();
|
||||
storage.keyBindings.forEach((item) => {
|
||||
var row = document.getElementById(item.action);
|
||||
var normalizedBinding = normalizeStoredBinding(item);
|
||||
|
||||
if (!row) {
|
||||
if (!row || usedDefaultShortcutRows.has(item.action)) {
|
||||
add_shortcut(item.action, item.value);
|
||||
row = document.querySelector(".shortcut-row.customs:last-of-type");
|
||||
} else {
|
||||
usedDefaultShortcutRows.add(item.action);
|
||||
}
|
||||
|
||||
if (!row) return;
|
||||
@@ -1882,6 +2009,13 @@ function restore_options(callback) {
|
||||
}
|
||||
|
||||
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 restoreButton = document.getElementById("restore");
|
||||
setOptionsSyncSettingsLoaded(false);
|
||||
@@ -1961,6 +2095,8 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
versionElement.textContent = manifest.version;
|
||||
}
|
||||
|
||||
document.querySelectorAll("#customs .shortcut-row").forEach(labelShortcutRow);
|
||||
|
||||
restore_options();
|
||||
initControlBarEditor();
|
||||
|
||||
@@ -1968,6 +2104,41 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
.addEventListener("change", updatePopupEditorDisabledState);
|
||||
|
||||
document.getElementById("save").addEventListener("click", save_options);
|
||||
document.getElementById("copyDiagnostics").addEventListener("click", function () {
|
||||
var status = document.getElementById("status");
|
||||
if (
|
||||
!navigator.clipboard ||
|
||||
typeof navigator.clipboard.writeText !== "function"
|
||||
) {
|
||||
status.textContent = "Clipboard access is unavailable.";
|
||||
return;
|
||||
}
|
||||
var report = popupControlUtils.buildDiagnosticReport({
|
||||
speederVersion: manifest.version,
|
||||
browser: navigator.userAgent,
|
||||
platform: navigator.platform || null,
|
||||
storage: {
|
||||
enabled: document.getElementById("enabled").checked,
|
||||
rememberSpeed: document.getElementById("rememberSpeed").checked,
|
||||
forceLastSavedSpeed:
|
||||
document.getElementById("forceLastSavedSpeed").checked,
|
||||
audioBoolean: document.getElementById("audioBoolean").checked,
|
||||
startHidden: document.getElementById("startHidden").checked,
|
||||
hideWithControls: document.getElementById("hideWithControls").checked,
|
||||
controllerLocation: document.getElementById("controllerLocation").value,
|
||||
shortcutTargetMode:
|
||||
document.getElementById("shortcutTargetMode").value
|
||||
}
|
||||
});
|
||||
navigator.clipboard.writeText(report).then(
|
||||
function() {
|
||||
status.textContent = "Diagnostics copied. Review before sharing.";
|
||||
},
|
||||
function() {
|
||||
status.textContent = "Could not copy diagnostics.";
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const addSelector = document.getElementById("addShortcutSelector");
|
||||
if (addSelector) {
|
||||
@@ -2020,11 +2191,13 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
if (siteRuleForShortcut) {
|
||||
refreshSiteRuleAddShortcutSelector(siteRuleForShortcut);
|
||||
}
|
||||
scheduleAutoSave();
|
||||
return;
|
||||
}
|
||||
var removeSiteRuleButton = targetEl.closest(".remove-site-rule");
|
||||
if (removeSiteRuleButton) {
|
||||
removeSiteRuleButton.closest(".site-rule").remove();
|
||||
scheduleAutoSave();
|
||||
return;
|
||||
}
|
||||
var toggleButton = targetEl.closest(".toggle-site-rule");
|
||||
@@ -2094,4 +2267,12 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
}
|
||||
}
|
||||
});
|
||||
document.addEventListener("change", (event) => {
|
||||
if (
|
||||
event.target.id !== "addShortcutSelector" &&
|
||||
!event.target.closest("#lucideIconSettings")
|
||||
) {
|
||||
scheduleAutoSave();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+23
-10
@@ -4,8 +4,9 @@
|
||||
--border: #e2e5e9;
|
||||
--border-strong: #d4d9e0;
|
||||
--text: #17191c;
|
||||
--muted: #626b76;
|
||||
--accent: #111827;
|
||||
--muted: #626b76;
|
||||
--accent: #111827;
|
||||
--focus-ring: #2563eb;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -83,10 +84,15 @@ button:active {
|
||||
background: #f1f3f5;
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 2px solid rgba(17, 24, 39, 0.14);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
#refresh {
|
||||
background: var(--accent);
|
||||
@@ -112,6 +118,12 @@ button:focus-visible {
|
||||
border-color: #9fbd98;
|
||||
color: #285d21;
|
||||
}
|
||||
|
||||
#pauseTab[aria-pressed="true"] {
|
||||
background: #fff3d6;
|
||||
border-color: #d8b66b;
|
||||
color: #704f0d;
|
||||
}
|
||||
|
||||
.popup-divider {
|
||||
height: 1px;
|
||||
@@ -250,8 +262,8 @@ button:focus-visible {
|
||||
background: #f1f3f5;
|
||||
}
|
||||
|
||||
.donate-icon-btn:focus-visible {
|
||||
outline: 2px solid rgba(17, 24, 39, 0.14);
|
||||
.donate-icon-btn:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
@@ -295,8 +307,9 @@ button:focus-visible {
|
||||
--border: #2b3138;
|
||||
--border-strong: #3a414a;
|
||||
--text: #f2f4f6;
|
||||
--muted: #a0a8b2;
|
||||
--accent: #f2f4f6;
|
||||
--muted: #a0a8b2;
|
||||
--accent: #f2f4f6;
|
||||
--focus-ring: #93c5fd;
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
@@ -24,6 +24,12 @@
|
||||
type="button"
|
||||
aria-pressed="false"
|
||||
>Force last saved speed</button>
|
||||
<button
|
||||
id="pauseTab"
|
||||
class="popup-compact-action"
|
||||
type="button"
|
||||
aria-pressed="false"
|
||||
>Pause on this tab</button>
|
||||
<div class="popup-divider"></div>
|
||||
<div id="popupControlBar" class="popup-control-bar">
|
||||
<span id="popupSpeed" class="popup-speed">1.00</span>
|
||||
@@ -32,10 +38,17 @@
|
||||
<button id="enable" class="hide">Enable</button>
|
||||
<button id="disable">Disable</button>
|
||||
</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">
|
||||
<button id="config">Settings</button>
|
||||
<div class="popup-secondary">
|
||||
<button id="addSiteRule" class="secondary">Add current site</button>
|
||||
<button id="copyDiagnostics" class="secondary">Copy diagnostics</button>
|
||||
<button id="feedback" class="secondary">Feedback</button>
|
||||
<button id="about" class="secondary">About</button>
|
||||
</div>
|
||||
|
||||
+208
-16
@@ -1,4 +1,6 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
if (window.vscPopupInitialized) return;
|
||||
window.vscPopupInitialized = true;
|
||||
var speederShared =
|
||||
typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {};
|
||||
var siteRuleUtils = speederShared.siteRules || {};
|
||||
@@ -27,6 +29,63 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
var popupExcludedButtonIds = new Set(["settings"]);
|
||||
var renderToken = 0;
|
||||
var forceLastSavedSpeedControlledBySiteRule = null;
|
||||
var selectedFrameToken = null;
|
||||
var shortcutTargetMode = "closest";
|
||||
var diagnosticContext = null;
|
||||
|
||||
function updateTabPauseButton(paused) {
|
||||
var button = document.querySelector("#pauseTab");
|
||||
button.setAttribute("aria-pressed", paused ? "true" : "false");
|
||||
button.textContent = paused ? "Resume on this tab" : "Pause on this tab";
|
||||
}
|
||||
|
||||
function getTabPauseState(tab, callback) {
|
||||
if (!tab || !Number.isInteger(tab.id)) {
|
||||
callback(false);
|
||||
return;
|
||||
}
|
||||
chrome.runtime.sendMessage(
|
||||
{ action: "get_tab_pause_state", tabId: tab.id },
|
||||
function(response) {
|
||||
callback(
|
||||
!chrome.runtime.lastError && response && response.paused === true
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function getCurrentSiteRuleDetails(url) {
|
||||
try {
|
||||
var parsed = new URL(url);
|
||||
if (
|
||||
(parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
|
||||
!parsed.host
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
title: parsed.host,
|
||||
pattern: parsed.protocol + "//" + parsed.host
|
||||
};
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildDiagnosticReport() {
|
||||
if (!diagnosticContext) return null;
|
||||
return popupControlUtils.buildDiagnosticReport({
|
||||
speederVersion: chrome.runtime.getManifest().version,
|
||||
browser: navigator.userAgent,
|
||||
platform: navigator.platform || null,
|
||||
url: diagnosticContext.url,
|
||||
storage: diagnosticContext.storage,
|
||||
siteRule: diagnosticContext.siteRule,
|
||||
siteRuleDisabled: isSiteRuleDisabled(diagnosticContext.siteRule),
|
||||
tabPaused: diagnosticContext.tabPaused,
|
||||
frame: diagnosticContext.frame
|
||||
});
|
||||
}
|
||||
|
||||
function persistExpandedSettings(rawStorage, settings, callback) {
|
||||
var mutation = vscBuildManagedStorageMutation(rawStorage, settings);
|
||||
@@ -123,16 +182,23 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
}
|
||||
|
||||
function getActiveTabContext(callback) {
|
||||
function finish(context) {
|
||||
getTabPauseState(context.tab, function(paused) {
|
||||
context.tabPaused = paused;
|
||||
if (callback) callback(context);
|
||||
});
|
||||
}
|
||||
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
|
||||
var activeTab = tabs && tabs[0] ? tabs[0] : null;
|
||||
if (!activeTab || !activeTab.id) {
|
||||
if (callback) callback({ tab: null, url: "" });
|
||||
finish({ tab: null, url: "" });
|
||||
return;
|
||||
}
|
||||
|
||||
var tabUrl = typeof activeTab.url === "string" ? activeTab.url : "";
|
||||
if (tabUrl.length > 0) {
|
||||
if (callback) callback({ tab: activeTab, url: tabUrl });
|
||||
finish({ tab: activeTab, url: tabUrl });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -141,13 +207,13 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
{ action: "get_page_context" },
|
||||
function (response) {
|
||||
if (chrome.runtime.lastError) {
|
||||
if (callback) callback({ tab: activeTab, url: "" });
|
||||
finish({ tab: activeTab, url: "" });
|
||||
return;
|
||||
}
|
||||
|
||||
var pageUrl =
|
||||
response && typeof response.url === "string" ? response.url : "";
|
||||
if (callback) callback({ tab: activeTab, url: pageUrl });
|
||||
finish({ tab: activeTab, url: pageUrl });
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -162,6 +228,10 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
if (response && response.speed != null) {
|
||||
updateSpeedDisplay(response.speed);
|
||||
}
|
||||
selectedFrameToken =
|
||||
response && typeof response.frameToken === "string"
|
||||
? response.frameToken
|
||||
: null;
|
||||
}
|
||||
|
||||
function pickBestFrameSpeedResult(results) {
|
||||
@@ -241,14 +311,19 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
}
|
||||
if (def.className) btn.className = def.className;
|
||||
btn.title = btnId.charAt(0).toUpperCase() + btnId.slice(1);
|
||||
btn.setAttribute("aria-label", btn.title);
|
||||
|
||||
btn.addEventListener("click", function () {
|
||||
if (btnId === "settings") {
|
||||
window.open(chrome.runtime.getURL("options/options.html"));
|
||||
return;
|
||||
}
|
||||
var message = { action: "run_action", actionName: btnId };
|
||||
if (shortcutTargetMode !== "all" && selectedFrameToken) {
|
||||
message.targetFrameToken = selectedFrameToken;
|
||||
}
|
||||
sendToActiveTab(
|
||||
{ action: "run_action", actionName: btnId },
|
||||
message,
|
||||
function () {
|
||||
querySpeed();
|
||||
}
|
||||
@@ -277,6 +352,104 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
window.open("https://github.com/SoPat712/Speeder/issues");
|
||||
});
|
||||
|
||||
document.querySelector("#copyDiagnostics").addEventListener("click", function () {
|
||||
var report = buildDiagnosticReport();
|
||||
if (!report) {
|
||||
setStatusMessage("Diagnostics are still loading.");
|
||||
return;
|
||||
}
|
||||
if (!navigator.clipboard || typeof navigator.clipboard.writeText !== "function") {
|
||||
setStatusMessage("Clipboard access is unavailable.");
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(report).then(
|
||||
function () {
|
||||
setStatusMessage("Diagnostics copied. Review before sharing.");
|
||||
},
|
||||
function () {
|
||||
setStatusMessage("Could not copy diagnostics.");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
document.querySelector("#addSiteRule").addEventListener("click", function () {
|
||||
var details = diagnosticContext
|
||||
? getCurrentSiteRuleDetails(diagnosticContext.url)
|
||||
: null;
|
||||
if (!details) {
|
||||
setStatusMessage("A site rule cannot be created for this page.");
|
||||
return;
|
||||
}
|
||||
var added = false;
|
||||
|
||||
updateStoredSettings(
|
||||
function (settings) {
|
||||
var rules = Array.isArray(settings.siteRules) ? settings.siteRules : [];
|
||||
if (
|
||||
rules.some(function(rule) {
|
||||
return rule && rule.pattern === details.pattern;
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
settings.siteRules = rules.concat([
|
||||
{
|
||||
title: details.title,
|
||||
pattern: details.pattern,
|
||||
enabled: true
|
||||
}
|
||||
]);
|
||||
added = true;
|
||||
},
|
||||
function (error) {
|
||||
if (error) {
|
||||
setStatusMessage("Could not add this site: " + error.message);
|
||||
return;
|
||||
}
|
||||
setStatusMessage(
|
||||
added
|
||||
? "Site rule added. Opening settings..."
|
||||
: "Site rule already exists. Opening settings..."
|
||||
);
|
||||
window.open(chrome.runtime.getURL("options/options.html"));
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
document.querySelector("#pauseTab").addEventListener("click", function () {
|
||||
var tab = diagnosticContext && diagnosticContext.tab;
|
||||
if (!tab || !Number.isInteger(tab.id)) {
|
||||
setStatusMessage("This page cannot be paused.");
|
||||
return;
|
||||
}
|
||||
var button = this;
|
||||
var paused = button.getAttribute("aria-pressed") !== "true";
|
||||
button.disabled = true;
|
||||
chrome.runtime.sendMessage(
|
||||
{ action: "set_tab_paused", tabId: tab.id, paused: paused },
|
||||
function(response) {
|
||||
button.disabled = false;
|
||||
if (chrome.runtime.lastError || !response) {
|
||||
setStatusMessage("Could not update this tab.");
|
||||
return;
|
||||
}
|
||||
diagnosticContext.tabPaused = response.paused === true;
|
||||
updateTabPauseButton(diagnosticContext.tabPaused);
|
||||
setControlBarVisible(
|
||||
!diagnosticContext.tabPaused &&
|
||||
diagnosticContext.siteAvailable &&
|
||||
diagnosticContext.showBar
|
||||
);
|
||||
setForceButtonLoading(diagnosticContext.tabPaused);
|
||||
setStatusMessage(
|
||||
diagnosticContext.tabPaused
|
||||
? "Speeder is paused for this tab."
|
||||
: "Speeder resumed for this tab."
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
document.querySelector("#donate").addEventListener("click", function () {
|
||||
this.classList.add("hide");
|
||||
document.querySelector("#donateOptions").classList.remove("hide");
|
||||
@@ -372,6 +545,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
function renderForActiveTab() {
|
||||
var currentRenderToken = ++renderToken;
|
||||
forceLastSavedSpeedControlledBySiteRule = null;
|
||||
selectedFrameToken = null;
|
||||
setForceButtonLoading(true);
|
||||
|
||||
chrome.storage.local.get(["customButtonIcons"], function (loc) {
|
||||
@@ -388,6 +562,9 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
getActiveTabContext(function (context) {
|
||||
if (currentRenderToken !== renderToken) return;
|
||||
|
||||
var tabPaused = context && context.tabPaused === true;
|
||||
updateTabPauseButton(tabPaused);
|
||||
|
||||
var url = context && context.url ? context.url : "";
|
||||
var siteRule = matchSiteRule(url, storage.siteRules);
|
||||
var siteDisabled = isSiteRuleDisabled(siteRule);
|
||||
@@ -395,6 +572,10 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
storage.enabled,
|
||||
siteRule
|
||||
);
|
||||
shortcutTargetMode =
|
||||
siteRule && siteRule.shortcutTargetMode !== undefined
|
||||
? siteRule.shortcutTargetMode
|
||||
: storage.shortcutTargetMode;
|
||||
var showBar = storage.showPopupControlBar !== false;
|
||||
forceLastSavedSpeedControlledBySiteRule = Boolean(
|
||||
siteRule && siteRule.forceLastSavedSpeed !== undefined
|
||||
@@ -403,9 +584,22 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
forceLastSavedSpeedControlledBySiteRule
|
||||
? siteRule.forceLastSavedSpeed === true
|
||||
: storage.forceLastSavedSpeed === true;
|
||||
diagnosticContext = {
|
||||
tab: context && context.tab,
|
||||
url: url,
|
||||
storage: storage,
|
||||
siteRule: siteRule,
|
||||
frame: null,
|
||||
tabPaused: tabPaused,
|
||||
siteAvailable: siteAvailable,
|
||||
showBar: showBar
|
||||
};
|
||||
document.querySelector("#addSiteRule").disabled =
|
||||
!getCurrentSiteRuleDetails(url);
|
||||
|
||||
if (siteRule && siteRule.showPopupControlBar !== undefined) {
|
||||
showBar = siteRule.showPopupControlBar;
|
||||
diagnosticContext.showBar = showBar;
|
||||
}
|
||||
|
||||
toggleEnabledUI(storage.enabled !== false);
|
||||
@@ -414,7 +608,13 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
resolvePopupButtons(storage, siteRule),
|
||||
customIconsMap
|
||||
);
|
||||
setControlBarVisible(siteAvailable && showBar);
|
||||
setControlBarVisible(!tabPaused && siteAvailable && showBar);
|
||||
|
||||
if (tabPaused) {
|
||||
setForceButtonLoading(true);
|
||||
setStatusMessage("Speeder is paused for this tab.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (siteDisabled) {
|
||||
setForceButtonLoading(false);
|
||||
@@ -427,6 +627,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
if (siteAvailable) {
|
||||
querySpeed(function(frameContext) {
|
||||
if (currentRenderToken !== renderToken) return;
|
||||
diagnosticContext.frame = frameContext || null;
|
||||
if (
|
||||
frameContext &&
|
||||
typeof frameContext.forceLastSavedSpeed === "boolean"
|
||||
@@ -507,20 +708,11 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
function toggleEnabledUI(enabled) {
|
||||
document.querySelector("#enable").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) {
|
||||
setStatusMessage(
|
||||
`${enabled ? "Enabled" : "Disabled"}. Reload page to see changes`
|
||||
`${enabled ? "Enabled" : "Disabled"}. Open pages update automatically.`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
"popupControllerButtons",
|
||||
"popupMatchHoverControls",
|
||||
"rememberSpeed",
|
||||
"shortcutTargetMode",
|
||||
"showAmbientLoopControls",
|
||||
"showPopupControlBar",
|
||||
"siteRules",
|
||||
"siteRulesFormat",
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
root.SpeederShared = root.SpeederShared || {};
|
||||
root.SpeederShared.keyBindings = exports;
|
||||
})(typeof globalThis !== "undefined" ? globalThis : this, function() {
|
||||
var MIN_SPEED = 0.1;
|
||||
var MAX_SPEED = 16;
|
||||
|
||||
function normalizeBindingKey(key) {
|
||||
if (typeof key !== "string" || key.length === 0) return null;
|
||||
if (key === "Spacebar") return " ";
|
||||
@@ -127,15 +130,15 @@
|
||||
if (!Number.isFinite(numericValue)) return "must be a finite number";
|
||||
if (
|
||||
(action === "slower" || action === "faster") &&
|
||||
(numericValue <= 0 || numericValue > 16)
|
||||
(numericValue <= 0 || numericValue > MAX_SPEED)
|
||||
) {
|
||||
return "must be greater than 0 and no more than 16";
|
||||
return "must be greater than 0 and no more than " + MAX_SPEED;
|
||||
}
|
||||
if (
|
||||
action === "fast" &&
|
||||
(numericValue < 0.0625 || numericValue > 16)
|
||||
(numericValue < MIN_SPEED || numericValue > MAX_SPEED)
|
||||
) {
|
||||
return "must be between 0.0625 and 16";
|
||||
return "must be between " + MIN_SPEED + " and " + MAX_SPEED;
|
||||
}
|
||||
if (
|
||||
(action === "rewind" || action === "advance") &&
|
||||
@@ -162,6 +165,8 @@
|
||||
}
|
||||
|
||||
return {
|
||||
MAX_SPEED: MAX_SPEED,
|
||||
MIN_SPEED: MIN_SPEED,
|
||||
getActionValueError: getActionValueError,
|
||||
getLegacyKeyCode: getLegacyKeyCode,
|
||||
inferBindingCode: inferBindingCode,
|
||||
|
||||
@@ -68,6 +68,12 @@
|
||||
|
||||
function normalizeResult(result) {
|
||||
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") {
|
||||
normalized.forceLastSavedSpeed = result.forceLastSavedSpeed;
|
||||
}
|
||||
@@ -91,7 +97,61 @@
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function getSafePageDetails(url) {
|
||||
try {
|
||||
var parsed = new URL(url);
|
||||
return {
|
||||
protocol: parsed.protocol,
|
||||
hostname: parsed.hostname || null
|
||||
};
|
||||
} catch (_error) {
|
||||
return { protocol: null, hostname: null };
|
||||
}
|
||||
}
|
||||
|
||||
function buildDiagnosticReport(context) {
|
||||
var config = context || {};
|
||||
var storage = config.storage || {};
|
||||
var siteRule = config.siteRule || null;
|
||||
var frame = config.frame || null;
|
||||
|
||||
return JSON.stringify(
|
||||
{
|
||||
speederVersion: config.speederVersion || null,
|
||||
browser: config.browser || null,
|
||||
platform: config.platform || null,
|
||||
page: getSafePageDetails(config.url),
|
||||
globalSettings: {
|
||||
enabled: storage.enabled !== false,
|
||||
rememberSpeed: storage.rememberSpeed === true,
|
||||
forceLastSavedSpeed: storage.forceLastSavedSpeed === true,
|
||||
audioEnabled: storage.audioBoolean === true,
|
||||
startHidden: storage.startHidden === true,
|
||||
hideWithControls: storage.hideWithControls === true,
|
||||
controllerLocation: storage.controllerLocation,
|
||||
shortcutTargetMode: storage.shortcutTargetMode
|
||||
},
|
||||
matchedSiteRule: {
|
||||
matched: Boolean(siteRule),
|
||||
disabled: config.siteRuleDisabled === true,
|
||||
overrideKeys: siteRule
|
||||
? Object.keys(siteRule)
|
||||
.filter(function(key) {
|
||||
return key !== "pattern" && key !== "title";
|
||||
})
|
||||
.sort()
|
||||
: []
|
||||
},
|
||||
tabPaused: config.tabPaused === true,
|
||||
frame: frame && frame.diagnostics ? frame.diagnostics : null
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
buildDiagnosticReport: buildDiagnosticReport,
|
||||
pickBestFrameSpeedResult: pickBestFrameSpeedResult,
|
||||
resolvePopupButtons: resolvePopupButtons,
|
||||
sanitizeButtonOrder: sanitizeButtonOrder
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
var LEGACY_SITE_RULES_DIFF_FORMAT = "defaults-diff-v1";
|
||||
var SITE_RULES_DIFF_FORMAT = "defaults-diff-v2";
|
||||
var REMOVED_DEFAULT_RULE_KEYS_META = "removedDefaultRuleKeys";
|
||||
var MIN_SPEED = 0.1;
|
||||
var MAX_SPEED = 16;
|
||||
var DEFAULT_BUTTONS = ["rewind", "slower", "faster", "advance", "display"];
|
||||
var LEGACY_SYNC_KEYS = [
|
||||
"resetSpeed",
|
||||
@@ -24,6 +26,7 @@
|
||||
"rememberSpeed",
|
||||
"forceLastSavedSpeed",
|
||||
"audioBoolean",
|
||||
"showAmbientLoopControls",
|
||||
"enabled",
|
||||
"startHidden",
|
||||
"hideWithControls",
|
||||
@@ -58,6 +61,7 @@
|
||||
lastSpeed: 1.0,
|
||||
rememberSpeed: false,
|
||||
audioBoolean: false,
|
||||
showAmbientLoopControls: false,
|
||||
startHidden: false,
|
||||
hideWithYouTubeControls: false,
|
||||
hideWithControls: false,
|
||||
@@ -324,6 +328,7 @@
|
||||
"rememberSpeed",
|
||||
"forceLastSavedSpeed",
|
||||
"audioBoolean",
|
||||
"showAmbientLoopControls",
|
||||
"enableSubtitleNudge",
|
||||
"subtitleNudgeEnabledByDefault",
|
||||
"showPopupControlBar",
|
||||
@@ -360,7 +365,7 @@
|
||||
return clampFiniteNumber(value, 250, 1000, undefined);
|
||||
}
|
||||
if (key === "preferredSpeed") {
|
||||
return clampFiniteNumber(value, 0.0625, 16, undefined);
|
||||
return clampFiniteNumber(value, MIN_SPEED, MAX_SPEED, undefined);
|
||||
}
|
||||
if (key === "shortcuts" && Array.isArray(value)) {
|
||||
return sanitizeStoredBindingValues(value).map(function(shortcut) {
|
||||
@@ -746,9 +751,9 @@
|
||||
var value = Number(normalized.value);
|
||||
var invalid = !Number.isFinite(value);
|
||||
if (action === "slower" || action === "faster") {
|
||||
invalid = invalid || value <= 0 || value > 16;
|
||||
invalid = invalid || value <= 0 || value > MAX_SPEED;
|
||||
} else if (action === "fast") {
|
||||
invalid = invalid || value < 0.0625 || value > 16;
|
||||
invalid = invalid || value < MIN_SPEED || value > MAX_SPEED;
|
||||
} else if (action === "rewind" || action === "advance") {
|
||||
invalid = invalid || value < 0;
|
||||
} else if (action === "louder" || action === "softer") {
|
||||
@@ -865,6 +870,7 @@
|
||||
"rememberSpeed",
|
||||
"forceLastSavedSpeed",
|
||||
"audioBoolean",
|
||||
"showAmbientLoopControls",
|
||||
"enabled",
|
||||
"startHidden",
|
||||
"hideWithControls",
|
||||
@@ -881,8 +887,8 @@
|
||||
|
||||
expanded.lastSpeed = clampFiniteNumber(
|
||||
expanded.lastSpeed,
|
||||
0.0625,
|
||||
16,
|
||||
MIN_SPEED,
|
||||
MAX_SPEED,
|
||||
DEFAULT_SETTINGS.lastSpeed
|
||||
);
|
||||
expanded.hideWithControlsTimer = clampFiniteNumber(
|
||||
@@ -979,6 +985,9 @@
|
||||
}
|
||||
|
||||
var regex;
|
||||
if (pattern.startsWith("/") && pattern.lastIndexOf("/") === 0) {
|
||||
return false;
|
||||
}
|
||||
if (pattern.startsWith("/") && pattern.lastIndexOf("/") > 0) {
|
||||
try {
|
||||
var lastSlash = pattern.lastIndexOf("/");
|
||||
|
||||
@@ -197,6 +197,13 @@
|
||||
var normalizedPattern = pattern.replace(regStrip, "");
|
||||
if (normalizedPattern.length === 0) return null;
|
||||
|
||||
if (
|
||||
normalizedPattern.startsWith("/") &&
|
||||
normalizedPattern.lastIndexOf("/") === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedPattern.startsWith("/") &&
|
||||
normalizedPattern.lastIndexOf("/") > 0
|
||||
|
||||
@@ -72,22 +72,26 @@ function vscClearElement(el) {
|
||||
function vscSanitizeSvgTree(svg) {
|
||||
if (!svg || String(svg.tagName).toLowerCase() !== "svg") return null;
|
||||
|
||||
svg.querySelectorAll("script, style, foreignObject").forEach(function (n) {
|
||||
n.remove();
|
||||
});
|
||||
svg
|
||||
.querySelectorAll(
|
||||
"script, style, foreignObject, iframe, object, embed, image, use, a, " +
|
||||
"animate, animateMotion, animateTransform, set"
|
||||
)
|
||||
.forEach(function (n) {
|
||||
n.remove();
|
||||
});
|
||||
|
||||
[svg].concat(Array.from(svg.querySelectorAll("*"))).forEach(function (el) {
|
||||
for (var i = el.attributes.length - 1; i >= 0; i--) {
|
||||
var attr = el.attributes[i];
|
||||
var name = attr.name.toLowerCase();
|
||||
var val = attr.value;
|
||||
if (name.indexOf("on") === 0) {
|
||||
el.removeAttribute(attr.name);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
(name === "href" || name === "xlink:href") &&
|
||||
/^\s*javascript:/i.test(val)
|
||||
name.indexOf("on") === 0 ||
|
||||
name === "style" ||
|
||||
name === "href" ||
|
||||
name === "xlink:href" ||
|
||||
/url\s*\(/i.test(val)
|
||||
) {
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
|
||||
Generated
+161
-200
@@ -7,7 +7,7 @@
|
||||
"name": "speeder",
|
||||
"devDependencies": {
|
||||
"jsdom": "^26.1.0",
|
||||
"vitest": "^3.2.4"
|
||||
"vitest": "^3.2.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
@@ -140,9 +140,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
||||
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -157,9 +157,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
|
||||
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
|
||||
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -174,9 +174,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -191,9 +191,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -208,9 +208,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -225,9 +225,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -242,9 +242,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -259,9 +259,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -276,9 +276,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
|
||||
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
|
||||
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -293,9 +293,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -310,9 +310,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
|
||||
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
|
||||
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -327,9 +327,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
|
||||
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
|
||||
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -344,9 +344,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
|
||||
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
|
||||
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
@@ -361,9 +361,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
|
||||
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
|
||||
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -378,9 +378,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
|
||||
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
|
||||
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -395,9 +395,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
|
||||
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
|
||||
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -412,9 +412,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -429,9 +429,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -446,9 +446,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -463,9 +463,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -480,9 +480,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -497,9 +497,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -514,9 +514,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -531,9 +531,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -548,9 +548,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
|
||||
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
|
||||
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -565,9 +565,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -680,9 +680,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -697,9 +694,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -714,9 +708,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -731,9 +722,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -748,9 +736,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -765,9 +750,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -782,9 +764,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -799,9 +778,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -816,9 +792,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -833,9 +806,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -850,9 +820,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -867,9 +834,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -884,9 +848,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1003,15 +964,15 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
|
||||
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
|
||||
"integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "3.2.4",
|
||||
"@vitest/utils": "3.2.4",
|
||||
"@vitest/spy": "3.2.7",
|
||||
"@vitest/utils": "3.2.7",
|
||||
"chai": "^5.2.0",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
@@ -1020,13 +981,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
|
||||
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz",
|
||||
"integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "3.2.4",
|
||||
"@vitest/spy": "3.2.7",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.17"
|
||||
},
|
||||
@@ -1047,9 +1008,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
|
||||
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz",
|
||||
"integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1060,13 +1021,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
|
||||
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz",
|
||||
"integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "3.2.4",
|
||||
"@vitest/utils": "3.2.7",
|
||||
"pathe": "^2.0.3",
|
||||
"strip-literal": "^3.0.0"
|
||||
},
|
||||
@@ -1075,13 +1036,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
|
||||
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz",
|
||||
"integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "3.2.4",
|
||||
"@vitest/pretty-format": "3.2.7",
|
||||
"magic-string": "^0.30.17",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -1090,9 +1051,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
|
||||
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz",
|
||||
"integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1103,13 +1064,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
|
||||
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz",
|
||||
"integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "3.2.4",
|
||||
"@vitest/pretty-format": "3.2.7",
|
||||
"loupe": "^3.1.4",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
@@ -1258,9 +1219,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
||||
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -1271,32 +1232,32 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.7",
|
||||
"@esbuild/android-arm": "0.27.7",
|
||||
"@esbuild/android-arm64": "0.27.7",
|
||||
"@esbuild/android-x64": "0.27.7",
|
||||
"@esbuild/darwin-arm64": "0.27.7",
|
||||
"@esbuild/darwin-x64": "0.27.7",
|
||||
"@esbuild/freebsd-arm64": "0.27.7",
|
||||
"@esbuild/freebsd-x64": "0.27.7",
|
||||
"@esbuild/linux-arm": "0.27.7",
|
||||
"@esbuild/linux-arm64": "0.27.7",
|
||||
"@esbuild/linux-ia32": "0.27.7",
|
||||
"@esbuild/linux-loong64": "0.27.7",
|
||||
"@esbuild/linux-mips64el": "0.27.7",
|
||||
"@esbuild/linux-ppc64": "0.27.7",
|
||||
"@esbuild/linux-riscv64": "0.27.7",
|
||||
"@esbuild/linux-s390x": "0.27.7",
|
||||
"@esbuild/linux-x64": "0.27.7",
|
||||
"@esbuild/netbsd-arm64": "0.27.7",
|
||||
"@esbuild/netbsd-x64": "0.27.7",
|
||||
"@esbuild/openbsd-arm64": "0.27.7",
|
||||
"@esbuild/openbsd-x64": "0.27.7",
|
||||
"@esbuild/openharmony-arm64": "0.27.7",
|
||||
"@esbuild/sunos-x64": "0.27.7",
|
||||
"@esbuild/win32-arm64": "0.27.7",
|
||||
"@esbuild/win32-ia32": "0.27.7",
|
||||
"@esbuild/win32-x64": "0.27.7"
|
||||
"@esbuild/aix-ppc64": "0.28.2",
|
||||
"@esbuild/android-arm": "0.28.2",
|
||||
"@esbuild/android-arm64": "0.28.2",
|
||||
"@esbuild/android-x64": "0.28.2",
|
||||
"@esbuild/darwin-arm64": "0.28.2",
|
||||
"@esbuild/darwin-x64": "0.28.2",
|
||||
"@esbuild/freebsd-arm64": "0.28.2",
|
||||
"@esbuild/freebsd-x64": "0.28.2",
|
||||
"@esbuild/linux-arm": "0.28.2",
|
||||
"@esbuild/linux-arm64": "0.28.2",
|
||||
"@esbuild/linux-ia32": "0.28.2",
|
||||
"@esbuild/linux-loong64": "0.28.2",
|
||||
"@esbuild/linux-mips64el": "0.28.2",
|
||||
"@esbuild/linux-ppc64": "0.28.2",
|
||||
"@esbuild/linux-riscv64": "0.28.2",
|
||||
"@esbuild/linux-s390x": "0.28.2",
|
||||
"@esbuild/linux-x64": "0.28.2",
|
||||
"@esbuild/netbsd-arm64": "0.28.2",
|
||||
"@esbuild/netbsd-x64": "0.28.2",
|
||||
"@esbuild/openbsd-arm64": "0.28.2",
|
||||
"@esbuild/openbsd-x64": "0.28.2",
|
||||
"@esbuild/openharmony-arm64": "0.28.2",
|
||||
"@esbuild/sunos-x64": "0.28.2",
|
||||
"@esbuild/win32-arm64": "0.28.2",
|
||||
"@esbuild/win32-ia32": "0.28.2",
|
||||
"@esbuild/win32-x64": "0.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
@@ -1492,9 +1453,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1568,9 +1529,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
||||
"version": "8.5.26",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1588,7 +1549,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"nanoid": "^3.3.17",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -1837,13 +1798,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"version": "7.3.6",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
|
||||
"integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"postcss": "^8.5.6",
|
||||
@@ -1935,20 +1896,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz",
|
||||
"integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
"@vitest/mocker": "3.2.4",
|
||||
"@vitest/pretty-format": "^3.2.4",
|
||||
"@vitest/runner": "3.2.4",
|
||||
"@vitest/snapshot": "3.2.4",
|
||||
"@vitest/spy": "3.2.4",
|
||||
"@vitest/utils": "3.2.4",
|
||||
"@vitest/expect": "3.2.7",
|
||||
"@vitest/mocker": "3.2.7",
|
||||
"@vitest/pretty-format": "^3.2.7",
|
||||
"@vitest/runner": "3.2.7",
|
||||
"@vitest/snapshot": "3.2.7",
|
||||
"@vitest/spy": "3.2.7",
|
||||
"@vitest/utils": "3.2.7",
|
||||
"chai": "^5.2.0",
|
||||
"debug": "^4.4.1",
|
||||
"expect-type": "^1.2.1",
|
||||
@@ -1978,8 +1939,8 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
|
||||
"@vitest/browser": "3.2.4",
|
||||
"@vitest/ui": "3.2.4",
|
||||
"@vitest/browser": "3.2.7",
|
||||
"@vitest/ui": "3.2.7",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*"
|
||||
},
|
||||
@@ -2086,9 +2047,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
+1
-1
@@ -7,6 +7,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"jsdom": "^26.1.0",
|
||||
"vitest": "^3.2.4"
|
||||
"vitest": "^3.2.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,8 +45,8 @@ validate_semver() {
|
||||
echo "Error: empty version." >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ ! "$s" =~ ^[0-9]+(\.[0-9]+){0,3}(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "Error: invalid version (use something like 5.0.4)." >&2
|
||||
if [[ ! "$s" =~ ^[0-9]+(\.[0-9]+){0,3}$ ]]; then
|
||||
echo "Error: Firefox versions must contain only 1-4 numeric parts (for example, 6.0.8.1)." >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
@@ -59,10 +59,15 @@ fi
|
||||
git checkout beta
|
||||
git pull origin beta
|
||||
|
||||
echo "Current version on beta ($MANIFEST_PATH): $(manifest_version)"
|
||||
CURRENT_VERSION="$(manifest_version)"
|
||||
echo "Current version on beta ($MANIFEST_PATH): $CURRENT_VERSION"
|
||||
read -r -p "Release version for $MANIFEST_PATH + tag (e.g. 5.0.4): " SEMVER_IN
|
||||
SEMVER="$(normalize_semver "$SEMVER_IN")"
|
||||
validate_semver "$SEMVER"
|
||||
if [[ "$SEMVER" == "$CURRENT_VERSION" ]]; then
|
||||
echo "Error: release version must differ from the current manifest version $CURRENT_VERSION." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TAG="v${SEMVER}"
|
||||
if [[ "$TAG" == *-beta* ]]; then
|
||||
@@ -70,6 +75,11 @@ if [[ "$TAG" == *-beta* ]]; then
|
||||
read -r -p "Continue anyway? [y/N] " w
|
||||
[[ "${w:-}" =~ ^[yY](es)?$ ]] || { echo "Aborted."; exit 1; }
|
||||
fi
|
||||
if git show-ref --verify --quiet "refs/tags/$TAG" ||
|
||||
git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then
|
||||
echo "Error: tag $TAG already exists." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "This will:"
|
||||
@@ -87,11 +97,11 @@ git pull origin main
|
||||
git merge --squash beta
|
||||
bump_manifest "$SEMVER"
|
||||
git add -A
|
||||
git commit -m "Release $TAG"
|
||||
git commit -m "chore(release): prepare $TAG"
|
||||
|
||||
git push origin main
|
||||
|
||||
git tag -a "$TAG" -m "$TAG"
|
||||
git tag -s "$TAG" -m "$TAG"
|
||||
git push origin "$TAG"
|
||||
|
||||
git checkout dev
|
||||
|
||||
+16
-6
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Merge dev → beta, push beta, and push an annotated beta tag (v*-beta*).
|
||||
# Merge dev → beta, push beta, and push a signed beta tag (v*-beta*).
|
||||
# Triggers .github/workflows/deploy.yml: unlisted AMO sign + GitHub prerelease.
|
||||
|
||||
set -euo pipefail
|
||||
@@ -44,8 +44,8 @@ validate_semver() {
|
||||
echo "Error: empty version." >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ ! "$s" =~ ^[0-9]+(\.[0-9]+){0,3}(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "Error: invalid version (use something like 5.0.4 or 5.0.4-beta.1)." >&2
|
||||
if [[ ! "$s" =~ ^[0-9]+(\.[0-9]+){0,3}$ ]]; then
|
||||
echo "Error: Firefox versions must contain only 1-4 numeric parts (for example, 6.0.8.1)." >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
@@ -58,10 +58,15 @@ fi
|
||||
git checkout dev
|
||||
git pull origin dev
|
||||
|
||||
echo "Current version in $MANIFEST_PATH: $(manifest_version)"
|
||||
CURRENT_VERSION="$(manifest_version)"
|
||||
echo "Current version in $MANIFEST_PATH: $CURRENT_VERSION"
|
||||
read -r -p "New version for $MANIFEST_PATH (e.g. 5.0.4): " SEMVER_IN
|
||||
SEMVER="$(normalize_semver "$SEMVER_IN")"
|
||||
validate_semver "$SEMVER"
|
||||
if [[ "$SEMVER" == "$CURRENT_VERSION" ]]; then
|
||||
echo "Error: release version must differ from the current manifest version $CURRENT_VERSION." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Beta git tag will include '-beta' (required by deploy.yml)."
|
||||
read -r -p "Beta tag suffix [beta.1]: " SUFFIX_IN
|
||||
@@ -74,6 +79,11 @@ if [[ "$TAG" != *-beta* ]]; then
|
||||
echo "Error: beta tag must contain '-beta' for the workflow (got $TAG). Try suffix like beta.1." >&2
|
||||
exit 1
|
||||
fi
|
||||
if git show-ref --verify --quiet "refs/tags/$TAG" ||
|
||||
git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then
|
||||
echo "Error: tag $TAG already exists." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "This will:"
|
||||
@@ -88,7 +98,7 @@ echo "🚀 Releasing beta $TAG"
|
||||
|
||||
bump_manifest "$SEMVER"
|
||||
git add "$MANIFEST_PATH"
|
||||
git commit -m "Bump version to $SEMVER"
|
||||
git commit -m "chore(release): bump version to $SEMVER"
|
||||
git push origin dev
|
||||
|
||||
git checkout beta
|
||||
@@ -96,7 +106,7 @@ git pull origin beta
|
||||
git merge dev --no-ff -m "$TAG"
|
||||
git push origin beta
|
||||
|
||||
git tag -a "$TAG" -m "$TAG"
|
||||
git tag -s "$TAG" -m "$TAG"
|
||||
git push origin "$TAG"
|
||||
|
||||
git checkout dev
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { applyJSDOMWindow } from "./jsdom-globals.js";
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const repoRoot = path.resolve(__dirname, "..", "..");
|
||||
let activeDom = null;
|
||||
|
||||
function readRepoFile(relPath) {
|
||||
return fs.readFileSync(path.join(repoRoot, relPath), "utf8");
|
||||
@@ -18,11 +19,13 @@ function readRepoFile(relPath) {
|
||||
* top-level `const` redeclaration errors (avoids document.write).
|
||||
*/
|
||||
export function loadHtmlString(html, options = {}) {
|
||||
if (activeDom) activeDom.window.close();
|
||||
const dom = new JSDOM(html, {
|
||||
url: options.url || "https://example.org/",
|
||||
pretendToBeVisual: true,
|
||||
runScripts: "dangerously"
|
||||
});
|
||||
activeDom = dom;
|
||||
applyJSDOMWindow(dom.window);
|
||||
}
|
||||
|
||||
@@ -173,7 +176,13 @@ export function createChromeMock(options = {}) {
|
||||
getManifest: vi.fn(() => ({
|
||||
version: options.manifestVersion || "9.9.9"
|
||||
})),
|
||||
getURL: vi.fn((url) => "moz-extension://speeder/" + url)
|
||||
getURL: vi.fn((url) => "moz-extension://speeder/" + url),
|
||||
sendMessage: vi.fn((message, callback) => {
|
||||
if (options.runtimeSendMessageImpl) {
|
||||
return options.runtimeSendMessageImpl(message, callback);
|
||||
}
|
||||
if (callback) callback({ paused: false });
|
||||
})
|
||||
},
|
||||
storage: {
|
||||
sync: syncArea,
|
||||
|
||||
@@ -4,6 +4,7 @@ const { JSDOM } = require("jsdom");
|
||||
const vi = globalThis.vi;
|
||||
|
||||
const ROOT = path.resolve(__dirname, "..", "..");
|
||||
let activeDom = null;
|
||||
|
||||
function clone(value) {
|
||||
if (value === undefined) return undefined;
|
||||
@@ -42,15 +43,18 @@ function applyJSDOMWindow(win) {
|
||||
win.Date = globalThis.Date;
|
||||
win.open = vi.fn();
|
||||
win.close = vi.fn();
|
||||
win.confirm = vi.fn(() => true);
|
||||
}
|
||||
|
||||
function loadHtmlString(html, options) {
|
||||
const config = options || {};
|
||||
if (activeDom) activeDom.window.close();
|
||||
const dom = new JSDOM(html, {
|
||||
url: config.url || "https://example.org/",
|
||||
pretendToBeVisual: true,
|
||||
runScripts: "dangerously"
|
||||
});
|
||||
activeDom = dom;
|
||||
applyJSDOMWindow(dom.window);
|
||||
}
|
||||
|
||||
@@ -244,6 +248,7 @@ function createChromeMock(options) {
|
||||
const storageOnChanged = createChromeEvent();
|
||||
const tabsOnActivated = createChromeEvent();
|
||||
const tabsOnUpdated = createChromeEvent();
|
||||
const tabsOnRemoved = createChromeEvent();
|
||||
const runtimeOnMessage = createChromeEvent();
|
||||
|
||||
const chrome = {
|
||||
@@ -251,6 +256,12 @@ function createChromeMock(options) {
|
||||
lastError: null,
|
||||
getManifest: vi.fn(() => clone(config.manifest) || { version: "0.0.0-test" }),
|
||||
getURL: vi.fn((relPath) => `moz-extension://${relPath}`),
|
||||
sendMessage: vi.fn((message, callback) => {
|
||||
if (config.runtimeSendMessageImpl) {
|
||||
return config.runtimeSendMessageImpl(message, callback);
|
||||
}
|
||||
if (callback) callback({ paused: false });
|
||||
}),
|
||||
onMessage: runtimeOnMessage
|
||||
},
|
||||
browserAction: {
|
||||
@@ -271,7 +282,8 @@ function createChromeMock(options) {
|
||||
}),
|
||||
create: vi.fn(),
|
||||
onActivated: tabsOnActivated,
|
||||
onUpdated: tabsOnUpdated
|
||||
onUpdated: tabsOnUpdated,
|
||||
onRemoved: tabsOnRemoved
|
||||
},
|
||||
storage: {
|
||||
onChanged: storageOnChanged,
|
||||
|
||||
@@ -27,4 +27,5 @@ export function applyJSDOMWindow(win) {
|
||||
|
||||
win.open = vi.fn();
|
||||
win.close = vi.fn();
|
||||
win.confirm = vi.fn(() => true);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ describe("import/export flows", () => {
|
||||
const settings = globalThis.vscGetSettingsDefaults();
|
||||
settings.siteRules[1].title = "My Shorts rule";
|
||||
settings.siteRules[1].shortcutTargetMode = "all";
|
||||
settings.siteRules[1].showAmbientLoopControls = true;
|
||||
|
||||
const exported = globalThis.normalizedSettingsForExport(
|
||||
globalThis.vscBuildStoredSettingsDiff(settings)
|
||||
@@ -33,6 +34,7 @@ describe("import/export flows", () => {
|
||||
|
||||
expect(restored.siteRules[1].title).toBe("My Shorts rule");
|
||||
expect(restored.siteRules[1].shortcutTargetMode).toBe("all");
|
||||
expect(restored.siteRules[1].showAmbientLoopControls).toBe(true);
|
||||
expect(exported.siteRules.map((rule) => rule.title)).toEqual([
|
||||
"YouTube videos",
|
||||
"My Shorts rule",
|
||||
@@ -42,7 +44,8 @@ describe("import/export flows", () => {
|
||||
|
||||
it("exports sync and local settings as a JSON download", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(2026, 3, 4, 8, 9, 10));
|
||||
const exportTime = new Date(2026, 3, 4, 8, 9, 10);
|
||||
vi.setSystemTime(exportTime);
|
||||
const chrome = await setupImportExport({
|
||||
sync: { rememberSpeed: true, shortcutTargetMode: "all" },
|
||||
local: { customButtonIcons: { faster: { slug: "rocket" } } }
|
||||
@@ -87,7 +90,7 @@ describe("import/export flows", () => {
|
||||
const blobText = await capturedBlob.text();
|
||||
expect(JSON.parse(blobText)).toEqual({
|
||||
version: "1.1",
|
||||
exportDate: "2026-04-04T12:09:10.000Z",
|
||||
exportDate: exportTime.toISOString(),
|
||||
settings: {
|
||||
rememberSpeed: true,
|
||||
shortcutTargetMode: "all",
|
||||
|
||||
@@ -70,7 +70,7 @@ describe("options/import-export.js", () => {
|
||||
});
|
||||
|
||||
it("generates timestamped backup filenames", () => {
|
||||
vi.setSystemTime(new Date("2026-04-04T13:14:15Z"));
|
||||
vi.setSystemTime(new Date(2026, 3, 4, 9, 14, 15));
|
||||
bootImportExport();
|
||||
|
||||
expect(window.generateBackupFilename()).toBe(
|
||||
@@ -137,9 +137,14 @@ describe("options/import-export.js", () => {
|
||||
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();
|
||||
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 fakeInput = realCreateElement("input");
|
||||
@@ -197,6 +202,12 @@ describe("options/import-export.js", () => {
|
||||
{ rememberSpeed: true, enabled: false },
|
||||
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);
|
||||
expect(window.restore_options).toHaveBeenCalled();
|
||||
|
||||
@@ -2,7 +2,8 @@ const {
|
||||
createChromeMock,
|
||||
evaluateScript,
|
||||
flushAsyncWork,
|
||||
loadHtmlString
|
||||
loadHtmlString,
|
||||
readWorkspaceFile
|
||||
} = require("./helpers/extension-test-utils");
|
||||
|
||||
function bootInject(options) {
|
||||
@@ -280,6 +281,55 @@ describe("inject.js media/controller lifecycle regressions", () => {
|
||||
expect(video.vsc.div.style.getPropertyValue("height")).toBe("226px");
|
||||
});
|
||||
|
||||
it("keeps measured geometry out of controller stylesheet defaults", () => {
|
||||
[
|
||||
{
|
||||
css: readWorkspaceFile("extension/content/inject.css"),
|
||||
host: /\.vsc-controller\s*\{([^}]*)\}/,
|
||||
fullscreen:
|
||||
/\.vsc-controller\.vsc-fullscreen-popover\s*\{([^}]*)\}/
|
||||
},
|
||||
{
|
||||
css: readWorkspaceFile("extension/content/shadow.css"),
|
||||
host: /:host\s*\{([^}]*)\}/,
|
||||
fullscreen: /:host\(\.vsc-fullscreen-popover\)\s*\{([^}]*)\}/
|
||||
}
|
||||
].forEach(({ css, host, fullscreen }) => {
|
||||
expect(css.match(host)[1]).not.toMatch(
|
||||
/\b(?:top|left|width|height)\s*:/
|
||||
);
|
||||
expect(css.match(fullscreen)[1]).not.toMatch(/\binset\s*:/);
|
||||
});
|
||||
});
|
||||
|
||||
it("repositions a Shorts controller when playback moves the video on-screen", async () => {
|
||||
vi.useFakeTimers();
|
||||
bootInject({ url: "https://www.youtube.com/shorts/example" });
|
||||
await settleLifecycle();
|
||||
|
||||
const player = document.createElement("div");
|
||||
player.className = "html5-video-player";
|
||||
const video = document.createElement("video");
|
||||
const playerRect = makeRect(40, 64, 351, 624);
|
||||
let videoRect = makeRect(40, -560, 351, 624);
|
||||
|
||||
setRect(player, playerRect);
|
||||
setBoxMetrics(player, playerRect.width, playerRect.height);
|
||||
video.getBoundingClientRect = () => videoRect;
|
||||
video.src = "blob:https://www.youtube.com/shorts";
|
||||
player.appendChild(video);
|
||||
document.body.appendChild(player);
|
||||
|
||||
window.ensureController(video, player);
|
||||
expect(video.vsc.div.style.getPropertyValue("top")).toBe("-624px");
|
||||
|
||||
videoRect = playerRect;
|
||||
video.dispatchEvent(new Event("playing"));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(video.vsc.div.style.getPropertyValue("top")).toBe("0px");
|
||||
});
|
||||
|
||||
it("targets the controller nearest the pointer unless change-all is selected", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
@@ -292,16 +342,27 @@ describe("inject.js media/controller lifecycle regressions", () => {
|
||||
src: "https://example.org/second.mp4",
|
||||
mountRect: makeRect(500, 0, 320, 180)
|
||||
});
|
||||
setRect(window.getControllerElement(first.controller), makeRect(10, 10, 120, 30));
|
||||
setRect(window.getControllerElement(second.controller), makeRect(510, 10, 120, 30));
|
||||
const firstLayoutRead = vi.fn(() => first.mount.getBoundingClientRect());
|
||||
const secondLayoutRead = vi.fn(() => second.mount.getBoundingClientRect());
|
||||
first.video.getBoundingClientRect = firstLayoutRead;
|
||||
second.video.getBoundingClientRect = secondLayoutRead;
|
||||
|
||||
document.dispatchEvent(
|
||||
new MouseEvent("mousemove", { bubbles: true, clientX: 600, clientY: 20 })
|
||||
);
|
||||
window.runAction("faster", 0.1);
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
code: "KeyD",
|
||||
key: "d"
|
||||
})
|
||||
);
|
||||
|
||||
expect(first.video.playbackRate).toBe(1);
|
||||
expect(second.video.playbackRate).toBe(1.1);
|
||||
expect(firstLayoutRead).not.toHaveBeenCalled();
|
||||
expect(secondLayoutRead).not.toHaveBeenCalled();
|
||||
|
||||
window.tc.settings.shortcutTargetMode = "all";
|
||||
window.runAction("faster", 0.1);
|
||||
@@ -309,7 +370,199 @@ describe("inject.js media/controller lifecycle regressions", () => {
|
||||
expect(second.video.playbackRate).toBe(1.2);
|
||||
});
|
||||
|
||||
it("skips GIF-like ambient loops but accepts them if they become real players", async () => {
|
||||
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 () => {
|
||||
bootInject({
|
||||
url: "https://www.youtube.com/",
|
||||
path: "/"
|
||||
});
|
||||
await settleLifecycle();
|
||||
|
||||
const preview = createControlledVideo({
|
||||
src: "blob:https://www.youtube.com/hover-preview",
|
||||
mountRect: makeRect(0, 0, 320, 180)
|
||||
});
|
||||
preview.mount.id = "inline-preview-player";
|
||||
document.dispatchEvent(
|
||||
new MouseEvent("mousemove", { bubbles: true, clientX: 100, clientY: 90 })
|
||||
);
|
||||
expect(window.tc.lastPointerPosition).toEqual(
|
||||
expect.objectContaining({ document, x: 100, y: 90 })
|
||||
);
|
||||
|
||||
window.history.pushState({}, "", "/watch?v=next-video");
|
||||
|
||||
const main = createControlledVideo({
|
||||
src: "blob:https://www.youtube.com/main-player",
|
||||
mountRect: makeRect(0, 0, 1280, 720)
|
||||
});
|
||||
main.mount.id = "movie_player";
|
||||
document.dispatchEvent(
|
||||
new MouseEvent("mousemove", { bubbles: true, clientX: 100, clientY: 90 })
|
||||
);
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
code: "KeyD",
|
||||
key: "d"
|
||||
})
|
||||
);
|
||||
|
||||
expect(window.tc.lastPointerPosition).not.toBeNull();
|
||||
expect(preview.video.playbackRate).toBe(1);
|
||||
expect(main.video.playbackRate).toBe(1.1);
|
||||
});
|
||||
|
||||
it("captures SPA shortcuts before later page handlers and distinguishes Shift", async () => {
|
||||
vi.useFakeTimers();
|
||||
bootInject({
|
||||
syncGetDelayMs: 25,
|
||||
syncData: {
|
||||
keyBindings: [
|
||||
{ action: "advance", code: "KeyX", value: 10 },
|
||||
{ action: "advance", code: "KeyX", shiftKey: true, value: 3 }
|
||||
]
|
||||
}
|
||||
});
|
||||
expect(window.vscKeydownListenerAttached).toBe(true);
|
||||
|
||||
window.addEventListener(
|
||||
"keydown",
|
||||
(event) => event.stopImmediatePropagation(),
|
||||
true
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
await settleLifecycle();
|
||||
|
||||
const { video } = createControlledVideo();
|
||||
video.currentTime = 50;
|
||||
video.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
bubbles: true,
|
||||
code: "KeyX",
|
||||
key: "x"
|
||||
})
|
||||
);
|
||||
video.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
bubbles: true,
|
||||
code: "KeyX",
|
||||
key: "X",
|
||||
shiftKey: true
|
||||
})
|
||||
);
|
||||
|
||||
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 () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
@@ -332,12 +585,71 @@ describe("inject.js media/controller lifecycle regressions", () => {
|
||||
expect(window.ensureController(video, mount)).toBeNull();
|
||||
expect(video.vsc).toBeUndefined();
|
||||
|
||||
video.loop = false;
|
||||
video.controls = true;
|
||||
window.tc.settings.showAmbientLoopControls = true;
|
||||
window.tc.siteRuleBase.showAmbientLoopControls = true;
|
||||
expect(window.ensureController(video, mount)).toBeTruthy();
|
||||
expect(video.vsc.div.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
it("always includes a genuine player even when its video has an ambient-loop signature", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
const player = document.createElement("media-player");
|
||||
const video = document.createElement("video");
|
||||
const playButton = document.createElement("button");
|
||||
const rect = makeRect(0, 0, 640, 360);
|
||||
playButton.setAttribute("aria-label", "Play");
|
||||
video.autoplay = true;
|
||||
video.defaultMuted = true;
|
||||
video.muted = true;
|
||||
video.loop = true;
|
||||
video.playsInline = true;
|
||||
video.src = "https://example.org/interactive-loop.mp4";
|
||||
player.append(video, playButton);
|
||||
document.body.appendChild(player);
|
||||
[player, video].forEach((element) => setRect(element, rect));
|
||||
setBoxMetrics(player, rect.width, rect.height);
|
||||
|
||||
expect(window.tc.settings.showAmbientLoopControls).toBe(false);
|
||||
expect(window.ensureController(video, player)).toBeTruthy();
|
||||
expect(video.vsc.div.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
it("applies the ambient-loop setting from a matching site rule", async () => {
|
||||
bootInject({
|
||||
url: "https://news.example.org/story",
|
||||
syncData: {
|
||||
showAmbientLoopControls: false,
|
||||
siteRules: [
|
||||
{
|
||||
pattern: "news.example.org",
|
||||
enabled: true,
|
||||
showAmbientLoopControls: true
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
await settleLifecycle();
|
||||
|
||||
const mount = document.createElement("div");
|
||||
const video = document.createElement("video");
|
||||
const rect = makeRect(0, 0, 480, 270);
|
||||
video.autoplay = true;
|
||||
video.defaultMuted = true;
|
||||
video.muted = true;
|
||||
video.loop = true;
|
||||
video.playsInline = true;
|
||||
video.src = "https://news.example.org/card.mp4";
|
||||
mount.appendChild(video);
|
||||
document.body.appendChild(mount);
|
||||
[mount, video].forEach((element) => setRect(element, rect));
|
||||
setBoxMetrics(mount, rect.width, rect.height);
|
||||
|
||||
expect(window.ensureController(video, mount)).toBeTruthy();
|
||||
expect(window.tc.settings.showAmbientLoopControls).toBe(true);
|
||||
});
|
||||
|
||||
it("moves the host into an ancestor fullscreen subtree and restores it", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
@@ -364,6 +676,7 @@ describe("inject.js media/controller lifecycle regressions", () => {
|
||||
div: wrapper,
|
||||
normalControllerMount: normalMount
|
||||
};
|
||||
wrapper.showPopover = vi.fn();
|
||||
window.setupControllerHostTracking(controller, wrapper, normalMount);
|
||||
Object.defineProperty(document, "fullscreenElement", {
|
||||
configurable: true,
|
||||
@@ -372,6 +685,8 @@ describe("inject.js media/controller lifecycle regressions", () => {
|
||||
|
||||
window.syncControllerFullscreenMount(controller);
|
||||
expect(fullscreenPlayer.contains(wrapper)).toBe(true);
|
||||
expect(wrapper.showPopover).not.toHaveBeenCalled();
|
||||
expect(wrapper.hasAttribute("popover")).toBe(false);
|
||||
|
||||
Object.defineProperty(document, "fullscreenElement", {
|
||||
configurable: true,
|
||||
@@ -384,6 +699,61 @@ describe("inject.js media/controller lifecycle regressions", () => {
|
||||
controller.controllerHostCleanup();
|
||||
});
|
||||
|
||||
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();
|
||||
await settleLifecycle();
|
||||
|
||||
const fullscreenPlayer = document.createElement("div");
|
||||
const fullscreenVideo = document.createElement("video");
|
||||
const otherVideo = document.createElement("video");
|
||||
const rect = makeRect(0, 0, 640, 360);
|
||||
|
||||
fullscreenVideo.src = "https://example.org/fullscreen.mp4";
|
||||
otherVideo.src = "https://example.org/other.mp4";
|
||||
fullscreenPlayer.appendChild(fullscreenVideo);
|
||||
document.body.append(fullscreenPlayer, otherVideo);
|
||||
[fullscreenPlayer, fullscreenVideo, otherVideo].forEach((element) => {
|
||||
setRect(element, rect);
|
||||
setBoxMetrics(element, rect.width, rect.height);
|
||||
});
|
||||
window.ensureController(fullscreenVideo, fullscreenPlayer);
|
||||
window.ensureController(otherVideo, document.body);
|
||||
fullscreenVideo.vsc.div.showPopover = vi.fn();
|
||||
otherVideo.vsc.div.showPopover = vi.fn();
|
||||
|
||||
Object.defineProperty(document, "fullscreenElement", {
|
||||
configurable: true,
|
||||
value: fullscreenVideo
|
||||
});
|
||||
window.syncControllerFullscreenMount(fullscreenVideo.vsc);
|
||||
window.syncControllerFullscreenMount(otherVideo.vsc);
|
||||
|
||||
expect(fullscreenVideo.vsc.div.showPopover).toHaveBeenCalledOnce();
|
||||
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 () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
@@ -562,6 +932,79 @@ describe("inject.js media/controller lifecycle regressions", () => {
|
||||
expect(mount.lastElementChild).toBe(wrapper);
|
||||
});
|
||||
|
||||
it("keeps expanded controls visible and disables auto-hide while hovered", async () => {
|
||||
vi.useFakeTimers();
|
||||
bootInject({
|
||||
syncData: {
|
||||
hideWithControls: true,
|
||||
hideWithControlsTimer: 1
|
||||
}
|
||||
});
|
||||
await settleLifecycle();
|
||||
|
||||
const { controller, wrapper } = createControlledVideo();
|
||||
const controllerElement = window.getControllerElement(controller);
|
||||
controllerElement.dispatchEvent(new Event("pointerenter"));
|
||||
|
||||
expect(controller.controllerInteractionActive).toBe(true);
|
||||
expect(controllerElement.classList.contains("vsc-controls-hovered")).toBe(true);
|
||||
expect(wrapper.classList.contains("vsc-controls-hovered")).toBe(true);
|
||||
await vi.advanceTimersByTimeAsync(1500);
|
||||
expect(wrapper.classList.contains("vsc-idle-hidden")).toBe(false);
|
||||
|
||||
controllerElement.dispatchEvent(new Event("pointerleave"));
|
||||
expect(controller.controllerInteractionActive).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
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 () => {
|
||||
vi.useFakeTimers();
|
||||
bootInject({
|
||||
url: "https://www.youtube.com/watch?v=hover-lock",
|
||||
syncData: {
|
||||
hideWithControls: true,
|
||||
hideWithControlsTimer: 1
|
||||
}
|
||||
});
|
||||
await settleLifecycle();
|
||||
|
||||
const player = document.createElement("div");
|
||||
player.className = "html5-video-player ytp-autohide";
|
||||
const { controller, wrapper } = createControlledVideo({ mount: player });
|
||||
const controllerElement = window.getControllerElement(controller);
|
||||
|
||||
expect(wrapper.classList.contains("ytp-autohide")).toBe(true);
|
||||
controllerElement.dispatchEvent(new Event("pointerenter"));
|
||||
expect(wrapper.classList.contains("ytp-autohide")).toBe(false);
|
||||
|
||||
controllerElement.dispatchEvent(new Event("pointerleave"));
|
||||
expect(wrapper.classList.contains("ytp-autohide")).toBe(true);
|
||||
|
||||
player.dispatchEvent(new MouseEvent("mousemove", { bubbles: true }));
|
||||
expect(wrapper.classList.contains("vsc-show")).toBe(true);
|
||||
player.classList.remove("ytp-autohide");
|
||||
player.classList.add("ytp-autohide");
|
||||
await settleLifecycle();
|
||||
expect(wrapper.classList.contains("ytp-autohide")).toBe(true);
|
||||
expect(wrapper.classList.contains("vsc-show")).toBe(true);
|
||||
});
|
||||
|
||||
it("suppresses a zero-size host and reveals it when the video becomes visible", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
@@ -604,9 +1047,13 @@ describe("inject.js media/controller lifecycle regressions", () => {
|
||||
videoRect: makeRect(40, 40, 640, 360),
|
||||
src: "https://example.org/visible.mp4"
|
||||
}).video;
|
||||
const offscreenLayoutRead = vi.spyOn(offscreen, "getBoundingClientRect");
|
||||
const visibleLayoutRead = vi.spyOn(visible, "getBoundingClientRect");
|
||||
|
||||
expect(window.getPrimaryVideoElement()).toBe(visible);
|
||||
expect(window.getPrimaryVideoElement()).not.toBe(offscreen);
|
||||
expect(offscreenLayoutRead).not.toHaveBeenCalled();
|
||||
expect(visibleLayoutRead).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("mounts a controller locally for video directly under an open ShadowRoot", async () => {
|
||||
|
||||
@@ -29,7 +29,10 @@ describe("lucide-client.js", () => {
|
||||
<svg width="10" height="10" onclick="evil()">
|
||||
<script>alert(1)</script>
|
||||
<foreignObject>bad</foreignObject>
|
||||
<path d="M0 0h10v10"></path>
|
||||
<image href="https://tracking.example/icon.png"></image>
|
||||
<use href="https://tracking.example/sprite.svg#icon"></use>
|
||||
<animate attributeName="opacity" values="0;1"></animate>
|
||||
<path style="fill: url(https://tracking.example/a)" fill="url(#paint)" d="M0 0h10v10"></path>
|
||||
</svg>
|
||||
`);
|
||||
|
||||
@@ -37,6 +40,12 @@ describe("lucide-client.js", () => {
|
||||
expect(sanitized).not.toContain("onclick");
|
||||
expect(sanitized).not.toContain("<script");
|
||||
expect(sanitized).not.toContain("foreignObject");
|
||||
expect(sanitized).not.toContain("tracking.example");
|
||||
expect(sanitized).not.toContain("<image");
|
||||
expect(sanitized).not.toContain("<use");
|
||||
expect(sanitized).not.toContain("<animate");
|
||||
expect(sanitized).not.toContain("style=");
|
||||
expect(sanitized).not.toContain("url(");
|
||||
expect(sanitized).toContain('width="100%"');
|
||||
});
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ describe("options page", () => {
|
||||
manifestVersion: "5.1.7.0",
|
||||
sync: {
|
||||
rememberSpeed: true,
|
||||
showAmbientLoopControls: true,
|
||||
enabled: false,
|
||||
shortcutTargetMode: "all",
|
||||
subtitleNudgeEnabledByDefault: false,
|
||||
@@ -45,6 +46,7 @@ describe("options page", () => {
|
||||
enabled: true,
|
||||
shortcutTargetMode: "all",
|
||||
preferredSpeed: 2.4,
|
||||
showAmbientLoopControls: false,
|
||||
subtitleNudgeEnabledByDefault: false,
|
||||
showPopupControlBar: false,
|
||||
popupControllerButtons: ["advance", "settings", "advance"]
|
||||
@@ -55,6 +57,7 @@ describe("options page", () => {
|
||||
|
||||
expect(document.getElementById("app-version").textContent).toBe("5.1.7.0");
|
||||
expect(document.getElementById("rememberSpeed").checked).toBe(true);
|
||||
expect(document.getElementById("showAmbientLoopControls").checked).toBe(true);
|
||||
expect(document.getElementById("enabled").checked).toBe(false);
|
||||
expect(document.getElementById("shortcutTargetMode").value).toBe("all");
|
||||
expect(document.getElementById("subtitleNudgeEnabledByDefault").checked).toBe(
|
||||
@@ -72,6 +75,9 @@ describe("options page", () => {
|
||||
expect(document.querySelector(".site-rule .site-preferredSpeed").value).toBe(
|
||||
"2.4"
|
||||
);
|
||||
expect(
|
||||
document.querySelector(".site-rule .site-showAmbientLoopControls").checked
|
||||
).toBe(false);
|
||||
expect(
|
||||
document.querySelector(".site-rule .site-shortcutTargetMode").value
|
||||
).toBe("all");
|
||||
@@ -85,12 +91,58 @@ describe("options page", () => {
|
||||
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 () => {
|
||||
const chrome = await setupOptions();
|
||||
chrome.storage.sync.set.mockClear();
|
||||
globalThis.createSiteRule(null);
|
||||
const rule = document.querySelector(".site-rule");
|
||||
rule.querySelector(".site-pattern").value = "/(/";
|
||||
rule.querySelector(".site-pattern").value = "/youtube";
|
||||
|
||||
globalThis.save_options();
|
||||
|
||||
@@ -100,6 +152,48 @@ describe("options page", () => {
|
||||
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adds and restores duplicate actions with Shift bindings", async () => {
|
||||
const chrome = await setupOptions();
|
||||
const selector = document.getElementById("addShortcutSelector");
|
||||
expect(selector.querySelector('option[value="rewind"]')).not.toBeNull();
|
||||
|
||||
selector.value = "rewind";
|
||||
selector.dispatchEvent(new window.Event("change", { bubbles: true }));
|
||||
const duplicate = document.querySelector(
|
||||
'.shortcut-row.customs[data-action="rewind"]'
|
||||
);
|
||||
duplicate.querySelector(".customKey").dispatchEvent(
|
||||
new window.KeyboardEvent("keydown", {
|
||||
key: "Z",
|
||||
code: "KeyZ",
|
||||
shiftKey: true,
|
||||
bubbles: true
|
||||
})
|
||||
);
|
||||
duplicate.querySelector(".customValue").value = "3";
|
||||
|
||||
globalThis.save_options();
|
||||
expect(
|
||||
chrome.storage.sync.__state.keyBindings.filter(
|
||||
(binding) => binding.action === "rewind"
|
||||
)
|
||||
).toEqual([
|
||||
expect.objectContaining({ value: 10, shiftKey: false }),
|
||||
expect.objectContaining({ value: 3, shiftKey: true })
|
||||
]);
|
||||
|
||||
globalThis.restore_options();
|
||||
await flushAsyncWork();
|
||||
expect(
|
||||
document.querySelectorAll('.shortcut-row[data-action="rewind"]')
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
document.querySelector(
|
||||
'.shortcut-row.customs[data-action="rewind"] .customKey'
|
||||
).value
|
||||
).toBe("Shift+Z");
|
||||
});
|
||||
|
||||
it("shows a more-menu trigger for collapsed site rules and a collapse trigger when open", async () => {
|
||||
await setupOptions({ sync: { siteRules: [] } });
|
||||
|
||||
@@ -181,6 +275,7 @@ describe("options page", () => {
|
||||
const chrome = await setupOptions();
|
||||
|
||||
document.getElementById("rememberSpeed").checked = true;
|
||||
document.getElementById("showAmbientLoopControls").checked = true;
|
||||
document.getElementById("hideWithControlsTimer").value = "20";
|
||||
document.getElementById("controllerOpacity").value = "0";
|
||||
document.getElementById("controllerMarginTop").value = "250";
|
||||
@@ -204,6 +299,7 @@ describe("options page", () => {
|
||||
rule.querySelector(".site-pattern").value = "youtube.com";
|
||||
rule.querySelector(".override-playback").checked = true;
|
||||
rule.querySelector(".site-rememberSpeed").checked = true;
|
||||
rule.querySelector(".site-showAmbientLoopControls").checked = false;
|
||||
rule.querySelector(".site-preferredSpeed").value = "2.4";
|
||||
rule.querySelector(".override-shortcut-target").checked = true;
|
||||
rule.querySelector(".site-shortcutTargetMode").value = "all";
|
||||
@@ -231,6 +327,7 @@ describe("options page", () => {
|
||||
);
|
||||
|
||||
expect(savedSettings.rememberSpeed).toBe(true);
|
||||
expect(savedSettings.showAmbientLoopControls).toBe(true);
|
||||
expect(savedSettings.hideWithControlsTimer).toBe(15);
|
||||
expect(savedSettings.controllerOpacity).toBe(0);
|
||||
expect(savedSettings.controllerMarginTop).toBe(200);
|
||||
@@ -246,6 +343,7 @@ describe("options page", () => {
|
||||
title: "My YouTube rule",
|
||||
pattern: "youtube.com",
|
||||
rememberSpeed: true,
|
||||
showAmbientLoopControls: false,
|
||||
shortcutTargetMode: "all",
|
||||
preferredSpeed: 2.4,
|
||||
controllerOpacity: 0,
|
||||
@@ -292,4 +390,22 @@ describe("options page", () => {
|
||||
).toEqual(expect.any(Number));
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,6 +67,59 @@ describe("options.js", () => {
|
||||
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("automatically saves changed settings", async () => {
|
||||
const chrome = bootOptions({ syncData: { rememberSpeed: false } });
|
||||
await flushAsyncWork(3);
|
||||
vi.useFakeTimers();
|
||||
chrome.storage.sync.set.mockClear();
|
||||
|
||||
const rememberSpeed = document.getElementById("rememberSpeed");
|
||||
rememberSpeed.checked = true;
|
||||
rememberSpeed.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
|
||||
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 () => {
|
||||
const chrome = bootOptions({ syncData: { rememberSpeed: false } });
|
||||
await flushAsyncWork(3);
|
||||
|
||||
@@ -35,6 +35,12 @@ describe("popup UI", () => {
|
||||
expect(
|
||||
document.querySelectorAll("#popupControlBar button").length
|
||||
).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 () => {
|
||||
@@ -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();
|
||||
chrome.storage.sync.set.mockClear();
|
||||
chrome.browserAction.setIcon.mockClear();
|
||||
@@ -84,13 +90,10 @@ describe("popup UI", () => {
|
||||
expect(document.getElementById("enable").classList.contains("hide")).toBe(
|
||||
false
|
||||
);
|
||||
expect(chrome.browserAction.setIcon).toHaveBeenCalledWith({
|
||||
path: {
|
||||
19: "assets/icons/icon19_disabled.png",
|
||||
38: "assets/icons/icon38_disabled.png",
|
||||
48: "assets/icons/icon48_disabled.png"
|
||||
}
|
||||
});
|
||||
expect(document.getElementById("status").textContent).toBe(
|
||||
"Disabled. Open pages update automatically."
|
||||
);
|
||||
expect(chrome.browserAction.setIcon).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles refresh responses for unsupported and successful pages", async () => {
|
||||
|
||||
+129
-10
@@ -18,6 +18,7 @@ function bootPopup(options) {
|
||||
manifest: { version: "9.9.9-test" },
|
||||
syncData: config.syncData,
|
||||
localData: config.localData,
|
||||
runtimeSendMessageImpl: config.runtimeSendMessageImpl,
|
||||
tabsQueryResult: [
|
||||
config.activeTab || { id: 99, active: true, url: "https://example.com/" }
|
||||
]
|
||||
@@ -107,10 +108,10 @@ describe("popup.js", () => {
|
||||
executeScriptImpl: (tabId, details, callback) => {
|
||||
speedQueryCount += 1;
|
||||
callback(
|
||||
speedQueryCount <= 2
|
||||
speedQueryCount === 1
|
||||
? [
|
||||
{ speed: 1.25, preferred: false },
|
||||
{ speed: 1.5, preferred: true }
|
||||
{ speed: 1.5, frameToken: "playing-frame", preferred: true }
|
||||
]
|
||||
: [{ speed: 1.75, preferred: true }]
|
||||
);
|
||||
@@ -138,12 +139,136 @@ describe("popup.js", () => {
|
||||
|
||||
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(
|
||||
99,
|
||||
{ action: "run_action", actionName: "faster" },
|
||||
{
|
||||
action: "run_action",
|
||||
actionName: "faster",
|
||||
targetFrameToken: "playing-frame"
|
||||
},
|
||||
expect.any(Function)
|
||||
);
|
||||
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 () => {
|
||||
const chrome = bootPopup({
|
||||
syncData: {
|
||||
@@ -162,13 +287,7 @@ describe("popup.js", () => {
|
||||
expect(
|
||||
window.vscExpandStoredSettings(chrome.storage.sync._dump()).enabled
|
||||
).toBe(true);
|
||||
expect(chrome.browserAction.setIcon).toHaveBeenCalledWith({
|
||||
path: {
|
||||
19: "assets/icons/icon19.png",
|
||||
38: "assets/icons/icon38.png",
|
||||
48: "assets/icons/icon48.png"
|
||||
}
|
||||
});
|
||||
expect(chrome.browserAction.setIcon).not.toHaveBeenCalled();
|
||||
|
||||
document.querySelector("#refresh").click();
|
||||
expect(document.querySelector("#status").textContent).toContain("Closing");
|
||||
|
||||
@@ -34,10 +34,36 @@ describe("canonical settings storage", () => {
|
||||
})
|
||||
);
|
||||
expect(settings.shortcutTargetMode).toBe("closest");
|
||||
expect(settings.showAmbientLoopControls).toBe(false);
|
||||
expect(settings.subtitleNudgeInterval).toBe(250);
|
||||
expect(settings.siteRules[0].enableSubtitleNudge).toBeUndefined();
|
||||
});
|
||||
|
||||
it("round-trips global and site-specific ambient-loop visibility", () => {
|
||||
const settings = window.vscExpandStoredSettings({
|
||||
showAmbientLoopControls: true,
|
||||
siteRules: [
|
||||
{
|
||||
pattern: "cnn.com",
|
||||
showAmbientLoopControls: false
|
||||
}
|
||||
]
|
||||
});
|
||||
const restored = window.vscExpandStoredSettings(
|
||||
window.vscBuildStoredSettingsDiff(settings)
|
||||
);
|
||||
|
||||
expect(restored.showAmbientLoopControls).toBe(true);
|
||||
expect(restored.siteRules).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
pattern: "cnn.com",
|
||||
showAmbientLoopControls: false
|
||||
})
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("round-trips global and site-specific shortcut targeting", () => {
|
||||
const settings = window.vscExpandStoredSettings({
|
||||
shortcutTargetMode: "all",
|
||||
@@ -63,6 +89,17 @@ describe("canonical settings storage", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps every managed setting recognizable in legacy raw backups", () => {
|
||||
evaluateScript("extension/shared/import-export.js");
|
||||
const importExport = window.SpeederShared.importExport;
|
||||
|
||||
window.vscGetManagedSyncKeys().forEach(function (key) {
|
||||
expect(importExport.isRecognizedRawSettingsObject({ [key]: null })).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("provides titles for built-in video rules and round-trips title edits sparsely", () => {
|
||||
const settings = window.vscExpandStoredSettings({});
|
||||
expect(settings.siteRules.map((rule) => rule.title)).toEqual([
|
||||
@@ -218,7 +255,7 @@ describe("canonical settings storage", () => {
|
||||
forceLastSavedSpeed: "true",
|
||||
controllerOpacity: 3,
|
||||
subtitleNudgeInterval: -5,
|
||||
preferredSpeed: 50
|
||||
preferredSpeed: 500
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
@@ -22,6 +22,13 @@ describe("shared helpers", () => {
|
||||
])
|
||||
).toBeNull();
|
||||
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", () => {
|
||||
@@ -134,6 +141,15 @@ describe("shared helpers", () => {
|
||||
).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", () => {
|
||||
expect(controllerUtils.normalizeControllerLocation("top-right")).toBe(
|
||||
"top-right"
|
||||
@@ -158,6 +174,14 @@ describe("shared helpers", () => {
|
||||
"NumpadAdd"
|
||||
);
|
||||
expect(keyBindingUtils.getLegacyKeyCode({ key: 65 })).toBe(65);
|
||||
expect(keyBindingUtils.getActionValueError("fast", 0.1)).toBeNull();
|
||||
expect(keyBindingUtils.getActionValueError("fast", 16)).toBeNull();
|
||||
expect(keyBindingUtils.getActionValueError("fast", 0.099)).toContain(
|
||||
"between 0.1 and 16"
|
||||
);
|
||||
expect(keyBindingUtils.getActionValueError("fast", 16.001)).toContain(
|
||||
"between 0.1 and 16"
|
||||
);
|
||||
});
|
||||
|
||||
it("builds and parses import/export payloads", () => {
|
||||
@@ -217,6 +241,24 @@ describe("shared helpers", () => {
|
||||
localSettings: null
|
||||
});
|
||||
|
||||
expect(
|
||||
importExportUtils.extractImportSettings({
|
||||
showAmbientLoopControls: true
|
||||
})
|
||||
).toEqual({
|
||||
isWrappedBackup: false,
|
||||
settings: { showAmbientLoopControls: true },
|
||||
localSettings: null
|
||||
});
|
||||
|
||||
expect(
|
||||
importExportUtils.extractImportSettings({ shortcutTargetMode: "closest" })
|
||||
).toEqual({
|
||||
isWrappedBackup: false,
|
||||
settings: { shortcutTargetMode: "closest" },
|
||||
localSettings: null
|
||||
});
|
||||
|
||||
expect(importExportUtils.isRecognizedRawSettingsObject({ wat: true })).toBe(
|
||||
false
|
||||
);
|
||||
|
||||
@@ -72,7 +72,7 @@ describe("durable playback-speed intent", () => {
|
||||
expect(window.tc.settings.rememberSpeed).toBe(true);
|
||||
});
|
||||
|
||||
it("does not seed a brand-new Short from unrelated global lastSpeed", async () => {
|
||||
it("seeds a brand-new Short from lastSpeed when remember is active", async () => {
|
||||
bootInject(
|
||||
{ lastSpeed: 1.8 },
|
||||
"https://www.youtube.com/shorts/new-source"
|
||||
@@ -80,6 +80,33 @@ describe("durable playback-speed intent", () => {
|
||||
const video = await createVideo();
|
||||
|
||||
expect(window.tc.settings.rememberSpeed).toBe(true);
|
||||
expect(window.getRememberedSpeed(video)).toBe(1.8);
|
||||
expect(video.playbackRate).toBe(1.8);
|
||||
});
|
||||
|
||||
it("prefers per-source speed over lastSpeed fallback", async () => {
|
||||
bootInject(
|
||||
{ lastSpeed: 2.0, rememberSpeed: true },
|
||||
"https://example.org/watch",
|
||||
{
|
||||
rememberedSpeeds: {
|
||||
"https://example.org/a.mp4": { speed: 1.4, updatedAt: 100 }
|
||||
}
|
||||
}
|
||||
);
|
||||
const video = await createVideo();
|
||||
|
||||
expect(window.getRememberedSpeed(video)).toBe(1.4);
|
||||
expect(video.playbackRate).toBe(1.4);
|
||||
});
|
||||
|
||||
it("does not fall back to lastSpeed when remember is off", async () => {
|
||||
bootInject(
|
||||
{ lastSpeed: 1.8, rememberSpeed: false },
|
||||
"https://example.org/watch"
|
||||
);
|
||||
const video = await createVideo();
|
||||
|
||||
expect(window.getRememberedSpeed(video)).toBeNull();
|
||||
expect(video.playbackRate).toBe(1);
|
||||
});
|
||||
|
||||
@@ -4,8 +4,10 @@ module.exports = defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
clearMocks: true,
|
||||
fileParallelism: false,
|
||||
globals: true,
|
||||
restoreMocks: true,
|
||||
testTimeout: 15000,
|
||||
include: ["tests/**/*.test.js", "tests/**/*.spec.js"],
|
||||
setupFiles: ["./tests/setup.js"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user