mirror of
https://github.com/SoPat712/Speeder.git
synced 2026-08-19 11:52:31 -04:00
chore(release): merge v6.0.6.0 beta
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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’s
|
||||
“Rescan page for videos” action after a player loads
|
||||
dynamically. Browser-internal pages and players that do not expose
|
||||
HTML5 media to extensions cannot be controlled.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
+136
-20
@@ -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();
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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.`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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("/");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -27,4 +27,5 @@ export function applyJSDOMWindow(win) {
|
||||
|
||||
win.open = vi.fn();
|
||||
win.close = vi.fn();
|
||||
win.confirm = vi.fn(() => true);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user