Compare commits

...
41 Commits
Author SHA1 Message Date
joshpatra 8073014bce v6.0.8.0-beta.1 2026-08-02 23:35:19 -04:00
joshpatra f8cfbf05c8 chore(release): bump version to 6.0.8.0 2026-08-02 23:34:34 -04:00
joshpatra b4234e0387 feat(options): add diagnostics copy action 2026-08-02 22:23:08 -04:00
joshpatra d55006dd0a feat(popup): pause Speeder per tab 2026-08-02 21:18:39 -04:00
joshpatra 745af2f1b9 feat(popup): add rule for current site 2026-08-02 21:03:39 -04:00
joshpatra 7e9ce436d3 feat(popup): copy privacy-safe diagnostics 2026-08-02 21:02:08 -04:00
joshpatra 7e3054f6fa v6.0.7.0-beta.1 2026-08-02 10:09:10 -04:00
joshpatra 98546adf41 Bump version to 6.0.7.0 2026-08-02 10:09:09 -04:00
joshpatra daeff63d2a fix(controller): avoid popovers over fullscreen players 2026-08-02 10:07:32 -04:00
joshpatra 80aa00670c chore(release): prepare v6.0.6.0 beta.2 2026-08-02 00:08:01 -04:00
joshpatra aa4f1d15e7 test: make export dates timezone independent 2026-08-02 00:07:40 -04:00
joshpatra 5af0200008 chore(release): merge v6.0.6.0 beta 2026-08-02 00:05:56 -04:00
joshpatra 1d9f6f7e13 chore(release): bump version to 6.0.6.0 2026-08-02 00:05:24 -04:00
joshpatra 0f64fd5ab1 docs: complete implementation checklist 2026-07-31 22:40:19 -04:00
joshpatra e555c4c116 ci: run tests before extension packaging 2026-07-31 22:39:49 -04:00
joshpatra 91d530e29b docs: correct extension behavior guidance 2026-07-31 22:39:25 -04:00
joshpatra 34f2561b50 fix(background): synchronize toolbar icon state 2026-07-31 22:37:44 -04:00
joshpatra fd29883d84 fix(rules): reject malformed regex patterns 2026-07-31 22:36:53 -04:00
joshpatra e083fdcafe fix(import): report partial settings imports 2026-07-31 22:36:12 -04:00
joshpatra 2fa7d8963a fix(settings): confirm destructive defaults reset 2026-07-31 22:35:23 -04:00
joshpatra e4edf10440 fix(ui): improve focus and status accessibility 2026-07-31 22:34:25 -04:00
joshpatra d5c2d0e343 fix(options): label generated settings controls 2026-07-31 22:33:18 -04:00
joshpatra 5cd559b883 feat(options): add keyboard control bar editing 2026-07-31 22:32:04 -04:00
joshpatra 0c9a6a1a1b fix(controller): make overlay controls accessible 2026-07-31 22:29:44 -04:00
joshpatra 3ee970a54b fix(shortcuts): ignore editable control events 2026-07-31 22:28:18 -04:00
joshpatra e12375f2d2 fix(popup): target the displayed media frame 2026-07-31 22:27:33 -04:00
joshpatra 8d7d31a4db fix(controller): restore fullscreen overlay visibility 2026-07-31 22:25:38 -04:00
joshpatra ed21281fa3 docs: add implementation checklist 2026-07-31 22:24:02 -04:00
joshpatra 13350e5cea v6.0.4.1-beta.1 2026-07-26 11:46:49 -04:00
joshpatra 39b7ba27fb Bump version to 6.0.4.1 2026-07-26 11:46:48 -04:00
joshpatra ee346ee9d8 fix(controller): defer SPA initialization until settings load 2026-07-26 11:46:18 -04:00
joshpatra 461c8c448f v6.0.4-beta.1 2026-07-26 11:15:22 -04:00
joshpatra bff65230bb Bump version to 6.0.4 2026-07-26 11:15:20 -04:00
joshpatra 0afb56abe0 fix(options): align shortcuts and show autosave status 2026-07-26 11:14:55 -04:00
joshpatra a02b7b57f7 fix: restore native playback speed range 2026-07-26 11:14:55 -04:00
joshpatra 39c1c76db1 v6.0.3-beta.1 2026-07-26 10:19:31 -04:00
joshpatra 9b6d1ccd3c Bump version to 6.0.3 2026-07-26 10:19:29 -04:00
joshpatra a2d7f0d9d6 feat: expand shortcuts and improve YouTube navigation 2026-07-26 10:19:11 -04:00
joshpatra f8cbcd4368 chore(release): merge v6.0.2-beta.1 2026-07-23 00:09:01 -04:00
joshpatra 04f1414118 chore(release): bump version to 6.0.2 2026-07-23 00:08:45 -04:00
joshpatra dad223d4ad fix(controller): reset shortcut target after navigation 2026-07-23 00:08:38 -04:00
32 changed files with 1671 additions and 301 deletions
+12 -1
View File
@@ -14,7 +14,18 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Install deps
- uses: actions/setup-node@v4
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
- name: Lint
+34
View File
@@ -0,0 +1,34 @@
# Implementation Plan
Constraints: local commits only; no push; no browser testing; one commit per feature.
## Controller and targeting
- [x] Keep the controller visible for its own video in element and ancestor fullscreen.
- [x] Target popup actions at the frame represented by the displayed speed; keep “all videos” intentional.
- [x] Ignore shortcuts originating from editable controls, including shadow-DOM inputs.
## Accessibility and usability
- [x] Give in-player controls accessible names, keyboard behavior, and visible focus.
- [x] Make control-bar customization operable by keyboard as well as drag and drop.
- [x] Label generated shortcut and site-rule form controls.
- [x] Improve popup status announcements, focus indicators, and icon-search semantics.
## Settings safety and validation
- [x] Confirm before Restore Defaults removes preferences and remembered data.
- [x] Report partial imports accurately when custom icons cannot be restored.
- [x] Reject malformed slash-prefixed regular expressions before saving site rules.
## Extension lifecycle and copy
- [x] Initialize and synchronize the disabled toolbar icon from background state.
- [x] Correct shortcut, subtitle-nudge, live-update, and obsolete troubleshooting copy.
- [x] Run automated tests in the release workflow before packaging.
## Verification
- [x] Run focused automated checks after each non-trivial change.
- [x] Run the complete non-browser test suite and review the final local commit series.
- [x] Leave cross-site fullscreen visual verification for reporter/user validation.
+9 -13
View File
@@ -56,16 +56,14 @@ settings page, as well as add additional shortcut keys to match your
preferences. For example, you can assign multiple different "preferred speed"
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
@@ -81,12 +79,10 @@ npx --yes web-ext lint --source-dir extension
### 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?
+65 -3
View File
@@ -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
+238 -81
View File
@@ -90,6 +90,81 @@ function getPrimaryVideoElement(mediaElements) {
return best;
}
function getDiagnosticsSnapshot(media) {
var primary = media || getPrimaryVideoElement();
if (!primary) return null;
var controller = primary.vsc || null;
var wrapper = controller && controller.div;
var fullscreenElement = getFullscreenElement(primary.ownerDocument);
var rect = null;
try {
rect = primary.getBoundingClientRect();
} catch (_error) {}
return {
mediaCount: tc.mediaElements.filter(function(item) {
return item && item.isConnected;
}).length,
mediaType: String(primary.nodeName || "media").toLowerCase(),
playbackRate: Number(primary.playbackRate),
paused: primary.paused === true,
ended: primary.ended === true,
readyState: Number(primary.readyState) || 0,
muted: primary.muted === true,
volume: Number(primary.volume),
dimensions: rect
? [Math.round(Number(rect.width) || 0), Math.round(Number(rect.height) || 0)]
: [0, 0],
fullscreen: {
active: Boolean(fullscreenElement),
element: fullscreenElement
? String(fullscreenElement.nodeName || "element").toLowerCase()
: null,
ownsMedia: Boolean(
fullscreenElement &&
(fullscreenElement === primary ||
isComposedDescendant(primary, fullscreenElement))
)
},
controller: {
present: Boolean(controller && wrapper),
connected: Boolean(wrapper && wrapper.isConnected),
hidden: Boolean(wrapper && wrapper.classList.contains("vsc-hidden")),
geometryHidden: Boolean(
wrapper && wrapper.classList.contains("vsc-geometry-hidden")
),
fullscreenPopover: Boolean(
wrapper && wrapper.classList.contains("vsc-fullscreen-popover")
),
location: controller ? controller.controllerLocation : null
},
effectiveSettings: {
enabled: tc.settings.enabled !== false,
tabPaused: tc.tabPaused === true,
startHidden: tc.settings.startHidden === true,
hideWithControls: tc.settings.hideWithControls === true,
rememberSpeed: tc.settings.rememberSpeed === true,
forceLastSavedSpeed: tc.settings.forceLastSavedSpeed === true,
shortcutTargetMode: tc.settings.shortcutTargetMode
},
siteRule: {
matched: Boolean(tc.activeSiteRule),
disabled: Boolean(
tc.activeSiteRule &&
siteRuleUtils.isSiteRuleDisabled(tc.activeSiteRule)
),
overrideKeys: tc.activeSiteRule
? Object.keys(tc.activeSiteRule)
.filter(function(key) {
return key !== "pattern" && key !== "title";
})
.sort()
: []
}
};
}
var tc = {
settings: {
lastSpeed: getSharedDefault("lastSpeed", 1.0),
@@ -151,16 +226,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;
@@ -235,21 +338,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) {
@@ -652,6 +759,7 @@ function normalizeStoredBinding(binding, fallbackCode) {
var normalized = {
action: binding.action,
code: normalizedCode,
shiftKey: binding.shiftKey === true,
disabled: false,
value: keyBindingUtils.sanitizeActionValue(
binding.action,
@@ -1360,7 +1468,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
);
}
@@ -1506,7 +1615,7 @@ function ensureController(node, parent) {
// 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;
}
@@ -2314,12 +2423,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);
@@ -2333,6 +2454,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 &&
@@ -2370,10 +2493,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;
}
@@ -2564,6 +2690,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") {
@@ -2585,7 +2714,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] &&
@@ -3144,14 +3277,14 @@ function disableDirectFullscreenPopover(videoController) {
wrapper.removeAttribute("popover");
}
function enableFullscreenPopover(videoController, preferredMount) {
function enableDirectFullscreenPopover(videoController) {
if (!videoController || !videoController.video || !videoController.div) {
return false;
}
var wrapper = videoController.div;
if (typeof wrapper.showPopover !== "function") return false;
var normalMount = preferredMount || videoController.normalControllerMount;
var normalMount = videoController.normalControllerMount;
var normalMountIsConnected = Boolean(
normalMount &&
(normalMount.isConnected ||
@@ -3191,12 +3324,13 @@ function syncControllerFullscreenMount(videoController) {
var doc = video.ownerDocument;
var fullscreenElement = getFullscreenElement(doc);
var targetMount = videoController.normalControllerMount;
if (
var ownsFullscreen = Boolean(
fullscreenElement &&
(fullscreenElement === video ||
isComposedDescendant(video, fullscreenElement))
) {
(fullscreenElement === video ||
isComposedDescendant(video, fullscreenElement))
);
if (ownsFullscreen) {
targetMount = getControllerMount(video, fullscreenElement);
} else if (!fullscreenElement && (!targetMount || !targetMount.isConnected)) {
targetMount = getControllerMount(video);
@@ -3205,13 +3339,10 @@ function syncControllerFullscreenMount(videoController) {
if (!targetMount) return false;
if (fullscreenElement) {
// Fullscreen elements and popovers both participate in the browser's top
// layer. Showing Speeder's host after the player enters fullscreen keeps it
// above provider-owned surfaces even when the provider clips descendants or
// creates a new fullscreen stacking context (notably Firefox + YouTube).
// Browsers without the Popover API retain the player-local remount fallback.
if (enableFullscreenPopover(videoController, targetMount)) return true;
if (fullscreenElement === video) {
// A replaced <video> cannot paint author children, so direct-media
// fullscreen is the only case that needs a separate top-layer popover.
if (enableDirectFullscreenPopover(videoController)) return true;
} else {
disableDirectFullscreenPopover(videoController);
}
@@ -3752,7 +3883,7 @@ function defineVideoController() {
// 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".
// and "auto-hidden after timespan".
// We'll follow the timer strictly.
wrapper.classList.add("vsc-idle-hidden");
log("Generic hide: controller hidden due to inactivity", 5);
@@ -3856,11 +3987,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];
@@ -3944,21 +4078,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);
@@ -4150,7 +4269,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);
});
@@ -4208,7 +4327,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);
@@ -4240,7 +4359,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) {
@@ -4309,7 +4428,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;
}
@@ -4352,14 +4475,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 (
@@ -4374,19 +4524,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;
}
@@ -4411,7 +4552,7 @@ function attachKeydownListeners(doc) {
},
true
);
keyDoc.vscKeydownListenerAttached = true;
keyTarget.vscKeydownListenerAttached = true;
});
}
@@ -4557,7 +4698,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);
@@ -4602,10 +4752,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.
@@ -4807,6 +4954,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
+24 -1
View File
@@ -10,6 +10,24 @@
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;
inset: auto !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;
@@ -316,10 +334,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 -1
View File
@@ -1,7 +1,7 @@
{
"name": "Speeder",
"short_name": "Speeder",
"version": "6.0.1",
"version": "6.0.8.0",
"manifest_version": 2,
"description": "Speed up, slow down, advance and rewind HTML5 audio/video with shortcuts (New and improved version of \"Video Speed Controller\")",
"homepage_url": "https://github.com/SoPat712/speeder",
+7 -14
View File
@@ -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();
+29 -9
View File
@@ -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;
+17 -16
View File
@@ -250,7 +250,7 @@
</div>
</div>
<select id="addShortcutSelector">
<select id="addShortcutSelector" aria-label="Add another shortcut">
<option value="">Add shortcut&hellip;</option>
</select>
</section>
@@ -468,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">
@@ -591,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>
@@ -843,7 +843,7 @@
</div>
<div class="site-rule-option site-rule-option-field">
<label
>Preferred speed (0.0625&ndash;16):<br /><em
>Preferred speed (0.1&ndash;16):<br /><em
>Overrides the Preferred speed action for matching
URLs. It does not force every video to start at this
speed.</em
@@ -943,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" />
@@ -998,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" />
@@ -1022,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>
@@ -1037,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&rsquo;s
&ldquo;Rescan page for videos&rdquo; action after a player loads
dynamically. Browser-internal pages and players that do not expose
HTML5 media to extensions cannot be controlled.
</p>
</section>
+240 -69
View File
@@ -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 =
@@ -1092,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
@@ -1117,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);
});
@@ -1139,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";
@@ -1195,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);
@@ -1371,6 +1413,7 @@ function createSiteRule(rule) {
}
applySiteRuleOverrideState(ruleEl, "override-shortcuts", "site-shortcuts-container");
refreshSiteRuleAddShortcutSelector(ruleEl);
associateSiteRuleLabels(ruleEl);
document.getElementById("siteRulesContainer").appendChild(ruleEl);
}
@@ -1379,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;
@@ -1402,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);
@@ -1424,6 +1487,8 @@ function populateControlBarZones(activeZone, availableZone, activeIds, allowButt
if (block) availableZone.appendChild(block);
}
});
updateControlBarBlockLabels(activeZone.closest(".cb-editor"));
}
function readControlBarOrder(activeZone) {
@@ -1467,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) {
@@ -1494,6 +1559,8 @@ function getDragAfterElement(container, x, y) {
}
function initControlBarEditor() {
if (document.vscControlBarEditorInitialized) return;
document.vscControlBarEditorInitialized = true;
var draggedBlock = null;
function clearControlBarDropTargets(activeZone) {
@@ -1506,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);
@@ -1551,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;
@@ -1626,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");
}
@@ -1647,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)
@@ -1712,7 +1826,7 @@ function initLucideButtonIconsUI() {
slug +
" for " +
action +
". Reload pages for the hover bar."
". Open pages update automatically."
);
});
})
@@ -1825,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;
@@ -1892,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);
@@ -1971,6 +2095,8 @@ document.addEventListener("DOMContentLoaded", function () {
versionElement.textContent = manifest.version;
}
document.querySelectorAll("#customs .shortcut-row").forEach(labelShortcutRow);
restore_options();
initControlBarEditor();
@@ -1978,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) {
@@ -2030,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");
@@ -2104,4 +2267,12 @@ document.addEventListener("DOMContentLoaded", function () {
}
}
});
document.addEventListener("change", (event) => {
if (
event.target.id !== "addShortcutSelector" &&
!event.target.closest("#lucideIconSettings")
) {
scheduleAutoSave();
}
});
});
+23 -10
View File
@@ -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 {
+14 -1
View File
@@ -24,6 +24,12 @@
type="button"
aria-pressed="false"
>Force last saved speed</button>
<button
id="pauseTab"
class="popup-compact-action"
type="button"
aria-pressed="false"
>Pause on this tab</button>
<div class="popup-divider"></div>
<div id="popupControlBar" class="popup-control-bar">
<span id="popupSpeed" class="popup-speed">1.00</span>
@@ -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
View File
@@ -1,4 +1,6 @@
document.addEventListener("DOMContentLoaded", function () {
if (window.vscPopupInitialized) return;
window.vscPopupInitialized = true;
var speederShared =
typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {};
var siteRuleUtils = speederShared.siteRules || {};
@@ -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.`
);
}
+9 -4
View File
@@ -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,
+60
View File
@@ -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
+10 -5
View File
@@ -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",
@@ -363,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) {
@@ -749,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") {
@@ -885,8 +887,8 @@
expanded.lastSpeed = clampFiniteNumber(
expanded.lastSpeed,
0.0625,
16,
MIN_SPEED,
MAX_SPEED,
DEFAULT_SETTINGS.lastSpeed
);
expanded.hideWithControlsTimer = clampFiniteNumber(
@@ -983,6 +985,9 @@
}
var regex;
if (pattern.startsWith("/") && pattern.lastIndexOf("/") === 0) {
return false;
}
if (pattern.startsWith("/") && pattern.lastIndexOf("/") > 0) {
try {
var lastSlash = pattern.lastIndexOf("/");
+7
View File
@@ -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
+66
View File
@@ -0,0 +1,66 @@
const {
createChromeMock,
evaluateScript,
loadHtmlString
} = require("./helpers/extension-test-utils");
describe("background toolbar state", () => {
afterEach(() => {
delete global.chrome;
});
it("initializes and follows the enabled storage setting", () => {
loadHtmlString("<!doctype html><html><body></body></html>");
const chrome = createChromeMock({ syncData: { enabled: false } });
global.chrome = chrome;
window.chrome = chrome;
evaluateScript("extension/background/background.js");
expect(chrome.browserAction.setIcon).toHaveBeenLastCalledWith({
path: {
19: "assets/icons/icon19_disabled.png",
38: "assets/icons/icon38_disabled.png",
48: "assets/icons/icon48_disabled.png"
}
});
chrome.storage.sync.set({ enabled: true });
expect(chrome.browserAction.setIcon).toHaveBeenLastCalledWith({
path: {
19: "assets/icons/icon19.png",
38: "assets/icons/icon38.png",
48: "assets/icons/icon48.png"
}
});
});
it("keeps tab pauses in memory and clears closed tabs", () => {
loadHtmlString("<!doctype html><html><body></body></html>");
const chrome = createChromeMock();
global.chrome = chrome;
window.chrome = chrome;
evaluateScript("extension/background/background.js");
const listener = chrome.runtime.onMessage.listeners[0];
const respond = vi.fn();
listener(
{ action: "set_tab_paused", tabId: 42, paused: true },
{},
respond
);
expect(respond).toHaveBeenLastCalledWith({ paused: true });
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(
42,
{ action: "set_tab_paused", paused: true },
expect.any(Function)
);
listener({ action: "get_tab_pause_state" }, { tab: { id: 42 } }, respond);
expect(respond).toHaveBeenLastCalledWith({ paused: true });
chrome.tabs.onRemoved.emit(42);
listener({ action: "get_tab_pause_state", tabId: 42 }, {}, respond);
expect(respond).toHaveBeenLastCalledWith({ paused: false });
});
});
+7 -1
View File
@@ -173,7 +173,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,
+10 -1
View File
@@ -42,6 +42,7 @@ function applyJSDOMWindow(win) {
win.Date = globalThis.Date;
win.open = vi.fn();
win.close = vi.fn();
win.confirm = vi.fn(() => true);
}
function loadHtmlString(html, options) {
@@ -244,6 +245,7 @@ function createChromeMock(options) {
const storageOnChanged = createChromeEvent();
const tabsOnActivated = createChromeEvent();
const tabsOnUpdated = createChromeEvent();
const tabsOnRemoved = createChromeEvent();
const runtimeOnMessage = createChromeEvent();
const chrome = {
@@ -251,6 +253,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 +279,8 @@ function createChromeMock(options) {
}),
create: vi.fn(),
onActivated: tabsOnActivated,
onUpdated: tabsOnUpdated
onUpdated: tabsOnUpdated,
onRemoved: tabsOnRemoved
},
storage: {
onChanged: storageOnChanged,
+1
View File
@@ -27,4 +27,5 @@ export function applyJSDOMWindow(win) {
win.open = vi.fn();
win.close = vi.fn();
win.confirm = vi.fn(() => true);
}
+3 -2
View File
@@ -44,7 +44,8 @@ describe("import/export flows", () => {
it("exports sync and local settings as a JSON download", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 3, 4, 8, 9, 10));
const exportTime = new Date(2026, 3, 4, 8, 9, 10);
vi.setSystemTime(exportTime);
const chrome = await setupImportExport({
sync: { rememberSpeed: true, shortcutTargetMode: "all" },
local: { customButtonIcons: { faster: { slug: "rocket" } } }
@@ -89,7 +90,7 @@ describe("import/export flows", () => {
const blobText = await capturedBlob.text();
expect(JSON.parse(blobText)).toEqual({
version: "1.1",
exportDate: "2026-04-04T12:09:10.000Z",
exportDate: exportTime.toISOString(),
settings: {
rememberSpeed: true,
shortcutTargetMode: "all",
+13 -2
View File
@@ -70,7 +70,7 @@ describe("options/import-export.js", () => {
});
it("generates timestamped backup filenames", () => {
vi.setSystemTime(new Date("2026-04-04T13:14:15Z"));
vi.setSystemTime(new Date(2026, 3, 4, 9, 14, 15));
bootImportExport();
expect(window.generateBackupFilename()).toBe(
@@ -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();
+231 -31
View File
@@ -309,6 +309,198 @@ describe("inject.js media/controller lifecycle regressions", () => {
expect(second.video.playbackRate).toBe(1.2);
});
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();
@@ -423,6 +615,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,
@@ -431,6 +624,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,
@@ -443,50 +638,40 @@ describe("inject.js media/controller lifecycle regressions", () => {
controller.controllerHostCleanup();
});
it("promotes an ancestor-fullscreen controller into the browser top layer", async () => {
it("only promotes the directly-fullscreen video's controller", async () => {
bootInject();
await settleLifecycle();
const fullscreenPlayer = document.createElement("div");
const video = document.createElement("video");
const wrapper = document.createElement("div");
const rect = makeRect(0, 0, 1280, 720);
const fullscreenVideo = document.createElement("video");
const otherVideo = document.createElement("video");
const rect = makeRect(0, 0, 640, 360);
fullscreenPlayer.append(video, wrapper);
document.body.appendChild(fullscreenPlayer);
[fullscreenPlayer, video].forEach((element) => {
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);
});
wrapper.showPopover = vi.fn();
wrapper.hidePopover = vi.fn();
const controller = {
video,
div: wrapper,
normalControllerMount: fullscreenPlayer
};
window.setupControllerHostTracking(controller, wrapper, fullscreenPlayer);
Object.defineProperty(document, "fullscreenElement", {
configurable: true,
value: fullscreenPlayer
});
expect(window.syncControllerFullscreenMount(controller)).toBe(true);
expect(wrapper.parentNode).toBe(fullscreenPlayer);
expect(wrapper.showPopover).toHaveBeenCalledOnce();
expect(wrapper.getAttribute("popover")).toBe("manual");
expect(wrapper.classList.contains("vsc-fullscreen-popover")).toBe(true);
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: null
value: fullscreenVideo
});
window.syncControllerFullscreenMount(controller);
expect(wrapper.hidePopover).toHaveBeenCalledOnce();
window.syncControllerFullscreenMount(fullscreenVideo.vsc);
window.syncControllerFullscreenMount(otherVideo.vsc);
wrapper.remove();
controller.controllerHostCleanup();
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 () => {
@@ -693,6 +878,21 @@ describe("inject.js media/controller lifecycle regressions", () => {
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({
+107 -1
View File
@@ -91,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();
@@ -106,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: [] } });
@@ -302,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);
});
});
+53
View File
@@ -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);
+11 -8
View File
@@ -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
View File
@@ -18,6 +18,7 @@ function bootPopup(options) {
manifest: { version: "9.9.9-test" },
syncData: config.syncData,
localData: config.localData,
runtimeSendMessageImpl: config.runtimeSendMessageImpl,
tabsQueryResult: [
config.activeTab || { id: 99, active: true, url: "https://example.com/" }
]
@@ -107,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");
+1 -1
View File
@@ -244,7 +244,7 @@ describe("canonical settings storage", () => {
forceLastSavedSpeed: "true",
controllerOpacity: 3,
subtitleNudgeInterval: -5,
preferredSpeed: 50
preferredSpeed: 500
}
]
});
+24
View File
@@ -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", () => {