Compare commits

..
139 Commits
Author SHA1 Message Date
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
joshpatra e5ae1ed4ba v6.0.1-beta.1 2026-07-16 23:58:33 -04:00
joshpatra cb892a36a4 Bump version to 6.0.1 2026-07-16 23:58:32 -04:00
joshpatra 722eb0754d fix(controller): fall back to lastSpeed for Shorts when rememberSpeed is active 2026-07-16 23:58:25 -04:00
joshpatra 1ae7bbdee3 v6.0.0-beta.1 2026-07-16 17:42:12 -04:00
joshpatra 6b2a5ed472 Bump version to 6.0.0 2026-07-16 17:42:10 -04:00
joshpatra 29689ae4ec fix(controller): promote fullscreen controls to top layer 2026-07-16 17:40:34 -04:00
joshpatra 9c10183ab6 feat(controller): configure ambient video overlays 2026-07-16 17:05:52 -04:00
joshpatra f1e8f44f42 v5.3.6.1-beta.1 2026-07-16 02:12:13 -04:00
joshpatra 7073330916 Bump version to 5.3.6.1 2026-07-16 02:12:10 -04:00
joshpatra f10818964c fix(controller): keep controls open while hovered 2026-07-16 02:10:56 -04:00
joshpatra 7fd8e5d92d v5.3.6.0-beta.1 2026-07-16 01:59:48 -04:00
joshpatra 2adafb51f2 Bump version to 5.3.6.0 2026-07-16 01:59:46 -04:00
joshpatra 22c8f9d793 fix(controller): elevate controls above sibling panes 2026-07-16 01:46:36 -04:00
joshpatra c0bd997287 fix(performance): bound media discovery work 2026-07-16 01:44:34 -04:00
joshpatra 85d4a0588a fix(extension): harden controls, rules, and speed persistence 2026-07-16 01:31:57 -04:00
joshpatra fd9911855a v5.3.5.0-beta.1 2026-07-14 21:07:29 -04:00
joshpatra cd3088a6db Bump version to 5.3.5.0 2026-07-14 21:07:28 -04:00
joshpatra a2ab1ad261 fix(controller): restore dynamic and fullscreen overlays 2026-07-14 21:07:19 -04:00
joshpatra 5b8f2422ab v5.3.4.0-beta.1 2026-07-12 16:01:33 -04:00
joshpatra 477e5ba8b2 Bump version to 5.3.4.0 2026-07-12 16:01:31 -04:00
joshpatra 8b1820823b fix(controller): keep overlay below sticky headers 2026-07-12 15:58:40 -04:00
joshpatra c429215626 v5.3.3.0-beta.1 2026-07-12 15:51:14 -04:00
joshpatra 9b7a6edd14 Bump version to 5.3.3.0 2026-07-12 15:51:12 -04:00
joshpatra bbab33f55f fix(controller): keep video controls accessible 2026-07-12 15:50:37 -04:00
joshpatra 432ed37f45 v5.3.2.0-beta.1 2026-06-01 01:24:52 -04:00
joshpatra a56742b421 Bump version to 5.3.2.0 2026-06-01 01:24:50 -04:00
joshpatra 9fd66f6668 Reorganize extension source layout 2026-06-01 01:24:09 -04:00
joshpatra 51caae3b7c v5.3.1.0-beta.1 2026-05-29 15:24:02 -04:00
joshpatra 80a54eed06 Bump version to 5.3.1.0 2026-05-29 15:24:00 -04:00
joshpatra f626c5aa33 Fix controller visibility issues and improve Shadow DOM video detection 2026-05-29 15:23:52 -04:00
joshpatra 5df0bbc551 v5.3.0.0-beta.1 2026-05-22 13:37:49 -04:00
joshpatra e61d56b59a Bump version to 5.3.0.0 2026-05-22 13:37:48 -04:00
joshpatra 0bc5ca0315 chore: merge beta into dev
# Conflicts:
#	settings-core.js
2026-05-22 13:37:03 -04:00
joshpatra 2a56834ff3 feat(subtitles): add nudge default state setting 2026-05-22 13:20:08 -04:00
netjeff cddf197a17 Minor improvements to README.md
Delete chrome store link to original upstream Video Speed Controller by igrigorik.
Also some minor formatting improvements.
2026-04-21 10:06:43 -04:00
joshpatra 904e657bb9 chore: restore settings-core.js on dev 2026-04-21 09:57:44 -04:00
joshpatra e652850dfa v5.2.7.0-beta.1 2026-04-11 13:20:07 -04:00
joshpatra bfe20a84dd Bump version to 5.2.7.0 2026-04-11 13:20:06 -04:00
joshpatra 46a8a62110 refactor: reordered donate buttons, moved to icons over text 2026-04-11 13:19:57 -04:00
joshpatra de582ae157 v5.2.6.0-beta.1 2026-04-11 13:13:14 -04:00
joshpatra eb64de6ea3 Bump version to 5.2.6.0 2026-04-11 13:13:13 -04:00
joshpatra eab6d10a19 refactor: Better explanations, feat: made show/hide controller shortcut optional 2026-04-11 13:13:05 -04:00
joshpatra 76f6bd1a1e v5.2.5.0-beta.1 2026-04-11 13:01:55 -04:00
joshpatra 29a9a1d07f Bump version to 5.2.5.0 2026-04-11 13:01:54 -04:00
joshpatra 4d37b4f570 feat(options): site-rule shortcut picker, chevrons, defaults and site-rule help copy
fix(inject,popup): isSpeederActiveForSite whitelist/blacklist; tests; clear activeSiteRule on no match
2026-04-11 12:59:46 -04:00
joshpatra 9d41e6ceeb v5.2.3-beta.1 2026-04-10 13:18:18 -04:00
joshpatra 6d10a569d9 Bump version to 5.2.3 2026-04-10 13:18:17 -04:00
joshpatra 5f63718d62 fix(export): exclude lucide tags cache from backup payload 2026-04-10 13:17:49 -04:00
joshpatra eaffab1f27 v5.2.2.0-beta.1 2026-04-09 16:39:28 -04:00
joshpatra d01c01a216 Bump version to 5.2.2.0 2026-04-09 16:39:27 -04:00
joshpatra 0b6bc5d0a0 fix: some warnings, refactor: changed the donation look, moved it up 2026-04-09 16:39:13 -04:00
joshpatra 3f5983685f v5.2.0-beta.1 2026-04-07 15:38:54 -04:00
joshpatra 41d89e0993 Bump version to 5.2.0 2026-04-07 15:38:53 -04:00
joshpatra a424dea5ca feat: add optional volume shortcuts and control bar actions 2026-04-07 15:38:03 -04:00
joshpatra ab7ae99807 v5.1.9.0-beta.1 2026-04-07 14:54:34 -04:00
joshpatra 5d47c511be Bump version to 5.1.9.0 2026-04-07 14:54:33 -04:00
joshpatra 7713ba5bad Fix Firefox extension link in README
Updated Firefox extension link in README.md.
2026-04-07 14:53:49 -04:00
joshpatra ce0b28de4f Update shortcut validation and stop tracking VS Code settings.
Allow empty keybinds for optional shortcuts while requiring Show/Hide controller, Decrease speed, and Increase speed, and remove tracked .vscode files while keeping the folder gitignored.
2026-04-07 14:52:50 -04:00
joshpatra a2b041e225 v5.1.8.0-beta.1 2026-04-07 14:33:07 -04:00
joshpatra 4f87aadfd7 Bump version to 5.1.8.0 2026-04-07 14:32:21 -04:00
joshpatra 8456111bf0 fix: correct AMO URL and lucide spec object key 2026-04-07 14:31:45 -04:00
joshpatra 6efe92a036 Refine site rule toggle and override UI 2026-04-07 14:31:41 -04:00
joshpatra 0cb13905ff Fix subtitle nudge site gating 2026-04-07 14:31:39 -04:00
joshpatra f32d1b3f71 Accept raw settings backups during import 2026-04-07 14:31:36 -04:00
joshpatra e6c56bcecb Allow zero controller opacity in settings 2026-04-07 14:31:34 -04:00
joshpatra a7a0aafd68 Add Vitest suite and fix wrapped local import restore 2026-04-07 14:31:27 -04:00
joshpatra 3cf1a4acd1 style(inject): normalize formatting 2026-04-04 16:14:56 -04:00
joshpatra a9956831c4 refactor(shortcuts): switch shortcut bindings to event.code 2026-04-04 13:33:11 -04:00
joshpatra 25d3acf576 v5.1.7.0-beta.1 2026-04-02 22:34:00 -04:00
joshpatra 7b8b4324af Bump version to 5.1.7.0 2026-04-02 22:33:59 -04:00
joshpatra 8b9e4bea1d fix: refresh site rules on DOM video changes 2026-04-02 21:16:37 -04:00
joshpatra 8c94cc2088 fix: clarify site rule auto-hide copy 2026-04-02 21:08:15 -04:00
joshpatra 19d3af02a2 refactor: store settings as sparse diffs 2026-04-02 21:07:31 -04:00
joshpatra 306e0e3ea0 Exclude Lucide cache from backups 2026-04-02 20:47:32 -04:00
joshpatra 1536c13c3e v5.1.6-beta.1 2026-04-02 18:20:49 -04:00
joshpatra 6bd319c8cc Bump version to 5.1.6 2026-04-02 18:20:48 -04:00
joshpatra 3aee8c8f9a fix: errors from web-ext 2026-04-02 18:20:33 -04:00
joshpatra 939ee08466 v5.1.4-beta.1 2026-04-02 14:17:18 -04:00
joshpatra 5a175c3cf8 Bump version to 5.1.4 2026-04-02 14:17:17 -04:00
joshpatra 805e5a82e5 fix: unicode reset glyph fallback in extension popup 2026-04-02 14:16:53 -04:00
joshpatra df34b1fee9 feat: Lucide subtitle nudge on/off targets and dual preview in options 2026-04-02 14:16:46 -04:00
joshpatra 0741c6e535 feat: custom Lucide icons for subtitle nudge on/off in inject 2026-04-02 14:16:40 -04:00
joshpatra fad0c49e65 v5.1.3-beta.1 2026-04-02 13:56:22 -04:00
joshpatra 66075fb6f3 Bump version to 5.1.3 2026-04-02 13:56:21 -04:00
joshpatra bf4025dcb4 fix: settings update 2026-04-02 13:54:01 -04:00
joshpatra 76a7b933bb v5.1.2-beta.1 2026-04-02 13:52:04 -04:00
joshpatra 1cd533fc5c Bump version to 5.1.2 2026-04-02 13:52:02 -04:00
joshpatra 8c5bd68d39 fix: popup control bar section layout in options 2026-04-02 13:44:03 -04:00
joshpatra 9c257af446 feat: omit settings from popup control bar 2026-04-02 13:43:56 -04:00
joshpatra 64a9b85587 fix: control bar icon clicks, hover/focus-within, nudge action 2026-04-02 13:43:43 -04:00
joshpatra edd997037a v5.1.1-beta.1 2026-04-02 13:11:47 -04:00
joshpatra f85a1f9f29 Bump version to 5.1.1 2026-04-02 13:11:46 -04:00
joshpatra 97366b76b6 chore: open options in tab 2026-04-02 13:09:09 -04:00
joshpatra 8269875bb1 fix: removed divider 2026-04-02 13:01:14 -04:00
joshpatra e34ec17f33 v5.1.0-beta.1 2026-04-02 12:53:10 -04:00
joshpatra 8d3905b654 Bump version to 5.1.0 2026-04-02 12:53:09 -04:00
joshpatra 7fd8a931d8 deploy: squash beta→main for stable; beta script pushes dev then pulls 2026-04-02 12:52:27 -04:00
joshpatra 17319c1e25 Re-run site rules on DOM media attach; extract refreshAllControllerGeometry 2026-04-02 12:52:27 -04:00
joshpatra 841c1a246e fix: nudge flash layout, Lucide icons, hover bar spacing 2026-04-02 12:52:27 -04:00
joshpatra ed0f63e8bc feat: user-customizable Lucide controller button icons 2026-04-02 12:52:27 -04:00
joshpatra 53f66f1eeb v5.0.4-beta.1 2026-04-01 16:31:49 -04:00
joshpatra f106ab490a Bump version to 5.0.4 2026-04-01 16:31:48 -04:00
joshpatra 5a38121e09 refactor: scripts update 2026-04-01 16:31:29 -04:00
joshpatra 36ed922b5c Add interactive deploy scripts for beta and AMO stable releases 2026-04-01 16:29:19 -04:00
joshpatra 3275d1f322 v5.0.2-beta.1 2026-04-01 16:24:24 -04:00
joshpatra f6d706f096 chore: version bump, deployment update 2026-04-01 16:21:44 -04:00
joshpatra 04292a8018 refactor: update settings, feat: change reset speed indicator to show speed it changes to/from 2026-04-01 16:18:36 -04:00
28 changed files with 661 additions and 145 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?
+21
View File
@@ -1,3 +1,24 @@
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"
}
});
}
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) {
if (request.action === "openOptions") {
chrome.tabs.create({ url: chrome.runtime.getURL("options/options.html") });
@@ -8,6 +8,10 @@
if (!v) return null;
return {
speed: v.playbackRate,
frameToken:
typeof tc === "object" && typeof tc.frameToken === "string"
? tc.frameToken
: null,
preferred: !v.paused,
forceLastSavedSpeed: Boolean(
typeof tc === "object" && tc.settings && tc.settings.forceLastSavedSpeed
+75 -47
View File
@@ -156,7 +156,11 @@ var tc = {
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 = Number(keyBindingUtils.MIN_SPEED) || 0.1;
@@ -235,21 +239,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) {
@@ -2335,6 +2343,7 @@ function loadInitialRuntimeSettings(attempt) {
if (!videoGs) return false;
sendResponse({
speed: videoGs.playbackRate,
frameToken: tc.frameToken,
forceLastSavedSpeed: tc.settings.forceLastSavedSpeed === true,
forceLastSavedSpeedControlledBySiteRule: Boolean(
tc.activeSiteRule &&
@@ -2371,6 +2380,12 @@ function loadInitialRuntimeSettings(attempt) {
return false;
}
if (request.action === "run_action") {
if (
request.targetFrameToken &&
request.targetFrameToken !== tc.frameToken
) {
return false;
}
if (
!siteRuleUtils.isSpeederActiveForSite(
tc.settings.enabled,
@@ -2590,7 +2605,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] &&
@@ -3196,12 +3215,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);
@@ -3210,7 +3230,7 @@ function syncControllerFullscreenMount(videoController) {
if (!targetMount) return false;
if (fullscreenElement) {
if (ownsFullscreen) {
// Fullscreen elements and popovers both participate in the browser's top
// layer. Showing Speeder's host after the player enters fullscreen keeps it
// above provider-owned surfaces even when the provider clips descendants or
@@ -3861,11 +3881,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];
@@ -3949,21 +3972,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);
@@ -4361,6 +4369,32 @@ function inIframe() {
}
}
function isEditableShortcutTarget(event) {
var path =
event && typeof event.composedPath === "function"
? event.composedPath()
: [event && event.target];
return path.some(function(target) {
if (!target || target.nodeType !== 1) return false;
var nodeName = target.nodeName;
var role = target.getAttribute && target.getAttribute("role");
var contentEditable =
target.getAttribute && target.getAttribute("contenteditable");
return (
nodeName === "INPUT" ||
nodeName === "TEXTAREA" ||
nodeName === "SELECT" ||
target.isContentEditable ||
(contentEditable !== null && contentEditable !== "false") ||
role === "textbox" ||
role === "searchbox" ||
role === "combobox" ||
role === "spinbutton"
);
});
}
function attachKeydownListeners(doc) {
// Content scripts already run in every frame. Keeping each listener scoped
// to its own frame avoids duplicate shortcuts and stale iframe ownership.
@@ -4384,13 +4418,7 @@ function attachKeydownListeners(doc) {
return;
}
if (
event.target.nodeName === "INPUT" ||
event.target.nodeName === "TEXTAREA" ||
event.target.isContentEditable
) {
return;
}
if (isEditableShortcutTarget(event)) return;
if (
!siteRuleUtils.isSpeederActiveForSite(
+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.5",
"version": "6.0.6.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",
+5 -4
View File
@@ -215,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();
+14 -4
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;
}
@@ -624,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 {
@@ -1239,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;
+12 -12
View File
@@ -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>
@@ -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" />
@@ -1037,11 +1037,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>
+136 -20
View File
@@ -672,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;
@@ -711,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);
@@ -793,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";
@@ -1145,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";
@@ -1201,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);
@@ -1377,6 +1413,7 @@ function createSiteRule(rule) {
}
applySiteRuleOverrideState(ruleEl, "override-shortcuts", "site-shortcuts-container");
refreshSiteRuleAddShortcutSelector(ruleEl);
associateSiteRuleLabels(ruleEl);
document.getElementById("siteRulesContainer").appendChild(ruleEl);
}
@@ -1385,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;
@@ -1408,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);
@@ -1430,6 +1487,8 @@ function populateControlBarZones(activeZone, availableZone, activeIds, allowButt
if (block) availableZone.appendChild(block);
}
});
updateControlBarBlockLabels(activeZone.closest(".cb-editor"));
}
function readControlBarOrder(activeZone) {
@@ -1473,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) {
@@ -1500,6 +1559,8 @@ function getDragAfterElement(container, x, y) {
}
function initControlBarEditor() {
if (document.vscControlBarEditorInitialized) return;
document.vscControlBarEditorInitialized = true;
var draggedBlock = null;
function clearControlBarDropTargets(activeZone) {
@@ -1512,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);
@@ -1557,11 +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;
@@ -1633,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");
}
@@ -1654,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)
@@ -1719,7 +1826,7 @@ function initLucideButtonIconsUI() {
slug +
" for " +
action +
". Reload pages for the hover bar."
". Open pages update automatically."
);
});
})
@@ -1902,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);
@@ -1981,6 +2095,8 @@ document.addEventListener("DOMContentLoaded", function () {
versionElement.textContent = manifest.version;
}
document.querySelectorAll("#customs .shortcut-row").forEach(labelShortcutRow);
restore_options();
initControlBarEditor();
+10 -8
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,8 +84,8 @@ button:active {
background: #f1f3f5;
}
button:focus-visible {
outline: 2px solid rgba(17, 24, 39, 0.14);
button:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
@@ -250,8 +251,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 +296,9 @@ button:focus-visible {
--border: #2b3138;
--border-strong: #3a414a;
--text: #f2f4f6;
--muted: #a0a8b2;
--accent: #f2f4f6;
--muted: #a0a8b2;
--accent: #f2f4f6;
--focus-ring: #93c5fd;
}
body {
+6 -1
View File
@@ -32,7 +32,12 @@
<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">
+18 -11
View File
@@ -27,6 +27,8 @@ document.addEventListener("DOMContentLoaded", function () {
var popupExcludedButtonIds = new Set(["settings"]);
var renderToken = 0;
var forceLastSavedSpeedControlledBySiteRule = null;
var selectedFrameToken = null;
var shortcutTargetMode = "closest";
function persistExpandedSettings(rawStorage, settings, callback) {
var mutation = vscBuildManagedStorageMutation(rawStorage, settings);
@@ -162,6 +164,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 +247,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();
}
@@ -372,6 +383,7 @@ document.addEventListener("DOMContentLoaded", function () {
function renderForActiveTab() {
var currentRenderToken = ++renderToken;
forceLastSavedSpeedControlledBySiteRule = null;
selectedFrameToken = null;
setForceButtonLoading(true);
chrome.storage.local.get(["customButtonIcons"], function (loc) {
@@ -395,6 +407,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
@@ -507,20 +523,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.`
);
}
+3
View File
@@ -68,6 +68,9 @@
function normalizeResult(result) {
var normalized = { speed: result.speed };
if (typeof result.frameToken === "string") {
normalized.frameToken = result.frameToken;
}
if (typeof result.forceLastSavedSpeed === "boolean") {
normalized.forceLastSavedSpeed = result.forceLastSavedSpeed;
}
+3
View File
@@ -985,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
+36
View File
@@ -0,0 +1,36 @@
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"
}
});
});
});
+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) {
+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();
+95
View File
@@ -309,6 +309,26 @@ 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("drops a stale hover-preview shortcut target after SPA navigation", async () => {
bootInject({
url: "https://www.youtube.com/",
@@ -394,6 +414,30 @@ describe("inject.js media/controller lifecycle regressions", () => {
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({
@@ -612,6 +656,42 @@ describe("inject.js media/controller lifecycle regressions", () => {
controller.controllerHostCleanup();
});
it("only promotes the controller owned by the fullscreen player", async () => {
bootInject();
await settleLifecycle();
const fullscreenPlayer = document.createElement("div");
const fullscreenVideo = document.createElement("video");
const otherVideo = document.createElement("video");
const rect = makeRect(0, 0, 640, 360);
fullscreenVideo.src = "https://example.org/fullscreen.mp4";
otherVideo.src = "https://example.org/other.mp4";
fullscreenPlayer.appendChild(fullscreenVideo);
document.body.append(fullscreenPlayer, otherVideo);
[fullscreenPlayer, fullscreenVideo, otherVideo].forEach((element) => {
setRect(element, rect);
setBoxMetrics(element, rect.width, rect.height);
});
window.ensureController(fullscreenVideo, fullscreenPlayer);
window.ensureController(otherVideo, document.body);
fullscreenVideo.vsc.div.showPopover = vi.fn();
otherVideo.vsc.div.showPopover = vi.fn();
Object.defineProperty(document, "fullscreenElement", {
configurable: true,
value: fullscreenPlayer
});
window.syncControllerFullscreenMount(fullscreenVideo.vsc);
window.syncControllerFullscreenMount(otherVideo.vsc);
expect(fullscreenVideo.vsc.div.showPopover).toHaveBeenCalledOnce();
expect(otherVideo.vsc.div.showPopover).not.toHaveBeenCalled();
expect(
otherVideo.vsc.div.classList.contains("vsc-fullscreen-popover")
).toBe(false);
});
it("preserves direct-video requestFullscreen semantics and overlays with a popover", async () => {
bootInject();
await settleLifecycle();
@@ -816,6 +896,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({
+65 -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();
@@ -344,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);
});
});
+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 () => {
+26 -9
View File
@@ -110,7 +110,7 @@ describe("popup.js", () => {
speedQueryCount <= 2
? [
{ 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 +138,35 @@ 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("toggles enablement and closes after a successful refresh", async () => {
const chrome = bootPopup({
syncData: {
@@ -162,13 +185,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");
+16
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"