chore(release): merge v6.0.6.0 beta

This commit is contained in:
2026-08-02 00:05:56 -04:00
27 changed files with 657 additions and 142 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.4.1",
"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);
}
+12 -1
View File
@@ -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"