mirror of
https://github.com/SoPat712/Speeder.git
synced 2026-08-20 12:06:19 -04:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8cfbf05c8
|
||
|
|
b4234e0387
|
||
|
|
d55006dd0a
|
||
|
|
745af2f1b9
|
||
|
|
7e9ce436d3
|
||
|
|
98546adf41
|
||
|
|
daeff63d2a
|
||
|
|
aa4f1d15e7
|
||
|
|
1d9f6f7e13
|
||
|
|
0f64fd5ab1
|
||
|
|
e555c4c116
|
||
|
|
91d530e29b
|
||
|
|
34f2561b50
|
||
|
|
fd29883d84
|
||
|
|
e083fdcafe
|
||
|
|
2fa7d8963a
|
||
|
|
e4edf10440
|
||
|
|
d5c2d0e343
|
||
|
|
5cd559b883
|
||
|
|
0c9a6a1a1b
|
||
|
|
3ee970a54b
|
||
|
|
e12375f2d2
|
||
|
|
8d7d31a4db
|
||
|
|
ed21281fa3
|
||
|
|
39b7ba27fb
|
||
|
|
ee346ee9d8
|
||
|
|
bff65230bb
|
||
|
|
0afb56abe0
|
||
|
|
a02b7b57f7
|
||
|
|
9b6d1ccd3c
|
||
|
|
a2d7f0d9d6
|
||
|
|
04f1414118
|
||
|
|
dad223d4ad
|
@@ -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,5 +1,67 @@
|
||||
chrome.runtime.onMessage.addListener(function (request) {
|
||||
if (request.action === "openOptions") {
|
||||
chrome.tabs.create({ url: chrome.runtime.getURL("options/options.html") });
|
||||
function setToolbarIcon(enabled) {
|
||||
var suffix = enabled === false ? "_disabled" : "";
|
||||
chrome.browserAction.setIcon({
|
||||
path: {
|
||||
19: "assets/icons/icon19" + suffix + ".png",
|
||||
38: "assets/icons/icon38" + suffix + ".png",
|
||||
48: "assets/icons/icon48" + suffix + ".png"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var pausedTabIds = new Set();
|
||||
|
||||
function getMessageTabId(request, sender) {
|
||||
if (request && Number.isInteger(request.tabId)) return request.tabId;
|
||||
return sender && sender.tab && Number.isInteger(sender.tab.id)
|
||||
? sender.tab.id
|
||||
: null;
|
||||
}
|
||||
|
||||
chrome.storage.sync.get(["enabled"], function(storage) {
|
||||
if (!chrome.runtime.lastError) setToolbarIcon(storage.enabled !== false);
|
||||
});
|
||||
|
||||
chrome.storage.onChanged.addListener(function(changes, areaName) {
|
||||
if (areaName === "sync" && changes.enabled) {
|
||||
setToolbarIcon(changes.enabled.newValue !== false);
|
||||
}
|
||||
});
|
||||
|
||||
chrome.runtime.onMessage.addListener(function (request, sender, sendResponse) {
|
||||
if (request.action === "openOptions") {
|
||||
chrome.tabs.create({ url: chrome.runtime.getURL("options/options.html") });
|
||||
return false;
|
||||
}
|
||||
if (request.action === "get_tab_pause_state") {
|
||||
var queriedTabId = getMessageTabId(request, sender);
|
||||
sendResponse({
|
||||
paused: queriedTabId !== null && pausedTabIds.has(queriedTabId)
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if (request.action === "set_tab_paused") {
|
||||
var tabId = getMessageTabId(request, sender);
|
||||
if (tabId === null) {
|
||||
sendResponse({ paused: false });
|
||||
return false;
|
||||
}
|
||||
var paused = request.paused === true;
|
||||
if (paused) pausedTabIds.add(tabId);
|
||||
else pausedTabIds.delete(tabId);
|
||||
chrome.tabs.sendMessage(
|
||||
tabId,
|
||||
{ action: "set_tab_paused", paused: paused },
|
||||
function () {
|
||||
void chrome.runtime.lastError;
|
||||
}
|
||||
);
|
||||
sendResponse({ paused: paused });
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
chrome.tabs.onRemoved.addListener(function(tabId) {
|
||||
pausedTabIds.delete(tabId);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,14 @@
|
||||
if (!v) return null;
|
||||
return {
|
||||
speed: v.playbackRate,
|
||||
frameToken:
|
||||
typeof tc === "object" && typeof tc.frameToken === "string"
|
||||
? tc.frameToken
|
||||
: null,
|
||||
diagnostics:
|
||||
typeof getDiagnosticsSnapshot === "function"
|
||||
? getDiagnosticsSnapshot(v)
|
||||
: null,
|
||||
preferred: !v.paused,
|
||||
forceLastSavedSpeed: Boolean(
|
||||
typeof tc === "object" && tc.settings && tc.settings.forceLastSavedSpeed
|
||||
|
||||
+238
-81
@@ -90,6 +90,81 @@ function getPrimaryVideoElement(mediaElements) {
|
||||
return best;
|
||||
}
|
||||
|
||||
function getDiagnosticsSnapshot(media) {
|
||||
var primary = media || getPrimaryVideoElement();
|
||||
if (!primary) return null;
|
||||
|
||||
var controller = primary.vsc || null;
|
||||
var wrapper = controller && controller.div;
|
||||
var fullscreenElement = getFullscreenElement(primary.ownerDocument);
|
||||
var rect = null;
|
||||
try {
|
||||
rect = primary.getBoundingClientRect();
|
||||
} catch (_error) {}
|
||||
|
||||
return {
|
||||
mediaCount: tc.mediaElements.filter(function(item) {
|
||||
return item && item.isConnected;
|
||||
}).length,
|
||||
mediaType: String(primary.nodeName || "media").toLowerCase(),
|
||||
playbackRate: Number(primary.playbackRate),
|
||||
paused: primary.paused === true,
|
||||
ended: primary.ended === true,
|
||||
readyState: Number(primary.readyState) || 0,
|
||||
muted: primary.muted === true,
|
||||
volume: Number(primary.volume),
|
||||
dimensions: rect
|
||||
? [Math.round(Number(rect.width) || 0), Math.round(Number(rect.height) || 0)]
|
||||
: [0, 0],
|
||||
fullscreen: {
|
||||
active: Boolean(fullscreenElement),
|
||||
element: fullscreenElement
|
||||
? String(fullscreenElement.nodeName || "element").toLowerCase()
|
||||
: null,
|
||||
ownsMedia: Boolean(
|
||||
fullscreenElement &&
|
||||
(fullscreenElement === primary ||
|
||||
isComposedDescendant(primary, fullscreenElement))
|
||||
)
|
||||
},
|
||||
controller: {
|
||||
present: Boolean(controller && wrapper),
|
||||
connected: Boolean(wrapper && wrapper.isConnected),
|
||||
hidden: Boolean(wrapper && wrapper.classList.contains("vsc-hidden")),
|
||||
geometryHidden: Boolean(
|
||||
wrapper && wrapper.classList.contains("vsc-geometry-hidden")
|
||||
),
|
||||
fullscreenPopover: Boolean(
|
||||
wrapper && wrapper.classList.contains("vsc-fullscreen-popover")
|
||||
),
|
||||
location: controller ? controller.controllerLocation : null
|
||||
},
|
||||
effectiveSettings: {
|
||||
enabled: tc.settings.enabled !== false,
|
||||
tabPaused: tc.tabPaused === true,
|
||||
startHidden: tc.settings.startHidden === true,
|
||||
hideWithControls: tc.settings.hideWithControls === true,
|
||||
rememberSpeed: tc.settings.rememberSpeed === true,
|
||||
forceLastSavedSpeed: tc.settings.forceLastSavedSpeed === true,
|
||||
shortcutTargetMode: tc.settings.shortcutTargetMode
|
||||
},
|
||||
siteRule: {
|
||||
matched: Boolean(tc.activeSiteRule),
|
||||
disabled: Boolean(
|
||||
tc.activeSiteRule &&
|
||||
siteRuleUtils.isSiteRuleDisabled(tc.activeSiteRule)
|
||||
),
|
||||
overrideKeys: tc.activeSiteRule
|
||||
? Object.keys(tc.activeSiteRule)
|
||||
.filter(function(key) {
|
||||
return key !== "pattern" && key !== "title";
|
||||
})
|
||||
.sort()
|
||||
: []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
var tc = {
|
||||
settings: {
|
||||
lastSpeed: getSharedDefault("lastSpeed", 1.0),
|
||||
@@ -151,16 +226,44 @@ var tc = {
|
||||
speedAccessTimes: {},
|
||||
persistedLastSpeed: 1.0,
|
||||
activeSiteRule: null,
|
||||
tabPaused: false,
|
||||
siteRuleBase: null,
|
||||
runtimeSettingsHydrated: false,
|
||||
pendingMediaCandidates: [],
|
||||
settingsReloadRetries: 0,
|
||||
lastPointerPosition: null,
|
||||
lastInteractedMedia: null
|
||||
lastInteractedMedia: null,
|
||||
frameToken:
|
||||
window.crypto && typeof window.crypto.randomUUID === "function"
|
||||
? window.crypto.randomUUID()
|
||||
: String(Date.now()) + "-" + Math.random().toString(36).slice(2)
|
||||
};
|
||||
|
||||
var MIN_SPEED = 0.0625;
|
||||
var MAX_SPEED = 16;
|
||||
function isSpeederActiveForCurrentPage() {
|
||||
return (
|
||||
tc.tabPaused !== true &&
|
||||
siteRuleUtils.isSpeederActiveForSite(
|
||||
tc.settings.enabled,
|
||||
tc.activeSiteRule
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function applyTabPausedState(paused) {
|
||||
tc.tabPaused = paused === true;
|
||||
if (!tc.runtimeSettingsHydrated) return;
|
||||
if (tc.tabPaused) {
|
||||
clearAllSpeedRestoreEnforcement();
|
||||
tc.mediaElements.slice().forEach(function(media) {
|
||||
removeController(media);
|
||||
});
|
||||
return;
|
||||
}
|
||||
initializeWhenReady(document, true);
|
||||
}
|
||||
|
||||
var MIN_SPEED = Number(keyBindingUtils.MIN_SPEED) || 0.1;
|
||||
var MAX_SPEED = Number(keyBindingUtils.MAX_SPEED) || 16;
|
||||
var YT_NATIVE_MIN = 0.25;
|
||||
var YT_NATIVE_MAX = 2.0;
|
||||
var YT_NATIVE_STEP = 0.05;
|
||||
@@ -235,21 +338,25 @@ var controllerLocationStyles = {
|
||||
|
||||
/* `label` fallback only when ui-icons has no path for the action. */
|
||||
var controllerButtonDefs = {
|
||||
rewind: { label: "", className: "rw" },
|
||||
slower: { label: "", className: "" },
|
||||
faster: { label: "", className: "" },
|
||||
advance: { label: "", className: "rw" },
|
||||
display: { label: "", className: "hideButton" },
|
||||
reset: { label: "\u21BB", className: "" },
|
||||
fast: { label: "", className: "" },
|
||||
nudge: { label: "", className: "" },
|
||||
pause: { label: "", className: "" },
|
||||
muted: { label: "", className: "" },
|
||||
louder: { label: "", className: "" },
|
||||
softer: { label: "", className: "" },
|
||||
mark: { label: "", className: "" },
|
||||
jump: { label: "", className: "" },
|
||||
settings: { label: "", className: "" }
|
||||
rewind: { label: "", name: "Rewind", className: "rw" },
|
||||
slower: { label: "", name: "Decrease speed", className: "" },
|
||||
faster: { label: "", name: "Increase speed", className: "" },
|
||||
advance: { label: "", name: "Advance", className: "rw" },
|
||||
display: {
|
||||
label: "",
|
||||
name: "Show or hide controller",
|
||||
className: "hideButton"
|
||||
},
|
||||
reset: { label: "\u21BB", name: "Reset speed", className: "" },
|
||||
fast: { label: "", name: "Toggle preferred speed", className: "" },
|
||||
nudge: { label: "", name: "Toggle subtitle nudge", className: "" },
|
||||
pause: { label: "", name: "Play or pause", className: "" },
|
||||
muted: { label: "", name: "Mute or unmute", className: "" },
|
||||
louder: { label: "", name: "Increase volume", className: "" },
|
||||
softer: { label: "", name: "Decrease volume", className: "" },
|
||||
mark: { label: "", name: "Mark position", className: "" },
|
||||
jump: { label: "", name: "Jump to marked position", className: "" },
|
||||
settings: { label: "", name: "Open Speeder settings", className: "" }
|
||||
};
|
||||
|
||||
function createDefaultBinding(action, code, value) {
|
||||
@@ -652,6 +759,7 @@ function normalizeStoredBinding(binding, fallbackCode) {
|
||||
var normalized = {
|
||||
action: binding.action,
|
||||
code: normalizedCode,
|
||||
shiftKey: binding.shiftKey === true,
|
||||
disabled: false,
|
||||
value: keyBindingUtils.sanitizeActionValue(
|
||||
binding.action,
|
||||
@@ -1360,7 +1468,8 @@ function matchesKeyBinding(binding, event) {
|
||||
binding.disabled !== true &&
|
||||
typeof binding.code === "string" &&
|
||||
binding.code.length > 0 &&
|
||||
binding.code === event.code
|
||||
binding.code === event.code &&
|
||||
(binding.shiftKey === true) === event.shiftKey
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1506,7 +1615,7 @@ function ensureController(node, parent) {
|
||||
// href selects site rules; re-run on every new/usable media so all runtime
|
||||
// paths agree on activation and effective settings.
|
||||
applySiteRuleOverrides();
|
||||
if (!siteRuleUtils.isSpeederActiveForSite(tc.settings.enabled, tc.activeSiteRule)) {
|
||||
if (!isSpeederActiveForCurrentPage()) {
|
||||
if (node.vsc) removeController(node);
|
||||
return null;
|
||||
}
|
||||
@@ -2314,12 +2423,24 @@ function loadInitialRuntimeSettings(attempt) {
|
||||
return;
|
||||
}
|
||||
hydrateRuntimeSettings(rawStorage || {});
|
||||
if (chrome.runtime && typeof chrome.runtime.sendMessage === "function") {
|
||||
chrome.runtime.sendMessage({ action: "get_tab_pause_state" }, function(response) {
|
||||
if (!chrome.runtime.lastError && response) {
|
||||
applyTabPausedState(response.paused === true);
|
||||
}
|
||||
});
|
||||
}
|
||||
// patchAttachShadow() is now called at top-level before this callback
|
||||
// Add a listener for messages from the popup.
|
||||
// We use a global flag to ensure the listener is only attached once.
|
||||
if (!window.vscMessageListener) {
|
||||
chrome.runtime.onMessage.addListener(
|
||||
function(request, sender, sendResponse) {
|
||||
if (request.action === "set_tab_paused") {
|
||||
applyTabPausedState(request.paused === true);
|
||||
sendResponse({ paused: tc.tabPaused });
|
||||
return false;
|
||||
}
|
||||
if (request.action === "rescan_page") {
|
||||
log("Re-scan command received from popup.", 4);
|
||||
initializeWhenReady(document, true);
|
||||
@@ -2333,6 +2454,8 @@ function loadInitialRuntimeSettings(attempt) {
|
||||
if (!videoGs) return false;
|
||||
sendResponse({
|
||||
speed: videoGs.playbackRate,
|
||||
frameToken: tc.frameToken,
|
||||
diagnostics: getDiagnosticsSnapshot(videoGs),
|
||||
forceLastSavedSpeed: tc.settings.forceLastSavedSpeed === true,
|
||||
forceLastSavedSpeedControlledBySiteRule: Boolean(
|
||||
tc.activeSiteRule &&
|
||||
@@ -2370,10 +2493,13 @@ function loadInitialRuntimeSettings(attempt) {
|
||||
}
|
||||
if (request.action === "run_action") {
|
||||
if (
|
||||
!siteRuleUtils.isSpeederActiveForSite(
|
||||
tc.settings.enabled,
|
||||
tc.activeSiteRule
|
||||
)
|
||||
request.targetFrameToken &&
|
||||
request.targetFrameToken !== tc.frameToken
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!isSpeederActiveForCurrentPage()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -2564,6 +2690,9 @@ function loadInitialRuntimeSettings(attempt) {
|
||||
});
|
||||
}
|
||||
|
||||
// Install before async settings hydration so SPA-owned window capture handlers
|
||||
// cannot hide later key events from Speeder.
|
||||
attachKeydownListeners(document);
|
||||
loadInitialRuntimeSettings(0);
|
||||
|
||||
function getKeyBindings(action, what = "value") {
|
||||
@@ -2585,7 +2714,11 @@ function setKeyBindings(action, value) {
|
||||
|
||||
function createControllerButton(doc, action, label, className) {
|
||||
var button = doc.createElement("button");
|
||||
var name = controllerButtonDefs[action] && controllerButtonDefs[action].name;
|
||||
button.type = "button";
|
||||
button.dataset.action = action;
|
||||
button.setAttribute("aria-label", name || action);
|
||||
button.title = name || action;
|
||||
var custom =
|
||||
tc.settings.customButtonIcons &&
|
||||
tc.settings.customButtonIcons[action] &&
|
||||
@@ -3144,14 +3277,14 @@ function disableDirectFullscreenPopover(videoController) {
|
||||
wrapper.removeAttribute("popover");
|
||||
}
|
||||
|
||||
function enableFullscreenPopover(videoController, preferredMount) {
|
||||
function enableDirectFullscreenPopover(videoController) {
|
||||
if (!videoController || !videoController.video || !videoController.div) {
|
||||
return false;
|
||||
}
|
||||
var wrapper = videoController.div;
|
||||
if (typeof wrapper.showPopover !== "function") return false;
|
||||
|
||||
var normalMount = preferredMount || videoController.normalControllerMount;
|
||||
var normalMount = videoController.normalControllerMount;
|
||||
var normalMountIsConnected = Boolean(
|
||||
normalMount &&
|
||||
(normalMount.isConnected ||
|
||||
@@ -3191,12 +3324,13 @@ function syncControllerFullscreenMount(videoController) {
|
||||
var doc = video.ownerDocument;
|
||||
var fullscreenElement = getFullscreenElement(doc);
|
||||
var targetMount = videoController.normalControllerMount;
|
||||
|
||||
if (
|
||||
var ownsFullscreen = Boolean(
|
||||
fullscreenElement &&
|
||||
(fullscreenElement === video ||
|
||||
isComposedDescendant(video, fullscreenElement))
|
||||
) {
|
||||
(fullscreenElement === video ||
|
||||
isComposedDescendant(video, fullscreenElement))
|
||||
);
|
||||
|
||||
if (ownsFullscreen) {
|
||||
targetMount = getControllerMount(video, fullscreenElement);
|
||||
} else if (!fullscreenElement && (!targetMount || !targetMount.isConnected)) {
|
||||
targetMount = getControllerMount(video);
|
||||
@@ -3205,13 +3339,10 @@ function syncControllerFullscreenMount(videoController) {
|
||||
|
||||
if (!targetMount) return false;
|
||||
|
||||
if (fullscreenElement) {
|
||||
// Fullscreen elements and popovers both participate in the browser's top
|
||||
// layer. Showing Speeder's host after the player enters fullscreen keeps it
|
||||
// above provider-owned surfaces even when the provider clips descendants or
|
||||
// creates a new fullscreen stacking context (notably Firefox + YouTube).
|
||||
// Browsers without the Popover API retain the player-local remount fallback.
|
||||
if (enableFullscreenPopover(videoController, targetMount)) return true;
|
||||
if (fullscreenElement === video) {
|
||||
// A replaced <video> cannot paint author children, so direct-media
|
||||
// fullscreen is the only case that needs a separate top-layer popover.
|
||||
if (enableDirectFullscreenPopover(videoController)) return true;
|
||||
} else {
|
||||
disableDirectFullscreenPopover(videoController);
|
||||
}
|
||||
@@ -3752,7 +3883,7 @@ function defineVideoController() {
|
||||
// Only hide if the video is not paused
|
||||
// (Many players keep controls visible while paused)
|
||||
// However, the user said "Reveal on every mouse and keyboard input"
|
||||
// and "auto-hidden after timespan".
|
||||
// and "auto-hidden after timespan".
|
||||
// We'll follow the timer strictly.
|
||||
wrapper.classList.add("vsc-idle-hidden");
|
||||
log("Generic hide: controller hidden due to inactivity", 5);
|
||||
@@ -3856,11 +3987,14 @@ function defineVideoController() {
|
||||
|
||||
buttonConfig.forEach(function(btnId) {
|
||||
if (btnId === "nudge") {
|
||||
subtitleNudgeIndicator = doc.createElement("span");
|
||||
subtitleNudgeIndicator = createControllerButton(
|
||||
doc,
|
||||
btnId,
|
||||
controllerButtonDefs.nudge.label,
|
||||
controllerButtonDefs.nudge.className
|
||||
);
|
||||
subtitleNudgeIndicator.id = "nudge-indicator";
|
||||
subtitleNudgeIndicator.setAttribute("role", "button");
|
||||
subtitleNudgeIndicator.setAttribute("aria-live", "polite");
|
||||
subtitleNudgeIndicator.setAttribute("tabindex", "0");
|
||||
controls.appendChild(subtitleNudgeIndicator);
|
||||
} else {
|
||||
var def = controllerButtonDefs[btnId];
|
||||
@@ -3944,21 +4078,6 @@ function defineVideoController() {
|
||||
true
|
||||
);
|
||||
});
|
||||
if (subtitleNudgeIndicator) {
|
||||
subtitleNudgeIndicator.addEventListener(
|
||||
"click",
|
||||
(e) => {
|
||||
var video = this.video;
|
||||
if (video) {
|
||||
var newState = !isSubtitleNudgeEnabledForVideo(video);
|
||||
setSubtitleNudgeEnabledForVideo(video, newState);
|
||||
}
|
||||
blurAfterPointerTap(subtitleNudgeIndicator, e);
|
||||
e.stopPropagation();
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
controller.addEventListener("click", (e) => e.stopPropagation(), false);
|
||||
controller.addEventListener("mousedown", (e) => e.stopPropagation(), false);
|
||||
|
||||
@@ -4150,7 +4269,7 @@ function refreshAllControllerGeometry() {
|
||||
/** Re-match site rules for current URL and refresh controller position/opacity on every video. */
|
||||
function reapplySiteRulesAndControllerGeometry() {
|
||||
applySiteRuleOverrides();
|
||||
if (!siteRuleUtils.isSpeederActiveForSite(tc.settings.enabled, tc.activeSiteRule)) {
|
||||
if (!isSpeederActiveForCurrentPage()) {
|
||||
tc.mediaElements.slice().forEach(function(video) {
|
||||
removeController(video);
|
||||
});
|
||||
@@ -4208,7 +4327,7 @@ function setupListener(root) {
|
||||
if (config.skipResetDisarm !== true) {
|
||||
video.vsc.resetToggleArmed = false;
|
||||
}
|
||||
var speed = video.playbackRate; // Preserve full precision (e.g. 0.0625)
|
||||
var speed = video.playbackRate; // Preserve full precision (e.g. 0.01)
|
||||
video.vsc.speedIndicator.textContent = speed.toFixed(2);
|
||||
video.vsc.targetSpeed = speed;
|
||||
video.vsc.targetSpeedSourceKey = getVideoSourceKey(video);
|
||||
@@ -4240,7 +4359,7 @@ function setupListener(root) {
|
||||
if (!video || typeof video.playbackRate === "undefined" || !video.vsc)
|
||||
return;
|
||||
if (shouldIgnoreSuppressedRateChange(video)) return;
|
||||
var currentSpeed = video.playbackRate; // Preserve full precision (e.g. 0.0625)
|
||||
var currentSpeed = video.playbackRate; // Preserve full precision (e.g. 0.01)
|
||||
var pendingRateChange = takePendingRateChange(video, currentSpeed);
|
||||
if (tc.settings.forceLastSavedSpeed) {
|
||||
if (pendingRateChange) {
|
||||
@@ -4309,7 +4428,11 @@ function clearPendingInitialization(doc) {
|
||||
|
||||
function tryInitializeDocument(doc, forceReinit) {
|
||||
if (!doc) return false;
|
||||
if ((!forceReinit && vscInitializedDocuments.has(doc)) || !doc.body) {
|
||||
if (
|
||||
!tc.runtimeSettingsHydrated ||
|
||||
(!forceReinit && vscInitializedDocuments.has(doc)) ||
|
||||
!doc.body
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -4352,14 +4475,41 @@ function inIframe() {
|
||||
}
|
||||
}
|
||||
|
||||
function isEditableShortcutTarget(event) {
|
||||
var path =
|
||||
event && typeof event.composedPath === "function"
|
||||
? event.composedPath()
|
||||
: [event && event.target];
|
||||
|
||||
return path.some(function(target) {
|
||||
if (!target || target.nodeType !== 1) return false;
|
||||
var nodeName = target.nodeName;
|
||||
var role = target.getAttribute && target.getAttribute("role");
|
||||
var contentEditable =
|
||||
target.getAttribute && target.getAttribute("contenteditable");
|
||||
return (
|
||||
nodeName === "INPUT" ||
|
||||
nodeName === "TEXTAREA" ||
|
||||
nodeName === "SELECT" ||
|
||||
target.isContentEditable ||
|
||||
(contentEditable !== null && contentEditable !== "false") ||
|
||||
role === "textbox" ||
|
||||
role === "searchbox" ||
|
||||
role === "combobox" ||
|
||||
role === "spinbutton"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function attachKeydownListeners(doc) {
|
||||
// Content scripts already run in every frame. Keeping each listener scoped
|
||||
// to its own document avoids duplicate shortcuts and stale iframe ownership.
|
||||
// to its own frame avoids duplicate shortcuts and stale iframe ownership.
|
||||
var docs = [doc];
|
||||
|
||||
docs.forEach(function(keyDoc) {
|
||||
if (keyDoc.vscKeydownListenerAttached) return;
|
||||
keyDoc.addEventListener(
|
||||
var keyTarget = keyDoc.defaultView || keyDoc;
|
||||
if (keyTarget.vscKeydownListenerAttached) return;
|
||||
keyTarget.addEventListener(
|
||||
"keydown",
|
||||
function(event) {
|
||||
if (
|
||||
@@ -4374,19 +4524,10 @@ function attachKeydownListeners(doc) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event.target.nodeName === "INPUT" ||
|
||||
event.target.nodeName === "TEXTAREA" ||
|
||||
event.target.isContentEditable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (isEditableShortcutTarget(event)) return;
|
||||
|
||||
if (
|
||||
!siteRuleUtils.isSpeederActiveForSite(
|
||||
tc.settings.enabled,
|
||||
tc.activeSiteRule
|
||||
)
|
||||
!isSpeederActiveForCurrentPage()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -4411,7 +4552,7 @@ function attachKeydownListeners(doc) {
|
||||
},
|
||||
true
|
||||
);
|
||||
keyDoc.vscKeydownListenerAttached = true;
|
||||
keyTarget.vscKeydownListenerAttached = true;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4557,7 +4698,16 @@ function attachMediaDetectionListeners(root) {
|
||||
}
|
||||
|
||||
function scheduleNavigationRescan() {
|
||||
window.vscLastObservedHref = location.href;
|
||||
var nextHref = location.href;
|
||||
if (window.vscLastObservedHref !== nextHref) {
|
||||
// Pointer and controller interaction state belongs to the previous route.
|
||||
// YouTube keeps hover-preview and Shorts players connected across SPA
|
||||
// navigations, so retaining either target can send shortcuts to an
|
||||
// off-screen/stale player even though the new watch controller works.
|
||||
tc.lastPointerPosition = null;
|
||||
tc.lastInteractedMedia = null;
|
||||
}
|
||||
window.vscLastObservedHref = nextHref;
|
||||
clearTimeout(window.vscNavigationRescanTimer);
|
||||
window.vscNavigationRescanTimer = setTimeout(function() {
|
||||
initializeWhenReady(document, true);
|
||||
@@ -4602,10 +4752,7 @@ function initializeNow(doc, forceReinit = false) {
|
||||
attachNavigationListeners();
|
||||
if (typeof tc.videoController === "undefined") defineVideoController();
|
||||
applySiteRuleOverrides();
|
||||
var isActive = siteRuleUtils.isSpeederActiveForSite(
|
||||
tc.settings.enabled,
|
||||
tc.activeSiteRule
|
||||
);
|
||||
var isActive = isSpeederActiveForCurrentPage();
|
||||
|
||||
// Keep observing while inactive so dynamically-created media/shadow roots
|
||||
// are available to the next forced SPA rescan, but remove stale controls.
|
||||
@@ -4807,6 +4954,16 @@ function resolveActionMediaTargets(event, specificVideo) {
|
||||
|
||||
if (tc.settings.shortcutTargetMode === "all") return candidates;
|
||||
|
||||
if (
|
||||
isOnYouTube() &&
|
||||
/^\/(watch|live)(\/|$)/.test(location.pathname)
|
||||
) {
|
||||
var youtubeMain = candidates.find(function(video) {
|
||||
return video.closest && video.closest("#movie_player");
|
||||
});
|
||||
if (youtubeMain) return [youtubeMain];
|
||||
}
|
||||
|
||||
var pointer =
|
||||
tc.lastPointerPosition && tc.lastPointerPosition.document === docContext
|
||||
? tc.lastPointerPosition
|
||||
|
||||
@@ -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.1",
|
||||
"version": "6.0.8.0",
|
||||
"manifest_version": 2,
|
||||
"description": "Speed up, slow down, advance and rewind HTML5 audio/video with shortcuts (New and improved version of \"Video Speed Controller\")",
|
||||
"homepage_url": "https://github.com/SoPat712/speeder",
|
||||
|
||||
@@ -54,11 +54,7 @@ function normalizedSettingsForExport(rawStorage) {
|
||||
// lastSpeed is useful user data, but it is intentionally outside the
|
||||
// managed options diff because content scripts update it at runtime.
|
||||
if (Object.prototype.hasOwnProperty.call(raw, "lastSpeed")) {
|
||||
if (
|
||||
Number.isFinite(expanded.lastSpeed) &&
|
||||
expanded.lastSpeed >= 0.0625 &&
|
||||
expanded.lastSpeed <= 16
|
||||
) {
|
||||
if (Number.isFinite(expanded.lastSpeed)) {
|
||||
normalized.lastSpeed = expanded.lastSpeed;
|
||||
}
|
||||
}
|
||||
@@ -117,11 +113,7 @@ function persistImportedSyncSettings(currentRaw, importedRaw, callback) {
|
||||
importedRaw &&
|
||||
Object.prototype.hasOwnProperty.call(importedRaw, "lastSpeed")
|
||||
) {
|
||||
if (
|
||||
Number.isFinite(expanded.lastSpeed) &&
|
||||
expanded.lastSpeed >= 0.0625 &&
|
||||
expanded.lastSpeed <= 16
|
||||
) {
|
||||
if (Number.isFinite(expanded.lastSpeed)) {
|
||||
mutation.set.lastSpeed = expanded.lastSpeed;
|
||||
}
|
||||
}
|
||||
@@ -223,14 +215,15 @@ function importSettings() {
|
||||
importLocalSettings(scopedLocalSettings, function (localError) {
|
||||
if (localError) {
|
||||
showStatus(
|
||||
"Error: Failed to save local extension data - " +
|
||||
localError.message,
|
||||
"Settings imported, but custom icons could not be updated - " +
|
||||
localError.message +
|
||||
". Reloading...",
|
||||
true
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
showStatus("Settings imported successfully. Reloading...");
|
||||
}
|
||||
|
||||
showStatus("Settings imported successfully. Reloading...");
|
||||
setTimeout(function () {
|
||||
if (typeof restore_options === "function") {
|
||||
restore_options();
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
--text: #17191c;
|
||||
--muted: #626b76;
|
||||
--accent: #111827;
|
||||
--focus-ring: #2563eb;
|
||||
--switch-track-off: #c1cad6;
|
||||
--switch-track-off-border: #aeb8c5;
|
||||
--switch-track-on: #111827;
|
||||
@@ -177,9 +178,14 @@ a:visited {
|
||||
text-underline-offset: 0.14em;
|
||||
}
|
||||
|
||||
a:hover,
|
||||
a:focus {
|
||||
color: #000;
|
||||
a:hover {
|
||||
color: var(--text);
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
||||
a:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
code {
|
||||
@@ -225,7 +231,7 @@ input[type="checkbox"]:focus-visible,
|
||||
input[type="text"]:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: 2px solid rgba(17, 24, 39, 0.14);
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -322,7 +328,7 @@ label em {
|
||||
|
||||
.shortcut-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 120px 120px;
|
||||
grid-template-columns: minmax(0, 1fr) 120px 120px 38px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
@@ -334,10 +340,6 @@ label em {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.shortcut-row.customs {
|
||||
grid-template-columns: minmax(0, 1fr) 120px 120px 38px;
|
||||
}
|
||||
|
||||
.shortcut-label {
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
@@ -628,6 +630,9 @@ label em {
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
transition: box-shadow 150ms ease, opacity 150ms ease;
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.cb-block:hover {
|
||||
@@ -1061,6 +1066,20 @@ button.lucide-result-tile.lucide-picked {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#status:not(:empty) {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
z-index: 1000;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 999px;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 8px 24px rgba(17, 24, 39, 0.16);
|
||||
}
|
||||
|
||||
#status:empty {
|
||||
display: none;
|
||||
}
|
||||
@@ -1229,6 +1248,7 @@ button.lucide-result-tile.lucide-picked {
|
||||
--text: #f2f4f6;
|
||||
--muted: #a0a8b2;
|
||||
--accent: #f2f4f6;
|
||||
--focus-ring: #93c5fd;
|
||||
--switch-track-off: #374151;
|
||||
--switch-track-off-border: #4b5563;
|
||||
--switch-track-on: #aab7c6;
|
||||
|
||||
@@ -250,7 +250,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<select id="addShortcutSelector">
|
||||
<select id="addShortcutSelector" aria-label="Add another shortcut">
|
||||
<option value="">Add shortcut…</option>
|
||||
</select>
|
||||
</section>
|
||||
@@ -468,8 +468,8 @@
|
||||
<div class="section-heading">
|
||||
<h3>Hover control bar</h3>
|
||||
<p class="section-intro">
|
||||
Drag blocks to reorder. Move between Active and Available to
|
||||
show or hide buttons.
|
||||
Drag blocks or use arrow keys to reorder. Press a block to
|
||||
move it between Active and Available.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cb-editor">
|
||||
@@ -591,7 +591,7 @@
|
||||
<div
|
||||
id="lucideIconResults"
|
||||
class="lucide-icon-results"
|
||||
role="listbox"
|
||||
role="group"
|
||||
aria-label="Matching Lucide icons"
|
||||
></div>
|
||||
<p id="lucideIconStatus" class="lucide-icon-status" aria-live="polite"></p>
|
||||
@@ -843,7 +843,7 @@
|
||||
</div>
|
||||
<div class="site-rule-option site-rule-option-field">
|
||||
<label
|
||||
>Preferred speed (0.0625–16):<br /><em
|
||||
>Preferred speed (0.1–16):<br /><em
|
||||
>Overrides the Preferred speed action for matching
|
||||
URLs. It does not force every video to start at this
|
||||
speed.</em
|
||||
@@ -943,8 +943,8 @@
|
||||
<label class="site-override-lead">
|
||||
<span
|
||||
>Override in-player control bar for this site<br /><em
|
||||
>Same idea as Hover control bar: drag blocks between
|
||||
Active and Available for matching URLs only.</em
|
||||
>Drag blocks or use the keyboard to arrange Active and
|
||||
Available buttons for matching URLs only.</em
|
||||
></span
|
||||
>
|
||||
<input type="checkbox" class="override-controlbar" />
|
||||
@@ -998,8 +998,8 @@
|
||||
<label class="site-override-lead">
|
||||
<span
|
||||
>Override shortcuts for this site<br /><em
|
||||
>Add shortcuts from the menu; none by default. Leave off
|
||||
to use global Shortcuts.</em
|
||||
>A site shortcut replaces global bindings for the same
|
||||
action; other global shortcuts remain active.</em
|
||||
></span
|
||||
>
|
||||
<input type="checkbox" class="override-shortcuts" />
|
||||
@@ -1022,13 +1022,14 @@
|
||||
<section class="settings-card action-card">
|
||||
<div class="section-heading">
|
||||
<h3>Actions</h3>
|
||||
<p class="section-intro">Save, restore, export, or import settings.</p>
|
||||
<p class="section-intro">Changes save automatically.</p>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button id="save">Save Changes</button>
|
||||
<button id="save">Save Now</button>
|
||||
<button id="restore">Restore Defaults</button>
|
||||
<button id="exportSettings">Export Settings</button>
|
||||
<button id="importSettings">Import Settings</button>
|
||||
<button id="copyDiagnostics">Copy Diagnostics</button>
|
||||
</div>
|
||||
|
||||
<div id="status" role="status" aria-live="polite"></div>
|
||||
@@ -1037,11 +1038,11 @@
|
||||
<section id="faq" class="settings-card info-card">
|
||||
<h4>Extension controls not appearing?</h4>
|
||||
<p>
|
||||
This extension only works with HTML5 audio and video. If the
|
||||
controls never appear, you may be looking at Flash content instead.
|
||||
Right-click the player to check: if the menu mentions Flash, that
|
||||
is the issue. Most sites will fall back to HTML5 when Flash is not
|
||||
available, so disabling Flash in the browser can help.
|
||||
Speeder works with HTML5 video and, when enabled above, HTML5 audio.
|
||||
Check that the current site is enabled, then use the popup’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>
|
||||
|
||||
|
||||
+240
-69
@@ -164,9 +164,33 @@ function createDefaultBinding(action, code, value) {
|
||||
|
||||
var tcDefaults = vscGetSettingsDefaults();
|
||||
var optionsSyncSettingsLoaded = false;
|
||||
var autoSaveTimer = null;
|
||||
var saveStatusTimer = null;
|
||||
|
||||
function scheduleAutoSave() {
|
||||
if (!optionsSyncSettingsLoaded) return;
|
||||
clearTimeout(autoSaveTimer);
|
||||
var sourceSaveButton = document.getElementById("save");
|
||||
var scheduledTimer = setTimeout(function () {
|
||||
if (
|
||||
autoSaveTimer !== scheduledTimer ||
|
||||
!sourceSaveButton ||
|
||||
!sourceSaveButton.isConnected
|
||||
) {
|
||||
return;
|
||||
}
|
||||
autoSaveTimer = null;
|
||||
save_options(true);
|
||||
}, 300);
|
||||
autoSaveTimer = scheduledTimer;
|
||||
}
|
||||
|
||||
function setOptionsSyncSettingsLoaded(loaded) {
|
||||
optionsSyncSettingsLoaded = loaded === true;
|
||||
if (!optionsSyncSettingsLoaded) {
|
||||
clearTimeout(autoSaveTimer);
|
||||
autoSaveTimer = null;
|
||||
}
|
||||
var saveButton = document.getElementById("save");
|
||||
if (saveButton) {
|
||||
saveButton.disabled = !optionsSyncSettingsLoaded;
|
||||
@@ -302,33 +326,15 @@ function refreshAddShortcutSelector() {
|
||||
selector.remove(1);
|
||||
}
|
||||
|
||||
// Find all currently used actions
|
||||
const usedActions = new Set();
|
||||
document.querySelectorAll(".shortcut-row").forEach((row) => {
|
||||
const action = row.dataset.action;
|
||||
if (action) {
|
||||
usedActions.add(action);
|
||||
}
|
||||
});
|
||||
|
||||
// Add all unused actions
|
||||
Object.keys(actionLabels).forEach((action) => {
|
||||
if (!usedActions.has(action)) {
|
||||
const option = document.createElement("option");
|
||||
option.value = action;
|
||||
option.text = actionLabels[action];
|
||||
selector.appendChild(option);
|
||||
}
|
||||
const option = document.createElement("option");
|
||||
option.value = action;
|
||||
option.text = actionLabels[action];
|
||||
selector.appendChild(option);
|
||||
});
|
||||
|
||||
// If no available actions, hide or disable the selector
|
||||
if (selector.options.length === 1) {
|
||||
selector.disabled = true;
|
||||
selector.options[0].text = "All shortcuts added";
|
||||
} else {
|
||||
selector.disabled = false;
|
||||
selector.options[0].text = "Add shortcut\u2026";
|
||||
}
|
||||
selector.disabled = false;
|
||||
selector.options[0].text = "Add shortcut\u2026";
|
||||
}
|
||||
|
||||
function refreshSiteRuleAddShortcutSelector(ruleEl) {
|
||||
@@ -340,32 +346,19 @@ function refreshSiteRuleAddShortcutSelector(ruleEl) {
|
||||
selector.remove(1);
|
||||
}
|
||||
|
||||
var usedActions = new Set();
|
||||
ruleEl.querySelectorAll(".site-shortcuts-rows .shortcut-row.customs").forEach(function (row) {
|
||||
var action = row.dataset.action;
|
||||
if (action) usedActions.add(action);
|
||||
});
|
||||
|
||||
Object.keys(actionLabels).forEach(function (action) {
|
||||
if (!usedActions.has(action)) {
|
||||
var option = document.createElement("option");
|
||||
option.value = action;
|
||||
option.textContent = actionLabels[action];
|
||||
selector.appendChild(option);
|
||||
}
|
||||
var option = document.createElement("option");
|
||||
option.value = action;
|
||||
option.textContent = actionLabels[action];
|
||||
selector.appendChild(option);
|
||||
});
|
||||
|
||||
var overrideShortcutsOn =
|
||||
ruleEl.querySelector(".override-shortcuts") &&
|
||||
ruleEl.querySelector(".override-shortcuts").checked;
|
||||
|
||||
if (selector.options.length === 1) {
|
||||
selector.disabled = true;
|
||||
selector.options[0].text = "All shortcuts added";
|
||||
} else {
|
||||
selector.disabled = !overrideShortcutsOn;
|
||||
selector.options[0].text = "Add shortcut\u2026";
|
||||
}
|
||||
selector.disabled = !overrideShortcutsOn;
|
||||
selector.options[0].text = "Add shortcut\u2026";
|
||||
}
|
||||
|
||||
function getGlobalBindingSnapshotForSiteShortcut(action) {
|
||||
@@ -424,7 +417,10 @@ function readOptionalPreferredSpeedInput(input) {
|
||||
if (!rawValue) return undefined;
|
||||
var parsed = Number(rawValue);
|
||||
if (!Number.isFinite(parsed)) return undefined;
|
||||
return Math.min(16, Math.max(0.0625, parsed));
|
||||
return Math.min(
|
||||
keyBindingUtils.MAX_SPEED,
|
||||
Math.max(keyBindingUtils.MIN_SPEED, parsed)
|
||||
);
|
||||
}
|
||||
|
||||
function updateSiteRuleToggleIcon(toggleButton, action) {
|
||||
@@ -560,6 +556,7 @@ function normalizeStoredBinding(binding, fallbackCode) {
|
||||
|
||||
var normalized = {
|
||||
code: normalizedCode,
|
||||
shiftKey: binding.shiftKey === true,
|
||||
disabled: false
|
||||
};
|
||||
|
||||
@@ -578,7 +575,7 @@ function formatBindingCode(code) {
|
||||
function getBindingLabel(binding) {
|
||||
if (!binding) return "";
|
||||
if (binding.disabled) return "";
|
||||
return formatBindingCode(binding.code);
|
||||
return (binding.shiftKey ? "Shift+" : "") + formatBindingCode(binding.code);
|
||||
}
|
||||
|
||||
function setShortcutInputBinding(input, binding) {
|
||||
@@ -591,6 +588,7 @@ function captureBindingFromEvent(event) {
|
||||
if (typeof event.code !== "string" || event.code.length === 0) return null;
|
||||
return {
|
||||
code: event.code,
|
||||
shiftKey: event.shiftKey === true,
|
||||
disabled: false
|
||||
};
|
||||
}
|
||||
@@ -600,6 +598,7 @@ function recordKeyPress(event) {
|
||||
|
||||
if (event.key === "Backspace") {
|
||||
setShortcutInputBinding(event.target, null);
|
||||
scheduleAutoSave();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
@@ -607,6 +606,7 @@ function recordKeyPress(event) {
|
||||
|
||||
if (event.key === "Escape") {
|
||||
setShortcutInputBinding(event.target, createDisabledBinding());
|
||||
scheduleAutoSave();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
@@ -616,6 +616,7 @@ function recordKeyPress(event) {
|
||||
if (!binding) return;
|
||||
|
||||
setShortcutInputBinding(event.target, binding);
|
||||
scheduleAutoSave();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
@@ -671,6 +672,20 @@ function appendSelectOptions(select, options) {
|
||||
});
|
||||
}
|
||||
|
||||
function labelShortcutRow(row) {
|
||||
if (!row) return;
|
||||
var action = row.dataset.action;
|
||||
var name = actionLabels[action] || action || "Shortcut";
|
||||
var keyInput = row.querySelector(".customKey");
|
||||
var valueInput = row.querySelector(".customValue");
|
||||
var removeButton = row.querySelector(".removeParent");
|
||||
if (keyInput) keyInput.setAttribute("aria-label", name + " key");
|
||||
if (valueInput) valueInput.setAttribute("aria-label", name + " value");
|
||||
if (removeButton) {
|
||||
removeButton.setAttribute("aria-label", "Remove " + name + " shortcut");
|
||||
}
|
||||
}
|
||||
|
||||
function add_shortcut(action, value) {
|
||||
if (!action) return;
|
||||
|
||||
@@ -710,6 +725,7 @@ function add_shortcut(action, value) {
|
||||
div.appendChild(keyInput);
|
||||
div.appendChild(valueInput);
|
||||
div.appendChild(removeButton);
|
||||
labelShortcutRow(div);
|
||||
|
||||
var customsElement = document.querySelector(".shortcuts-grid");
|
||||
customsElement.appendChild(div);
|
||||
@@ -770,6 +786,7 @@ function createKeyBindings(item) {
|
||||
keyBindings.push({
|
||||
action: action,
|
||||
code: binding.code,
|
||||
shiftKey: binding.shiftKey === true,
|
||||
disabled: binding.disabled === true,
|
||||
value: bindingValue,
|
||||
force: false,
|
||||
@@ -791,9 +808,8 @@ function validate() {
|
||||
if (pattern.startsWith("/")) {
|
||||
try {
|
||||
var lastSlash = pattern.lastIndexOf("/");
|
||||
if (lastSlash > 0) {
|
||||
new RegExp(pattern.substring(1, lastSlash), pattern.substring(lastSlash + 1));
|
||||
}
|
||||
if (lastSlash === 0) throw new Error("Missing closing slash");
|
||||
new RegExp(pattern.substring(1, lastSlash), pattern.substring(lastSlash + 1));
|
||||
} catch (err) {
|
||||
status.textContent =
|
||||
"Error: Invalid site rule regex: " + pattern + ". Unable to save";
|
||||
@@ -806,7 +822,10 @@ function validate() {
|
||||
return valid;
|
||||
}
|
||||
|
||||
function save_options() {
|
||||
function save_options(isAutoSave) {
|
||||
clearTimeout(autoSaveTimer);
|
||||
clearTimeout(saveStatusTimer);
|
||||
autoSaveTimer = null;
|
||||
var status = document.getElementById("status");
|
||||
if (!optionsSyncSettingsLoaded) {
|
||||
status.textContent =
|
||||
@@ -1092,6 +1111,7 @@ function save_options() {
|
||||
shortcuts.push({
|
||||
action: action,
|
||||
code: binding.code,
|
||||
shiftKey: binding.shiftKey === true,
|
||||
disabled: binding.disabled === true,
|
||||
value: shortcutValue,
|
||||
force: forceCheckbox ? forceCheckbox.checked : false
|
||||
@@ -1117,8 +1137,8 @@ function save_options() {
|
||||
status.textContent = "Error: Unable to save options - " + error.message;
|
||||
return;
|
||||
}
|
||||
status.textContent = "Options saved";
|
||||
setTimeout(function () {
|
||||
status.textContent = isAutoSave === true ? "Auto-saved" : "Options saved";
|
||||
saveStatusTimer = setTimeout(function () {
|
||||
status.textContent = "";
|
||||
}, 1000);
|
||||
});
|
||||
@@ -1139,15 +1159,7 @@ function addSiteRuleShortcut(rowsEl, action, binding, value, force) {
|
||||
|
||||
var actionLabel = document.createElement("div");
|
||||
actionLabel.className = "shortcut-label";
|
||||
var actionLabelText = actionLabels[action] || action;
|
||||
if (action === "toggleSubtitleNudge") {
|
||||
var ruleEl = rowsEl.closest(".site-rule");
|
||||
var pattern = ruleEl ? ruleEl.querySelector(".site-pattern").value : "";
|
||||
if (!pattern.toLowerCase().includes("youtube.com")) {
|
||||
actionLabelText += " (only for YouTube embeds)";
|
||||
}
|
||||
}
|
||||
actionLabel.textContent = actionLabelText;
|
||||
actionLabel.textContent = actionLabels[action] || action;
|
||||
|
||||
var keyInput = document.createElement("input");
|
||||
keyInput.className = "customKey";
|
||||
@@ -1195,10 +1207,40 @@ function addSiteRuleShortcut(rowsEl, action, binding, value, force) {
|
||||
div.appendChild(valueInput);
|
||||
div.appendChild(forceLabel);
|
||||
div.appendChild(removeButton);
|
||||
labelShortcutRow(div);
|
||||
|
||||
rowsEl.appendChild(div);
|
||||
}
|
||||
|
||||
var siteRuleControlId = 0;
|
||||
|
||||
function associateSiteRuleLabels(ruleEl) {
|
||||
ruleEl.querySelectorAll(".site-rule-option").forEach(function(option) {
|
||||
var label = Array.from(option.children).find(function(child) {
|
||||
return child.tagName === "LABEL";
|
||||
});
|
||||
var controls = option.querySelectorAll("input, select, textarea");
|
||||
if (!label || controls.length !== 1 || label.contains(controls[0])) return;
|
||||
controls[0].id = "site-rule-control-" + ++siteRuleControlId;
|
||||
label.htmlFor = controls[0].id;
|
||||
});
|
||||
|
||||
ruleEl.querySelectorAll(".margin-pad-cell").forEach(function(cell) {
|
||||
var input = cell.querySelector("input");
|
||||
var miniLabel = cell.querySelector(".margin-pad-mini");
|
||||
if (input && miniLabel) {
|
||||
input.setAttribute(
|
||||
"aria-label",
|
||||
"Controller margin " +
|
||||
(miniLabel.textContent === "T" ? "top" : "bottom")
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
var removeRule = ruleEl.querySelector(".remove-site-rule");
|
||||
if (removeRule) removeRule.setAttribute("aria-label", "Remove site rule");
|
||||
}
|
||||
|
||||
function createSiteRule(rule) {
|
||||
var template = document.getElementById("siteRuleTemplate");
|
||||
var clone = template.content.cloneNode(true);
|
||||
@@ -1371,6 +1413,7 @@ function createSiteRule(rule) {
|
||||
}
|
||||
applySiteRuleOverrideState(ruleEl, "override-shortcuts", "site-shortcuts-container");
|
||||
refreshSiteRuleAddShortcutSelector(ruleEl);
|
||||
associateSiteRuleLabels(ruleEl);
|
||||
|
||||
document.getElementById("siteRulesContainer").appendChild(ruleEl);
|
||||
}
|
||||
@@ -1379,7 +1422,8 @@ function createControlBarBlock(buttonId) {
|
||||
var def = controllerButtonDefs[buttonId];
|
||||
if (!def) return null;
|
||||
|
||||
var block = document.createElement("div");
|
||||
var block = document.createElement("button");
|
||||
block.type = "button";
|
||||
block.className = "cb-block";
|
||||
block.dataset.buttonId = buttonId;
|
||||
block.draggable = true;
|
||||
@@ -1402,6 +1446,25 @@ function createControlBarBlock(buttonId) {
|
||||
return block;
|
||||
}
|
||||
|
||||
function updateControlBarBlockLabels(editor) {
|
||||
if (!editor) return;
|
||||
editor.querySelectorAll(".cb-dropzone").forEach(function(zone) {
|
||||
var state = zone.classList.contains("cb-active-zone")
|
||||
? "active"
|
||||
: "available";
|
||||
zone.querySelectorAll(".cb-block").forEach(function(block) {
|
||||
var def = controllerButtonDefs[block.dataset.buttonId];
|
||||
block.setAttribute(
|
||||
"aria-label",
|
||||
(def ? def.name : block.dataset.buttonId) +
|
||||
", " +
|
||||
state +
|
||||
". Press Enter to move; use arrow keys to reorder."
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function populateControlBarZones(activeZone, availableZone, activeIds, allowButtonId) {
|
||||
vscClearElement(activeZone);
|
||||
vscClearElement(availableZone);
|
||||
@@ -1424,6 +1487,8 @@ function populateControlBarZones(activeZone, availableZone, activeIds, allowButt
|
||||
if (block) availableZone.appendChild(block);
|
||||
}
|
||||
});
|
||||
|
||||
updateControlBarBlockLabels(activeZone.closest(".cb-editor"));
|
||||
}
|
||||
|
||||
function readControlBarOrder(activeZone) {
|
||||
@@ -1467,11 +1532,11 @@ function updatePopupEditorDisabledState() {
|
||||
var checkbox = document.getElementById("popupMatchHoverControls");
|
||||
var wrap = document.getElementById("popupCbEditorWrap");
|
||||
if (!checkbox || !wrap) return;
|
||||
if (checkbox.checked) {
|
||||
wrap.classList.add("cb-editor-disabled");
|
||||
} else {
|
||||
wrap.classList.remove("cb-editor-disabled");
|
||||
}
|
||||
wrap.classList.toggle("cb-editor-disabled", checkbox.checked);
|
||||
wrap.querySelectorAll(".cb-block").forEach(function(block) {
|
||||
block.disabled = checkbox.checked;
|
||||
block.draggable = !checkbox.checked;
|
||||
});
|
||||
}
|
||||
|
||||
function getDragAfterElement(container, x, y) {
|
||||
@@ -1494,6 +1559,8 @@ function getDragAfterElement(container, x, y) {
|
||||
}
|
||||
|
||||
function initControlBarEditor() {
|
||||
if (document.vscControlBarEditorInitialized) return;
|
||||
document.vscControlBarEditorInitialized = true;
|
||||
var draggedBlock = null;
|
||||
|
||||
function clearControlBarDropTargets(activeZone) {
|
||||
@@ -1506,7 +1573,7 @@ function initControlBarEditor() {
|
||||
|
||||
document.addEventListener("dragstart", function (e) {
|
||||
var block = e.target.closest(".cb-block");
|
||||
if (!block) return;
|
||||
if (!block || block.disabled) return;
|
||||
draggedBlock = block;
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", block.dataset.buttonId);
|
||||
@@ -1551,10 +1618,49 @@ function initControlBarEditor() {
|
||||
var zone = e.target.closest(".cb-dropzone");
|
||||
if (zone) {
|
||||
e.preventDefault();
|
||||
updateControlBarBlockLabels(zone.closest(".cb-editor"));
|
||||
scheduleAutoSave();
|
||||
}
|
||||
|
||||
clearControlBarDropTargets(null);
|
||||
});
|
||||
|
||||
document.addEventListener("click", function(e) {
|
||||
var block = e.target.closest ? e.target.closest(".cb-block") : null;
|
||||
if (!block || block.disabled) return;
|
||||
var editor = block.closest(".cb-editor");
|
||||
var currentZone = block.closest(".cb-dropzone");
|
||||
if (!editor || !currentZone) return;
|
||||
var otherZone = editor.querySelector(
|
||||
currentZone.classList.contains("cb-active-zone")
|
||||
? ".cb-available-zone"
|
||||
: ".cb-active-zone"
|
||||
);
|
||||
if (!otherZone) return;
|
||||
otherZone.appendChild(block);
|
||||
updateControlBarBlockLabels(editor);
|
||||
block.focus();
|
||||
scheduleAutoSave();
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", function(e) {
|
||||
var block = e.target.closest ? e.target.closest(".cb-block") : null;
|
||||
if (!block || block.disabled) return;
|
||||
var previous = e.key === "ArrowLeft" || e.key === "ArrowUp";
|
||||
var next = e.key === "ArrowRight" || e.key === "ArrowDown";
|
||||
if (!previous && !next) return;
|
||||
var sibling = previous ? block.previousElementSibling : block.nextElementSibling;
|
||||
if (!sibling) return;
|
||||
e.preventDefault();
|
||||
if (previous) {
|
||||
block.parentNode.insertBefore(block, sibling);
|
||||
} else {
|
||||
block.parentNode.insertBefore(block, sibling.nextElementSibling);
|
||||
}
|
||||
updateControlBarBlockLabels(block.closest(".cb-editor"));
|
||||
block.focus();
|
||||
scheduleAutoSave();
|
||||
});
|
||||
}
|
||||
|
||||
var lucidePickerSelectedSlug = null;
|
||||
@@ -1626,6 +1732,10 @@ function initLucideButtonIconsUI() {
|
||||
b.dataset.slug = slug;
|
||||
b.title = slug;
|
||||
b.setAttribute("aria-label", slug);
|
||||
b.setAttribute(
|
||||
"aria-pressed",
|
||||
slug === lucidePickerSelectedSlug ? "true" : "false"
|
||||
);
|
||||
if (slug === lucidePickerSelectedSlug) {
|
||||
b.classList.add("lucide-picked");
|
||||
}
|
||||
@@ -1647,6 +1757,10 @@ function initLucideButtonIconsUI() {
|
||||
resultsEl.querySelectorAll("button"),
|
||||
function (x) {
|
||||
x.classList.toggle("lucide-picked", x.dataset.slug === slug);
|
||||
x.setAttribute(
|
||||
"aria-pressed",
|
||||
x.dataset.slug === slug ? "true" : "false"
|
||||
);
|
||||
}
|
||||
);
|
||||
fetchLucideSvg(slug)
|
||||
@@ -1712,7 +1826,7 @@ function initLucideButtonIconsUI() {
|
||||
slug +
|
||||
" for " +
|
||||
action +
|
||||
". Reload pages for the hover bar."
|
||||
". Open pages update automatically."
|
||||
);
|
||||
});
|
||||
})
|
||||
@@ -1825,13 +1939,16 @@ function restore_options(callback) {
|
||||
|
||||
document.querySelectorAll(".customs:not([id])").forEach((row) => row.remove());
|
||||
|
||||
var usedDefaultShortcutRows = new Set();
|
||||
storage.keyBindings.forEach((item) => {
|
||||
var row = document.getElementById(item.action);
|
||||
var normalizedBinding = normalizeStoredBinding(item);
|
||||
|
||||
if (!row) {
|
||||
if (!row || usedDefaultShortcutRows.has(item.action)) {
|
||||
add_shortcut(item.action, item.value);
|
||||
row = document.querySelector(".shortcut-row.customs:last-of-type");
|
||||
} else {
|
||||
usedDefaultShortcutRows.add(item.action);
|
||||
}
|
||||
|
||||
if (!row) return;
|
||||
@@ -1892,6 +2009,13 @@ function restore_options(callback) {
|
||||
}
|
||||
|
||||
function restore_defaults() {
|
||||
if (
|
||||
!window.confirm(
|
||||
"Restore all defaults? This removes saved preferences, remembered speeds, and custom icons."
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
var status = document.getElementById("status");
|
||||
var restoreButton = document.getElementById("restore");
|
||||
setOptionsSyncSettingsLoaded(false);
|
||||
@@ -1971,6 +2095,8 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
versionElement.textContent = manifest.version;
|
||||
}
|
||||
|
||||
document.querySelectorAll("#customs .shortcut-row").forEach(labelShortcutRow);
|
||||
|
||||
restore_options();
|
||||
initControlBarEditor();
|
||||
|
||||
@@ -1978,6 +2104,41 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
.addEventListener("change", updatePopupEditorDisabledState);
|
||||
|
||||
document.getElementById("save").addEventListener("click", save_options);
|
||||
document.getElementById("copyDiagnostics").addEventListener("click", function () {
|
||||
var status = document.getElementById("status");
|
||||
if (
|
||||
!navigator.clipboard ||
|
||||
typeof navigator.clipboard.writeText !== "function"
|
||||
) {
|
||||
status.textContent = "Clipboard access is unavailable.";
|
||||
return;
|
||||
}
|
||||
var report = popupControlUtils.buildDiagnosticReport({
|
||||
speederVersion: manifest.version,
|
||||
browser: navigator.userAgent,
|
||||
platform: navigator.platform || null,
|
||||
storage: {
|
||||
enabled: document.getElementById("enabled").checked,
|
||||
rememberSpeed: document.getElementById("rememberSpeed").checked,
|
||||
forceLastSavedSpeed:
|
||||
document.getElementById("forceLastSavedSpeed").checked,
|
||||
audioBoolean: document.getElementById("audioBoolean").checked,
|
||||
startHidden: document.getElementById("startHidden").checked,
|
||||
hideWithControls: document.getElementById("hideWithControls").checked,
|
||||
controllerLocation: document.getElementById("controllerLocation").value,
|
||||
shortcutTargetMode:
|
||||
document.getElementById("shortcutTargetMode").value
|
||||
}
|
||||
});
|
||||
navigator.clipboard.writeText(report).then(
|
||||
function() {
|
||||
status.textContent = "Diagnostics copied. Review before sharing.";
|
||||
},
|
||||
function() {
|
||||
status.textContent = "Could not copy diagnostics.";
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const addSelector = document.getElementById("addShortcutSelector");
|
||||
if (addSelector) {
|
||||
@@ -2030,11 +2191,13 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
if (siteRuleForShortcut) {
|
||||
refreshSiteRuleAddShortcutSelector(siteRuleForShortcut);
|
||||
}
|
||||
scheduleAutoSave();
|
||||
return;
|
||||
}
|
||||
var removeSiteRuleButton = targetEl.closest(".remove-site-rule");
|
||||
if (removeSiteRuleButton) {
|
||||
removeSiteRuleButton.closest(".site-rule").remove();
|
||||
scheduleAutoSave();
|
||||
return;
|
||||
}
|
||||
var toggleButton = targetEl.closest(".toggle-site-rule");
|
||||
@@ -2104,4 +2267,12 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
}
|
||||
}
|
||||
});
|
||||
document.addEventListener("change", (event) => {
|
||||
if (
|
||||
event.target.id !== "addShortcutSelector" &&
|
||||
!event.target.closest("#lucideIconSettings")
|
||||
) {
|
||||
scheduleAutoSave();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+23
-10
@@ -4,8 +4,9 @@
|
||||
--border: #e2e5e9;
|
||||
--border-strong: #d4d9e0;
|
||||
--text: #17191c;
|
||||
--muted: #626b76;
|
||||
--accent: #111827;
|
||||
--muted: #626b76;
|
||||
--accent: #111827;
|
||||
--focus-ring: #2563eb;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -83,10 +84,15 @@ button:active {
|
||||
background: #f1f3f5;
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 2px solid rgba(17, 24, 39, 0.14);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
#refresh {
|
||||
background: var(--accent);
|
||||
@@ -112,6 +118,12 @@ button:focus-visible {
|
||||
border-color: #9fbd98;
|
||||
color: #285d21;
|
||||
}
|
||||
|
||||
#pauseTab[aria-pressed="true"] {
|
||||
background: #fff3d6;
|
||||
border-color: #d8b66b;
|
||||
color: #704f0d;
|
||||
}
|
||||
|
||||
.popup-divider {
|
||||
height: 1px;
|
||||
@@ -250,8 +262,8 @@ button:focus-visible {
|
||||
background: #f1f3f5;
|
||||
}
|
||||
|
||||
.donate-icon-btn:focus-visible {
|
||||
outline: 2px solid rgba(17, 24, 39, 0.14);
|
||||
.donate-icon-btn:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
@@ -295,8 +307,9 @@ button:focus-visible {
|
||||
--border: #2b3138;
|
||||
--border-strong: #3a414a;
|
||||
--text: #f2f4f6;
|
||||
--muted: #a0a8b2;
|
||||
--accent: #f2f4f6;
|
||||
--muted: #a0a8b2;
|
||||
--accent: #f2f4f6;
|
||||
--focus-ring: #93c5fd;
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
@@ -24,6 +24,12 @@
|
||||
type="button"
|
||||
aria-pressed="false"
|
||||
>Force last saved speed</button>
|
||||
<button
|
||||
id="pauseTab"
|
||||
class="popup-compact-action"
|
||||
type="button"
|
||||
aria-pressed="false"
|
||||
>Pause on this tab</button>
|
||||
<div class="popup-divider"></div>
|
||||
<div id="popupControlBar" class="popup-control-bar">
|
||||
<span id="popupSpeed" class="popup-speed">1.00</span>
|
||||
@@ -32,10 +38,17 @@
|
||||
<button id="enable" class="hide">Enable</button>
|
||||
<button id="disable">Disable</button>
|
||||
</div>
|
||||
<div id="status" class="popup-status hide"></div>
|
||||
<div
|
||||
id="status"
|
||||
class="popup-status hide"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
<div class="popup-links">
|
||||
<button id="config">Settings</button>
|
||||
<div class="popup-secondary">
|
||||
<button id="addSiteRule" class="secondary">Add current site</button>
|
||||
<button id="copyDiagnostics" class="secondary">Copy diagnostics</button>
|
||||
<button id="feedback" class="secondary">Feedback</button>
|
||||
<button id="about" class="secondary">About</button>
|
||||
</div>
|
||||
|
||||
+208
-16
@@ -1,4 +1,6 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
if (window.vscPopupInitialized) return;
|
||||
window.vscPopupInitialized = true;
|
||||
var speederShared =
|
||||
typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {};
|
||||
var siteRuleUtils = speederShared.siteRules || {};
|
||||
@@ -27,6 +29,63 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
var popupExcludedButtonIds = new Set(["settings"]);
|
||||
var renderToken = 0;
|
||||
var forceLastSavedSpeedControlledBySiteRule = null;
|
||||
var selectedFrameToken = null;
|
||||
var shortcutTargetMode = "closest";
|
||||
var diagnosticContext = null;
|
||||
|
||||
function updateTabPauseButton(paused) {
|
||||
var button = document.querySelector("#pauseTab");
|
||||
button.setAttribute("aria-pressed", paused ? "true" : "false");
|
||||
button.textContent = paused ? "Resume on this tab" : "Pause on this tab";
|
||||
}
|
||||
|
||||
function getTabPauseState(tab, callback) {
|
||||
if (!tab || !Number.isInteger(tab.id)) {
|
||||
callback(false);
|
||||
return;
|
||||
}
|
||||
chrome.runtime.sendMessage(
|
||||
{ action: "get_tab_pause_state", tabId: tab.id },
|
||||
function(response) {
|
||||
callback(
|
||||
!chrome.runtime.lastError && response && response.paused === true
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function getCurrentSiteRuleDetails(url) {
|
||||
try {
|
||||
var parsed = new URL(url);
|
||||
if (
|
||||
(parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
|
||||
!parsed.host
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
title: parsed.host,
|
||||
pattern: parsed.protocol + "//" + parsed.host
|
||||
};
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildDiagnosticReport() {
|
||||
if (!diagnosticContext) return null;
|
||||
return popupControlUtils.buildDiagnosticReport({
|
||||
speederVersion: chrome.runtime.getManifest().version,
|
||||
browser: navigator.userAgent,
|
||||
platform: navigator.platform || null,
|
||||
url: diagnosticContext.url,
|
||||
storage: diagnosticContext.storage,
|
||||
siteRule: diagnosticContext.siteRule,
|
||||
siteRuleDisabled: isSiteRuleDisabled(diagnosticContext.siteRule),
|
||||
tabPaused: diagnosticContext.tabPaused,
|
||||
frame: diagnosticContext.frame
|
||||
});
|
||||
}
|
||||
|
||||
function persistExpandedSettings(rawStorage, settings, callback) {
|
||||
var mutation = vscBuildManagedStorageMutation(rawStorage, settings);
|
||||
@@ -123,16 +182,23 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
}
|
||||
|
||||
function getActiveTabContext(callback) {
|
||||
function finish(context) {
|
||||
getTabPauseState(context.tab, function(paused) {
|
||||
context.tabPaused = paused;
|
||||
if (callback) callback(context);
|
||||
});
|
||||
}
|
||||
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
|
||||
var activeTab = tabs && tabs[0] ? tabs[0] : null;
|
||||
if (!activeTab || !activeTab.id) {
|
||||
if (callback) callback({ tab: null, url: "" });
|
||||
finish({ tab: null, url: "" });
|
||||
return;
|
||||
}
|
||||
|
||||
var tabUrl = typeof activeTab.url === "string" ? activeTab.url : "";
|
||||
if (tabUrl.length > 0) {
|
||||
if (callback) callback({ tab: activeTab, url: tabUrl });
|
||||
finish({ tab: activeTab, url: tabUrl });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -141,13 +207,13 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
{ action: "get_page_context" },
|
||||
function (response) {
|
||||
if (chrome.runtime.lastError) {
|
||||
if (callback) callback({ tab: activeTab, url: "" });
|
||||
finish({ tab: activeTab, url: "" });
|
||||
return;
|
||||
}
|
||||
|
||||
var pageUrl =
|
||||
response && typeof response.url === "string" ? response.url : "";
|
||||
if (callback) callback({ tab: activeTab, url: pageUrl });
|
||||
finish({ tab: activeTab, url: pageUrl });
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -162,6 +228,10 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
if (response && response.speed != null) {
|
||||
updateSpeedDisplay(response.speed);
|
||||
}
|
||||
selectedFrameToken =
|
||||
response && typeof response.frameToken === "string"
|
||||
? response.frameToken
|
||||
: null;
|
||||
}
|
||||
|
||||
function pickBestFrameSpeedResult(results) {
|
||||
@@ -241,14 +311,19 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
}
|
||||
if (def.className) btn.className = def.className;
|
||||
btn.title = btnId.charAt(0).toUpperCase() + btnId.slice(1);
|
||||
btn.setAttribute("aria-label", btn.title);
|
||||
|
||||
btn.addEventListener("click", function () {
|
||||
if (btnId === "settings") {
|
||||
window.open(chrome.runtime.getURL("options/options.html"));
|
||||
return;
|
||||
}
|
||||
var message = { action: "run_action", actionName: btnId };
|
||||
if (shortcutTargetMode !== "all" && selectedFrameToken) {
|
||||
message.targetFrameToken = selectedFrameToken;
|
||||
}
|
||||
sendToActiveTab(
|
||||
{ action: "run_action", actionName: btnId },
|
||||
message,
|
||||
function () {
|
||||
querySpeed();
|
||||
}
|
||||
@@ -277,6 +352,104 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
window.open("https://github.com/SoPat712/Speeder/issues");
|
||||
});
|
||||
|
||||
document.querySelector("#copyDiagnostics").addEventListener("click", function () {
|
||||
var report = buildDiagnosticReport();
|
||||
if (!report) {
|
||||
setStatusMessage("Diagnostics are still loading.");
|
||||
return;
|
||||
}
|
||||
if (!navigator.clipboard || typeof navigator.clipboard.writeText !== "function") {
|
||||
setStatusMessage("Clipboard access is unavailable.");
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(report).then(
|
||||
function () {
|
||||
setStatusMessage("Diagnostics copied. Review before sharing.");
|
||||
},
|
||||
function () {
|
||||
setStatusMessage("Could not copy diagnostics.");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
document.querySelector("#addSiteRule").addEventListener("click", function () {
|
||||
var details = diagnosticContext
|
||||
? getCurrentSiteRuleDetails(diagnosticContext.url)
|
||||
: null;
|
||||
if (!details) {
|
||||
setStatusMessage("A site rule cannot be created for this page.");
|
||||
return;
|
||||
}
|
||||
var added = false;
|
||||
|
||||
updateStoredSettings(
|
||||
function (settings) {
|
||||
var rules = Array.isArray(settings.siteRules) ? settings.siteRules : [];
|
||||
if (
|
||||
rules.some(function(rule) {
|
||||
return rule && rule.pattern === details.pattern;
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
settings.siteRules = rules.concat([
|
||||
{
|
||||
title: details.title,
|
||||
pattern: details.pattern,
|
||||
enabled: true
|
||||
}
|
||||
]);
|
||||
added = true;
|
||||
},
|
||||
function (error) {
|
||||
if (error) {
|
||||
setStatusMessage("Could not add this site: " + error.message);
|
||||
return;
|
||||
}
|
||||
setStatusMessage(
|
||||
added
|
||||
? "Site rule added. Opening settings..."
|
||||
: "Site rule already exists. Opening settings..."
|
||||
);
|
||||
window.open(chrome.runtime.getURL("options/options.html"));
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
document.querySelector("#pauseTab").addEventListener("click", function () {
|
||||
var tab = diagnosticContext && diagnosticContext.tab;
|
||||
if (!tab || !Number.isInteger(tab.id)) {
|
||||
setStatusMessage("This page cannot be paused.");
|
||||
return;
|
||||
}
|
||||
var button = this;
|
||||
var paused = button.getAttribute("aria-pressed") !== "true";
|
||||
button.disabled = true;
|
||||
chrome.runtime.sendMessage(
|
||||
{ action: "set_tab_paused", tabId: tab.id, paused: paused },
|
||||
function(response) {
|
||||
button.disabled = false;
|
||||
if (chrome.runtime.lastError || !response) {
|
||||
setStatusMessage("Could not update this tab.");
|
||||
return;
|
||||
}
|
||||
diagnosticContext.tabPaused = response.paused === true;
|
||||
updateTabPauseButton(diagnosticContext.tabPaused);
|
||||
setControlBarVisible(
|
||||
!diagnosticContext.tabPaused &&
|
||||
diagnosticContext.siteAvailable &&
|
||||
diagnosticContext.showBar
|
||||
);
|
||||
setForceButtonLoading(diagnosticContext.tabPaused);
|
||||
setStatusMessage(
|
||||
diagnosticContext.tabPaused
|
||||
? "Speeder is paused for this tab."
|
||||
: "Speeder resumed for this tab."
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
document.querySelector("#donate").addEventListener("click", function () {
|
||||
this.classList.add("hide");
|
||||
document.querySelector("#donateOptions").classList.remove("hide");
|
||||
@@ -372,6 +545,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
function renderForActiveTab() {
|
||||
var currentRenderToken = ++renderToken;
|
||||
forceLastSavedSpeedControlledBySiteRule = null;
|
||||
selectedFrameToken = null;
|
||||
setForceButtonLoading(true);
|
||||
|
||||
chrome.storage.local.get(["customButtonIcons"], function (loc) {
|
||||
@@ -388,6 +562,9 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
getActiveTabContext(function (context) {
|
||||
if (currentRenderToken !== renderToken) return;
|
||||
|
||||
var tabPaused = context && context.tabPaused === true;
|
||||
updateTabPauseButton(tabPaused);
|
||||
|
||||
var url = context && context.url ? context.url : "";
|
||||
var siteRule = matchSiteRule(url, storage.siteRules);
|
||||
var siteDisabled = isSiteRuleDisabled(siteRule);
|
||||
@@ -395,6 +572,10 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
storage.enabled,
|
||||
siteRule
|
||||
);
|
||||
shortcutTargetMode =
|
||||
siteRule && siteRule.shortcutTargetMode !== undefined
|
||||
? siteRule.shortcutTargetMode
|
||||
: storage.shortcutTargetMode;
|
||||
var showBar = storage.showPopupControlBar !== false;
|
||||
forceLastSavedSpeedControlledBySiteRule = Boolean(
|
||||
siteRule && siteRule.forceLastSavedSpeed !== undefined
|
||||
@@ -403,9 +584,22 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
forceLastSavedSpeedControlledBySiteRule
|
||||
? siteRule.forceLastSavedSpeed === true
|
||||
: storage.forceLastSavedSpeed === true;
|
||||
diagnosticContext = {
|
||||
tab: context && context.tab,
|
||||
url: url,
|
||||
storage: storage,
|
||||
siteRule: siteRule,
|
||||
frame: null,
|
||||
tabPaused: tabPaused,
|
||||
siteAvailable: siteAvailable,
|
||||
showBar: showBar
|
||||
};
|
||||
document.querySelector("#addSiteRule").disabled =
|
||||
!getCurrentSiteRuleDetails(url);
|
||||
|
||||
if (siteRule && siteRule.showPopupControlBar !== undefined) {
|
||||
showBar = siteRule.showPopupControlBar;
|
||||
diagnosticContext.showBar = showBar;
|
||||
}
|
||||
|
||||
toggleEnabledUI(storage.enabled !== false);
|
||||
@@ -414,7 +608,13 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
resolvePopupButtons(storage, siteRule),
|
||||
customIconsMap
|
||||
);
|
||||
setControlBarVisible(siteAvailable && showBar);
|
||||
setControlBarVisible(!tabPaused && siteAvailable && showBar);
|
||||
|
||||
if (tabPaused) {
|
||||
setForceButtonLoading(true);
|
||||
setStatusMessage("Speeder is paused for this tab.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (siteDisabled) {
|
||||
setForceButtonLoading(false);
|
||||
@@ -427,6 +627,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
if (siteAvailable) {
|
||||
querySpeed(function(frameContext) {
|
||||
if (currentRenderToken !== renderToken) return;
|
||||
diagnosticContext.frame = frameContext || null;
|
||||
if (
|
||||
frameContext &&
|
||||
typeof frameContext.forceLastSavedSpeed === "boolean"
|
||||
@@ -507,20 +708,11 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
function toggleEnabledUI(enabled) {
|
||||
document.querySelector("#enable").classList.toggle("hide", enabled);
|
||||
document.querySelector("#disable").classList.toggle("hide", !enabled);
|
||||
|
||||
const suffix = `${enabled ? "" : "_disabled"}.png`;
|
||||
chrome.browserAction.setIcon({
|
||||
path: {
|
||||
19: "assets/icons/icon19" + suffix,
|
||||
38: "assets/icons/icon38" + suffix,
|
||||
48: "assets/icons/icon48" + suffix
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function settingsSavedReloadMessage(enabled) {
|
||||
setStatusMessage(
|
||||
`${enabled ? "Enabled" : "Disabled"}. Reload page to see changes`
|
||||
`${enabled ? "Enabled" : "Disabled"}. Open pages update automatically.`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
root.SpeederShared = root.SpeederShared || {};
|
||||
root.SpeederShared.keyBindings = exports;
|
||||
})(typeof globalThis !== "undefined" ? globalThis : this, function() {
|
||||
var MIN_SPEED = 0.1;
|
||||
var MAX_SPEED = 16;
|
||||
|
||||
function normalizeBindingKey(key) {
|
||||
if (typeof key !== "string" || key.length === 0) return null;
|
||||
if (key === "Spacebar") return " ";
|
||||
@@ -127,15 +130,15 @@
|
||||
if (!Number.isFinite(numericValue)) return "must be a finite number";
|
||||
if (
|
||||
(action === "slower" || action === "faster") &&
|
||||
(numericValue <= 0 || numericValue > 16)
|
||||
(numericValue <= 0 || numericValue > MAX_SPEED)
|
||||
) {
|
||||
return "must be greater than 0 and no more than 16";
|
||||
return "must be greater than 0 and no more than " + MAX_SPEED;
|
||||
}
|
||||
if (
|
||||
action === "fast" &&
|
||||
(numericValue < 0.0625 || numericValue > 16)
|
||||
(numericValue < MIN_SPEED || numericValue > MAX_SPEED)
|
||||
) {
|
||||
return "must be between 0.0625 and 16";
|
||||
return "must be between " + MIN_SPEED + " and " + MAX_SPEED;
|
||||
}
|
||||
if (
|
||||
(action === "rewind" || action === "advance") &&
|
||||
@@ -162,6 +165,8 @@
|
||||
}
|
||||
|
||||
return {
|
||||
MAX_SPEED: MAX_SPEED,
|
||||
MIN_SPEED: MIN_SPEED,
|
||||
getActionValueError: getActionValueError,
|
||||
getLegacyKeyCode: getLegacyKeyCode,
|
||||
inferBindingCode: inferBindingCode,
|
||||
|
||||
@@ -68,6 +68,12 @@
|
||||
|
||||
function normalizeResult(result) {
|
||||
var normalized = { speed: result.speed };
|
||||
if (typeof result.frameToken === "string") {
|
||||
normalized.frameToken = result.frameToken;
|
||||
}
|
||||
if (result.diagnostics && typeof result.diagnostics === "object") {
|
||||
normalized.diagnostics = result.diagnostics;
|
||||
}
|
||||
if (typeof result.forceLastSavedSpeed === "boolean") {
|
||||
normalized.forceLastSavedSpeed = result.forceLastSavedSpeed;
|
||||
}
|
||||
@@ -91,7 +97,61 @@
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function getSafePageDetails(url) {
|
||||
try {
|
||||
var parsed = new URL(url);
|
||||
return {
|
||||
protocol: parsed.protocol,
|
||||
hostname: parsed.hostname || null
|
||||
};
|
||||
} catch (_error) {
|
||||
return { protocol: null, hostname: null };
|
||||
}
|
||||
}
|
||||
|
||||
function buildDiagnosticReport(context) {
|
||||
var config = context || {};
|
||||
var storage = config.storage || {};
|
||||
var siteRule = config.siteRule || null;
|
||||
var frame = config.frame || null;
|
||||
|
||||
return JSON.stringify(
|
||||
{
|
||||
speederVersion: config.speederVersion || null,
|
||||
browser: config.browser || null,
|
||||
platform: config.platform || null,
|
||||
page: getSafePageDetails(config.url),
|
||||
globalSettings: {
|
||||
enabled: storage.enabled !== false,
|
||||
rememberSpeed: storage.rememberSpeed === true,
|
||||
forceLastSavedSpeed: storage.forceLastSavedSpeed === true,
|
||||
audioEnabled: storage.audioBoolean === true,
|
||||
startHidden: storage.startHidden === true,
|
||||
hideWithControls: storage.hideWithControls === true,
|
||||
controllerLocation: storage.controllerLocation,
|
||||
shortcutTargetMode: storage.shortcutTargetMode
|
||||
},
|
||||
matchedSiteRule: {
|
||||
matched: Boolean(siteRule),
|
||||
disabled: config.siteRuleDisabled === true,
|
||||
overrideKeys: siteRule
|
||||
? Object.keys(siteRule)
|
||||
.filter(function(key) {
|
||||
return key !== "pattern" && key !== "title";
|
||||
})
|
||||
.sort()
|
||||
: []
|
||||
},
|
||||
tabPaused: config.tabPaused === true,
|
||||
frame: frame && frame.diagnostics ? frame.diagnostics : null
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
buildDiagnosticReport: buildDiagnosticReport,
|
||||
pickBestFrameSpeedResult: pickBestFrameSpeedResult,
|
||||
resolvePopupButtons: resolvePopupButtons,
|
||||
sanitizeButtonOrder: sanitizeButtonOrder
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
var LEGACY_SITE_RULES_DIFF_FORMAT = "defaults-diff-v1";
|
||||
var SITE_RULES_DIFF_FORMAT = "defaults-diff-v2";
|
||||
var REMOVED_DEFAULT_RULE_KEYS_META = "removedDefaultRuleKeys";
|
||||
var MIN_SPEED = 0.1;
|
||||
var MAX_SPEED = 16;
|
||||
var DEFAULT_BUTTONS = ["rewind", "slower", "faster", "advance", "display"];
|
||||
var LEGACY_SYNC_KEYS = [
|
||||
"resetSpeed",
|
||||
@@ -363,7 +365,7 @@
|
||||
return clampFiniteNumber(value, 250, 1000, undefined);
|
||||
}
|
||||
if (key === "preferredSpeed") {
|
||||
return clampFiniteNumber(value, 0.0625, 16, undefined);
|
||||
return clampFiniteNumber(value, MIN_SPEED, MAX_SPEED, undefined);
|
||||
}
|
||||
if (key === "shortcuts" && Array.isArray(value)) {
|
||||
return sanitizeStoredBindingValues(value).map(function(shortcut) {
|
||||
@@ -749,9 +751,9 @@
|
||||
var value = Number(normalized.value);
|
||||
var invalid = !Number.isFinite(value);
|
||||
if (action === "slower" || action === "faster") {
|
||||
invalid = invalid || value <= 0 || value > 16;
|
||||
invalid = invalid || value <= 0 || value > MAX_SPEED;
|
||||
} else if (action === "fast") {
|
||||
invalid = invalid || value < 0.0625 || value > 16;
|
||||
invalid = invalid || value < MIN_SPEED || value > MAX_SPEED;
|
||||
} else if (action === "rewind" || action === "advance") {
|
||||
invalid = invalid || value < 0;
|
||||
} else if (action === "louder" || action === "softer") {
|
||||
@@ -885,8 +887,8 @@
|
||||
|
||||
expanded.lastSpeed = clampFiniteNumber(
|
||||
expanded.lastSpeed,
|
||||
0.0625,
|
||||
16,
|
||||
MIN_SPEED,
|
||||
MAX_SPEED,
|
||||
DEFAULT_SETTINGS.lastSpeed
|
||||
);
|
||||
expanded.hideWithControlsTimer = clampFiniteNumber(
|
||||
@@ -983,6 +985,9 @@
|
||||
}
|
||||
|
||||
var regex;
|
||||
if (pattern.startsWith("/") && pattern.lastIndexOf("/") === 0) {
|
||||
return false;
|
||||
}
|
||||
if (pattern.startsWith("/") && pattern.lastIndexOf("/") > 0) {
|
||||
try {
|
||||
var lastSlash = pattern.lastIndexOf("/");
|
||||
|
||||
@@ -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,66 @@
|
||||
const {
|
||||
createChromeMock,
|
||||
evaluateScript,
|
||||
loadHtmlString
|
||||
} = require("./helpers/extension-test-utils");
|
||||
|
||||
describe("background toolbar state", () => {
|
||||
afterEach(() => {
|
||||
delete global.chrome;
|
||||
});
|
||||
|
||||
it("initializes and follows the enabled storage setting", () => {
|
||||
loadHtmlString("<!doctype html><html><body></body></html>");
|
||||
const chrome = createChromeMock({ syncData: { enabled: false } });
|
||||
global.chrome = chrome;
|
||||
window.chrome = chrome;
|
||||
|
||||
evaluateScript("extension/background/background.js");
|
||||
expect(chrome.browserAction.setIcon).toHaveBeenLastCalledWith({
|
||||
path: {
|
||||
19: "assets/icons/icon19_disabled.png",
|
||||
38: "assets/icons/icon38_disabled.png",
|
||||
48: "assets/icons/icon48_disabled.png"
|
||||
}
|
||||
});
|
||||
|
||||
chrome.storage.sync.set({ enabled: true });
|
||||
expect(chrome.browserAction.setIcon).toHaveBeenLastCalledWith({
|
||||
path: {
|
||||
19: "assets/icons/icon19.png",
|
||||
38: "assets/icons/icon38.png",
|
||||
48: "assets/icons/icon48.png"
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps tab pauses in memory and clears closed tabs", () => {
|
||||
loadHtmlString("<!doctype html><html><body></body></html>");
|
||||
const chrome = createChromeMock();
|
||||
global.chrome = chrome;
|
||||
window.chrome = chrome;
|
||||
|
||||
evaluateScript("extension/background/background.js");
|
||||
const listener = chrome.runtime.onMessage.listeners[0];
|
||||
const respond = vi.fn();
|
||||
|
||||
listener(
|
||||
{ action: "set_tab_paused", tabId: 42, paused: true },
|
||||
{},
|
||||
respond
|
||||
);
|
||||
expect(respond).toHaveBeenLastCalledWith({ paused: true });
|
||||
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(
|
||||
42,
|
||||
{ action: "set_tab_paused", paused: true },
|
||||
expect.any(Function)
|
||||
);
|
||||
|
||||
listener({ action: "get_tab_pause_state" }, { tab: { id: 42 } }, respond);
|
||||
expect(respond).toHaveBeenLastCalledWith({ paused: true });
|
||||
|
||||
chrome.tabs.onRemoved.emit(42);
|
||||
listener({ action: "get_tab_pause_state", tabId: 42 }, {}, respond);
|
||||
expect(respond).toHaveBeenLastCalledWith({ paused: false });
|
||||
});
|
||||
});
|
||||
@@ -173,7 +173,13 @@ export function createChromeMock(options = {}) {
|
||||
getManifest: vi.fn(() => ({
|
||||
version: options.manifestVersion || "9.9.9"
|
||||
})),
|
||||
getURL: vi.fn((url) => "moz-extension://speeder/" + url)
|
||||
getURL: vi.fn((url) => "moz-extension://speeder/" + url),
|
||||
sendMessage: vi.fn((message, callback) => {
|
||||
if (options.runtimeSendMessageImpl) {
|
||||
return options.runtimeSendMessageImpl(message, callback);
|
||||
}
|
||||
if (callback) callback({ paused: false });
|
||||
})
|
||||
},
|
||||
storage: {
|
||||
sync: syncArea,
|
||||
|
||||
@@ -42,6 +42,7 @@ function applyJSDOMWindow(win) {
|
||||
win.Date = globalThis.Date;
|
||||
win.open = vi.fn();
|
||||
win.close = vi.fn();
|
||||
win.confirm = vi.fn(() => true);
|
||||
}
|
||||
|
||||
function loadHtmlString(html, options) {
|
||||
@@ -244,6 +245,7 @@ function createChromeMock(options) {
|
||||
const storageOnChanged = createChromeEvent();
|
||||
const tabsOnActivated = createChromeEvent();
|
||||
const tabsOnUpdated = createChromeEvent();
|
||||
const tabsOnRemoved = createChromeEvent();
|
||||
const runtimeOnMessage = createChromeEvent();
|
||||
|
||||
const chrome = {
|
||||
@@ -251,6 +253,12 @@ function createChromeMock(options) {
|
||||
lastError: null,
|
||||
getManifest: vi.fn(() => clone(config.manifest) || { version: "0.0.0-test" }),
|
||||
getURL: vi.fn((relPath) => `moz-extension://${relPath}`),
|
||||
sendMessage: vi.fn((message, callback) => {
|
||||
if (config.runtimeSendMessageImpl) {
|
||||
return config.runtimeSendMessageImpl(message, callback);
|
||||
}
|
||||
if (callback) callback({ paused: false });
|
||||
}),
|
||||
onMessage: runtimeOnMessage
|
||||
},
|
||||
browserAction: {
|
||||
@@ -271,7 +279,8 @@ function createChromeMock(options) {
|
||||
}),
|
||||
create: vi.fn(),
|
||||
onActivated: tabsOnActivated,
|
||||
onUpdated: tabsOnUpdated
|
||||
onUpdated: tabsOnUpdated,
|
||||
onRemoved: tabsOnRemoved
|
||||
},
|
||||
storage: {
|
||||
onChanged: storageOnChanged,
|
||||
|
||||
@@ -27,4 +27,5 @@ export function applyJSDOMWindow(win) {
|
||||
|
||||
win.open = vi.fn();
|
||||
win.close = vi.fn();
|
||||
win.confirm = vi.fn(() => true);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,8 @@ describe("import/export flows", () => {
|
||||
|
||||
it("exports sync and local settings as a JSON download", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(2026, 3, 4, 8, 9, 10));
|
||||
const exportTime = new Date(2026, 3, 4, 8, 9, 10);
|
||||
vi.setSystemTime(exportTime);
|
||||
const chrome = await setupImportExport({
|
||||
sync: { rememberSpeed: true, shortcutTargetMode: "all" },
|
||||
local: { customButtonIcons: { faster: { slug: "rocket" } } }
|
||||
@@ -89,7 +90,7 @@ describe("import/export flows", () => {
|
||||
const blobText = await capturedBlob.text();
|
||||
expect(JSON.parse(blobText)).toEqual({
|
||||
version: "1.1",
|
||||
exportDate: "2026-04-04T12:09:10.000Z",
|
||||
exportDate: exportTime.toISOString(),
|
||||
settings: {
|
||||
rememberSpeed: true,
|
||||
shortcutTargetMode: "all",
|
||||
|
||||
@@ -70,7 +70,7 @@ describe("options/import-export.js", () => {
|
||||
});
|
||||
|
||||
it("generates timestamped backup filenames", () => {
|
||||
vi.setSystemTime(new Date("2026-04-04T13:14:15Z"));
|
||||
vi.setSystemTime(new Date(2026, 3, 4, 9, 14, 15));
|
||||
bootImportExport();
|
||||
|
||||
expect(window.generateBackupFilename()).toBe(
|
||||
@@ -137,9 +137,14 @@ describe("options/import-export.js", () => {
|
||||
expect(backup.localSettings.lucideTagsCacheV1At).toBeUndefined();
|
||||
});
|
||||
|
||||
it("imports wrapped backups, restores local data, and refreshes the options page", async () => {
|
||||
it("reports partial success and refreshes when custom icons fail to import", async () => {
|
||||
const { chrome } = bootImportExport();
|
||||
window.restore_options = vi.fn();
|
||||
chrome.storage.local.set.mockImplementationOnce(function(_items, callback) {
|
||||
chrome.runtime.lastError = { message: "icon quota exceeded" };
|
||||
callback();
|
||||
chrome.runtime.lastError = null;
|
||||
});
|
||||
|
||||
const realCreateElement = document.createElement.bind(document);
|
||||
const fakeInput = realCreateElement("input");
|
||||
@@ -197,6 +202,12 @@ describe("options/import-export.js", () => {
|
||||
{ rememberSpeed: true, enabled: false },
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(document.querySelector("#status").textContent).toContain(
|
||||
"Settings imported, but custom icons could not be updated"
|
||||
);
|
||||
expect(document.querySelector("#status").textContent).toContain(
|
||||
"icon quota exceeded"
|
||||
);
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(window.restore_options).toHaveBeenCalled();
|
||||
|
||||
@@ -309,6 +309,198 @@ describe("inject.js media/controller lifecycle regressions", () => {
|
||||
expect(second.video.playbackRate).toBe(1.2);
|
||||
});
|
||||
|
||||
it("ignores popup actions addressed to another frame", async () => {
|
||||
const chrome = bootInject();
|
||||
await settleLifecycle();
|
||||
const { video } = createControlledVideo();
|
||||
const listener = chrome.runtime.onMessage.listeners[0];
|
||||
const initialSpeed = video.playbackRate;
|
||||
|
||||
listener(
|
||||
{
|
||||
action: "run_action",
|
||||
actionName: "faster",
|
||||
targetFrameToken: "another-frame"
|
||||
},
|
||||
{},
|
||||
vi.fn()
|
||||
);
|
||||
|
||||
expect(video.playbackRate).toBe(initialSpeed);
|
||||
});
|
||||
|
||||
it("removes and restores controls when this tab is paused", async () => {
|
||||
const chrome = bootInject();
|
||||
await settleLifecycle();
|
||||
const { video } = createControlledVideo();
|
||||
const listener = chrome.runtime.onMessage.listeners[0];
|
||||
const playbackRate = video.playbackRate;
|
||||
|
||||
listener(
|
||||
{ action: "set_tab_paused", paused: true },
|
||||
{},
|
||||
vi.fn()
|
||||
);
|
||||
expect(window.tc.tabPaused).toBe(true);
|
||||
expect(video.vsc).toBeUndefined();
|
||||
expect(video.playbackRate).toBe(playbackRate);
|
||||
|
||||
listener(
|
||||
{ action: "set_tab_paused", paused: false },
|
||||
{},
|
||||
vi.fn()
|
||||
);
|
||||
expect(window.tc.tabPaused).toBe(false);
|
||||
expect(video.vsc).toBeTruthy();
|
||||
});
|
||||
|
||||
it("drops a stale hover-preview shortcut target after SPA navigation", async () => {
|
||||
bootInject({
|
||||
url: "https://www.youtube.com/",
|
||||
path: "/"
|
||||
});
|
||||
await settleLifecycle();
|
||||
|
||||
const preview = createControlledVideo({
|
||||
src: "blob:https://www.youtube.com/hover-preview",
|
||||
mountRect: makeRect(0, 0, 320, 180)
|
||||
});
|
||||
preview.mount.id = "inline-preview-player";
|
||||
document.dispatchEvent(
|
||||
new MouseEvent("mousemove", { bubbles: true, clientX: 100, clientY: 90 })
|
||||
);
|
||||
expect(window.tc.lastPointerPosition).toEqual(
|
||||
expect.objectContaining({ document, x: 100, y: 90 })
|
||||
);
|
||||
|
||||
window.history.pushState({}, "", "/watch?v=next-video");
|
||||
|
||||
const main = createControlledVideo({
|
||||
src: "blob:https://www.youtube.com/main-player",
|
||||
mountRect: makeRect(0, 0, 1280, 720)
|
||||
});
|
||||
main.mount.id = "movie_player";
|
||||
document.dispatchEvent(
|
||||
new MouseEvent("mousemove", { bubbles: true, clientX: 100, clientY: 90 })
|
||||
);
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
code: "KeyD",
|
||||
key: "d"
|
||||
})
|
||||
);
|
||||
|
||||
expect(window.tc.lastPointerPosition).not.toBeNull();
|
||||
expect(preview.video.playbackRate).toBe(1);
|
||||
expect(main.video.playbackRate).toBe(1.1);
|
||||
});
|
||||
|
||||
it("captures SPA shortcuts before later page handlers and distinguishes Shift", async () => {
|
||||
vi.useFakeTimers();
|
||||
bootInject({
|
||||
syncGetDelayMs: 25,
|
||||
syncData: {
|
||||
keyBindings: [
|
||||
{ action: "advance", code: "KeyX", value: 10 },
|
||||
{ action: "advance", code: "KeyX", shiftKey: true, value: 3 }
|
||||
]
|
||||
}
|
||||
});
|
||||
expect(window.vscKeydownListenerAttached).toBe(true);
|
||||
|
||||
window.addEventListener(
|
||||
"keydown",
|
||||
(event) => event.stopImmediatePropagation(),
|
||||
true
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
await settleLifecycle();
|
||||
|
||||
const { video } = createControlledVideo();
|
||||
video.currentTime = 50;
|
||||
video.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
bubbles: true,
|
||||
code: "KeyX",
|
||||
key: "x"
|
||||
})
|
||||
);
|
||||
video.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
bubbles: true,
|
||||
code: "KeyX",
|
||||
key: "X",
|
||||
shiftKey: true
|
||||
})
|
||||
);
|
||||
|
||||
expect(video.currentTime).toBe(63);
|
||||
});
|
||||
|
||||
it("ignores shortcuts from selects and shadow-DOM edit fields", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
const { video } = createControlledVideo();
|
||||
const select = document.createElement("select");
|
||||
const host = document.createElement("site-editor");
|
||||
const input = document.createElement("input");
|
||||
host.attachShadow({ mode: "open" }).appendChild(input);
|
||||
document.body.append(select, host);
|
||||
|
||||
[select, input].forEach((target) => {
|
||||
target.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
code: "KeyD",
|
||||
key: "d"
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
expect(video.playbackRate).toBe(1);
|
||||
});
|
||||
|
||||
it("finishes a forced SPA initialization after settings hydration", async () => {
|
||||
vi.useFakeTimers();
|
||||
bootInject({
|
||||
url: "https://www.youtube.com/",
|
||||
syncGetDelayMs: 1000
|
||||
});
|
||||
|
||||
const mount = document.createElement("div");
|
||||
const video = document.createElement("video");
|
||||
const rect = makeRect(0, 0, 1280, 720);
|
||||
mount.id = "movie_player";
|
||||
video.src = "blob:https://www.youtube.com/main-player";
|
||||
setRect(mount, rect);
|
||||
setBoxMetrics(mount, rect.width, rect.height);
|
||||
setRect(video, rect);
|
||||
mount.appendChild(video);
|
||||
document.body.appendChild(mount);
|
||||
|
||||
document.dispatchEvent(new Event("speeder-location-changed"));
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
expect(window.tc.runtimeSettingsHydrated).toBe(false);
|
||||
expect(video.vsc).toBeUndefined();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(700);
|
||||
await settleLifecycle();
|
||||
|
||||
expect(window.tc.runtimeSettingsHydrated).toBe(true);
|
||||
expect(video.vsc).toBeTruthy();
|
||||
video.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
bubbles: true,
|
||||
code: "KeyD",
|
||||
key: "d"
|
||||
})
|
||||
);
|
||||
expect(video.playbackRate).toBe(1.1);
|
||||
});
|
||||
|
||||
it("skips ambient loops by default and includes them when explicitly enabled", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
@@ -423,6 +615,7 @@ describe("inject.js media/controller lifecycle regressions", () => {
|
||||
div: wrapper,
|
||||
normalControllerMount: normalMount
|
||||
};
|
||||
wrapper.showPopover = vi.fn();
|
||||
window.setupControllerHostTracking(controller, wrapper, normalMount);
|
||||
Object.defineProperty(document, "fullscreenElement", {
|
||||
configurable: true,
|
||||
@@ -431,6 +624,8 @@ describe("inject.js media/controller lifecycle regressions", () => {
|
||||
|
||||
window.syncControllerFullscreenMount(controller);
|
||||
expect(fullscreenPlayer.contains(wrapper)).toBe(true);
|
||||
expect(wrapper.showPopover).not.toHaveBeenCalled();
|
||||
expect(wrapper.hasAttribute("popover")).toBe(false);
|
||||
|
||||
Object.defineProperty(document, "fullscreenElement", {
|
||||
configurable: true,
|
||||
@@ -443,50 +638,40 @@ describe("inject.js media/controller lifecycle regressions", () => {
|
||||
controller.controllerHostCleanup();
|
||||
});
|
||||
|
||||
it("promotes an ancestor-fullscreen controller into the browser top layer", async () => {
|
||||
it("only promotes the directly-fullscreen video's controller", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
const fullscreenPlayer = document.createElement("div");
|
||||
const video = document.createElement("video");
|
||||
const wrapper = document.createElement("div");
|
||||
const rect = makeRect(0, 0, 1280, 720);
|
||||
const fullscreenVideo = document.createElement("video");
|
||||
const otherVideo = document.createElement("video");
|
||||
const rect = makeRect(0, 0, 640, 360);
|
||||
|
||||
fullscreenPlayer.append(video, wrapper);
|
||||
document.body.appendChild(fullscreenPlayer);
|
||||
[fullscreenPlayer, video].forEach((element) => {
|
||||
fullscreenVideo.src = "https://example.org/fullscreen.mp4";
|
||||
otherVideo.src = "https://example.org/other.mp4";
|
||||
fullscreenPlayer.appendChild(fullscreenVideo);
|
||||
document.body.append(fullscreenPlayer, otherVideo);
|
||||
[fullscreenPlayer, fullscreenVideo, otherVideo].forEach((element) => {
|
||||
setRect(element, rect);
|
||||
setBoxMetrics(element, rect.width, rect.height);
|
||||
});
|
||||
wrapper.showPopover = vi.fn();
|
||||
wrapper.hidePopover = vi.fn();
|
||||
|
||||
const controller = {
|
||||
video,
|
||||
div: wrapper,
|
||||
normalControllerMount: fullscreenPlayer
|
||||
};
|
||||
window.setupControllerHostTracking(controller, wrapper, fullscreenPlayer);
|
||||
Object.defineProperty(document, "fullscreenElement", {
|
||||
configurable: true,
|
||||
value: fullscreenPlayer
|
||||
});
|
||||
|
||||
expect(window.syncControllerFullscreenMount(controller)).toBe(true);
|
||||
expect(wrapper.parentNode).toBe(fullscreenPlayer);
|
||||
expect(wrapper.showPopover).toHaveBeenCalledOnce();
|
||||
expect(wrapper.getAttribute("popover")).toBe("manual");
|
||||
expect(wrapper.classList.contains("vsc-fullscreen-popover")).toBe(true);
|
||||
window.ensureController(fullscreenVideo, fullscreenPlayer);
|
||||
window.ensureController(otherVideo, document.body);
|
||||
fullscreenVideo.vsc.div.showPopover = vi.fn();
|
||||
otherVideo.vsc.div.showPopover = vi.fn();
|
||||
|
||||
Object.defineProperty(document, "fullscreenElement", {
|
||||
configurable: true,
|
||||
value: null
|
||||
value: fullscreenVideo
|
||||
});
|
||||
window.syncControllerFullscreenMount(controller);
|
||||
expect(wrapper.hidePopover).toHaveBeenCalledOnce();
|
||||
window.syncControllerFullscreenMount(fullscreenVideo.vsc);
|
||||
window.syncControllerFullscreenMount(otherVideo.vsc);
|
||||
|
||||
wrapper.remove();
|
||||
controller.controllerHostCleanup();
|
||||
expect(fullscreenVideo.vsc.div.showPopover).toHaveBeenCalledOnce();
|
||||
expect(otherVideo.vsc.div.showPopover).not.toHaveBeenCalled();
|
||||
expect(
|
||||
otherVideo.vsc.div.classList.contains("vsc-fullscreen-popover")
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves direct-video requestFullscreen semantics and overlays with a popover", async () => {
|
||||
@@ -693,6 +878,21 @@ describe("inject.js media/controller lifecycle regressions", () => {
|
||||
expect(wrapper.classList.contains("vsc-idle-hidden")).toBe(true);
|
||||
});
|
||||
|
||||
it("creates named native buttons for in-player controls", async () => {
|
||||
bootInject({
|
||||
syncData: { controllerButtons: ["rewind", "nudge"] }
|
||||
});
|
||||
await settleLifecycle();
|
||||
const { wrapper } = createControlledVideo();
|
||||
const rewind = wrapper.shadowRoot.querySelector('[data-action="rewind"]');
|
||||
const nudge = wrapper.shadowRoot.querySelector("#nudge-indicator");
|
||||
|
||||
expect(rewind.tagName).toBe("BUTTON");
|
||||
expect(rewind.getAttribute("aria-label")).toBe("Rewind");
|
||||
expect(nudge.tagName).toBe("BUTTON");
|
||||
expect(nudge.getAttribute("aria-label")).toContain("Subtitle nudge");
|
||||
});
|
||||
|
||||
it("does not let YouTube auto-hide collapse controls under the pointer", async () => {
|
||||
vi.useFakeTimers();
|
||||
bootInject({
|
||||
|
||||
@@ -91,12 +91,58 @@ describe("options page", () => {
|
||||
expect(globalThis.getPopupControlBarOrder()).toEqual(["rewind", "advance"]);
|
||||
});
|
||||
|
||||
it("reorders and toggles control-bar buttons from the keyboard", async () => {
|
||||
await setupOptions({
|
||||
sync: { controllerButtons: ["rewind", "faster"] }
|
||||
});
|
||||
const active = document.getElementById("controlBarActive");
|
||||
const rewind = active.querySelector('[data-button-id="rewind"]');
|
||||
|
||||
rewind.dispatchEvent(
|
||||
new window.KeyboardEvent("keydown", {
|
||||
key: "ArrowRight",
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
);
|
||||
expect(globalThis.getControlBarOrder()).toEqual(["faster", "rewind"]);
|
||||
|
||||
expect(rewind.disabled).toBe(false);
|
||||
rewind.click();
|
||||
expect(globalThis.getControlBarOrder()).toEqual(["faster"]);
|
||||
expect(rewind.closest(".cb-available-zone")).not.toBeNull();
|
||||
expect(rewind.getAttribute("aria-label")).toContain("available");
|
||||
});
|
||||
|
||||
it("labels shortcut and generated site-rule controls", async () => {
|
||||
await setupOptions();
|
||||
expect(document.getElementById("lucideIconResults").getAttribute("role")).toBe(
|
||||
"group"
|
||||
);
|
||||
expect(
|
||||
document.querySelector('#display .customKey').getAttribute("aria-label")
|
||||
).toBe("Show/hide controller key");
|
||||
|
||||
globalThis.createSiteRule(null);
|
||||
const rule = document.querySelector(".site-rule");
|
||||
const location = rule.querySelector(".site-controllerLocation");
|
||||
const locationLabel = location.closest(".site-rule-option").querySelector("label");
|
||||
expect(location.id).not.toBe("");
|
||||
expect(locationLabel.htmlFor).toBe(location.id);
|
||||
expect(
|
||||
rule.querySelector(".site-controllerMarginTop").getAttribute("aria-label")
|
||||
).toBe("Controller margin top");
|
||||
expect(
|
||||
rule.querySelector(".remove-site-rule").getAttribute("aria-label")
|
||||
).toBe("Remove site rule");
|
||||
});
|
||||
|
||||
it("validates site rule regexes before saving", async () => {
|
||||
const chrome = await setupOptions();
|
||||
chrome.storage.sync.set.mockClear();
|
||||
globalThis.createSiteRule(null);
|
||||
const rule = document.querySelector(".site-rule");
|
||||
rule.querySelector(".site-pattern").value = "/(/";
|
||||
rule.querySelector(".site-pattern").value = "/youtube";
|
||||
|
||||
globalThis.save_options();
|
||||
|
||||
@@ -106,6 +152,48 @@ describe("options page", () => {
|
||||
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adds and restores duplicate actions with Shift bindings", async () => {
|
||||
const chrome = await setupOptions();
|
||||
const selector = document.getElementById("addShortcutSelector");
|
||||
expect(selector.querySelector('option[value="rewind"]')).not.toBeNull();
|
||||
|
||||
selector.value = "rewind";
|
||||
selector.dispatchEvent(new window.Event("change", { bubbles: true }));
|
||||
const duplicate = document.querySelector(
|
||||
'.shortcut-row.customs[data-action="rewind"]'
|
||||
);
|
||||
duplicate.querySelector(".customKey").dispatchEvent(
|
||||
new window.KeyboardEvent("keydown", {
|
||||
key: "Z",
|
||||
code: "KeyZ",
|
||||
shiftKey: true,
|
||||
bubbles: true
|
||||
})
|
||||
);
|
||||
duplicate.querySelector(".customValue").value = "3";
|
||||
|
||||
globalThis.save_options();
|
||||
expect(
|
||||
chrome.storage.sync.__state.keyBindings.filter(
|
||||
(binding) => binding.action === "rewind"
|
||||
)
|
||||
).toEqual([
|
||||
expect.objectContaining({ value: 10, shiftKey: false }),
|
||||
expect.objectContaining({ value: 3, shiftKey: true })
|
||||
]);
|
||||
|
||||
globalThis.restore_options();
|
||||
await flushAsyncWork();
|
||||
expect(
|
||||
document.querySelectorAll('.shortcut-row[data-action="rewind"]')
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
document.querySelector(
|
||||
'.shortcut-row.customs[data-action="rewind"] .customKey'
|
||||
).value
|
||||
).toBe("Shift+Z");
|
||||
});
|
||||
|
||||
it("shows a more-menu trigger for collapsed site rules and a collapse trigger when open", async () => {
|
||||
await setupOptions({ sync: { siteRules: [] } });
|
||||
|
||||
@@ -302,4 +390,22 @@ describe("options page", () => {
|
||||
).toEqual(expect.any(Number));
|
||||
expect(chrome.storage.local.__state.unrelatedLocalValue).toBe("keep");
|
||||
});
|
||||
|
||||
it("leaves settings untouched when restoring defaults is cancelled", async () => {
|
||||
const chrome = await setupOptions({ sync: { rememberSpeed: true } });
|
||||
window.confirm.mockReturnValueOnce(false);
|
||||
chrome.storage.sync.set.mockClear();
|
||||
chrome.storage.sync.remove.mockClear();
|
||||
chrome.storage.local.set.mockClear();
|
||||
chrome.storage.local.remove.mockClear();
|
||||
|
||||
globalThis.restore_defaults();
|
||||
|
||||
expect(window.confirm).toHaveBeenCalledOnce();
|
||||
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
|
||||
expect(chrome.storage.sync.remove).not.toHaveBeenCalled();
|
||||
expect(chrome.storage.local.set).not.toHaveBeenCalled();
|
||||
expect(chrome.storage.local.remove).not.toHaveBeenCalled();
|
||||
expect(chrome.storage.sync.__state.rememberSpeed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,6 +67,59 @@ describe("options.js", () => {
|
||||
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("automatically saves changed settings", async () => {
|
||||
const chrome = bootOptions({ syncData: { rememberSpeed: false } });
|
||||
await flushAsyncWork(3);
|
||||
vi.useFakeTimers();
|
||||
chrome.storage.sync.set.mockClear();
|
||||
|
||||
const rememberSpeed = document.getElementById("rememberSpeed");
|
||||
rememberSpeed.checked = true;
|
||||
rememberSpeed.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
|
||||
expect(chrome.storage.sync._dump().rememberSpeed).toBe(true);
|
||||
expect(document.getElementById("status").textContent).toBe("Auto-saved");
|
||||
});
|
||||
|
||||
it("copies privacy-safe diagnostics from settings", async () => {
|
||||
bootOptions({
|
||||
syncData: {
|
||||
enabled: false,
|
||||
rememberSpeed: true,
|
||||
siteRules: [
|
||||
{
|
||||
title: "Private account",
|
||||
pattern: "secret.example/private-token",
|
||||
enabled: true
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
const writeText = vi.fn(() => Promise.resolve());
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText }
|
||||
});
|
||||
await flushAsyncWork(3);
|
||||
|
||||
document.getElementById("copyDiagnostics").click();
|
||||
await flushAsyncWork(3);
|
||||
|
||||
const reportText = writeText.mock.calls[0][0];
|
||||
const report = JSON.parse(reportText);
|
||||
expect(report.globalSettings.enabled).toBe(false);
|
||||
expect(report.globalSettings.rememberSpeed).toBe(true);
|
||||
expect(report.page).toEqual({ protocol: null, hostname: null });
|
||||
expect(report.matchedSiteRule.matched).toBe(false);
|
||||
expect(report.frame).toBeNull();
|
||||
expect(reportText).not.toContain("Private account");
|
||||
expect(reportText).not.toContain("private-token");
|
||||
expect(document.getElementById("status").textContent).toContain("copied");
|
||||
});
|
||||
|
||||
it("does not partially save options when a required site shortcut is invalid", async () => {
|
||||
const chrome = bootOptions({ syncData: { rememberSpeed: false } });
|
||||
await flushAsyncWork(3);
|
||||
|
||||
@@ -35,6 +35,12 @@ describe("popup UI", () => {
|
||||
expect(
|
||||
document.querySelectorAll("#popupControlBar button").length
|
||||
).toBeGreaterThan(0);
|
||||
expect(document.getElementById("status").getAttribute("role")).toBe(
|
||||
"status"
|
||||
);
|
||||
expect(
|
||||
document.querySelector("#popupControlBar button").getAttribute("aria-label")
|
||||
).not.toBe("");
|
||||
});
|
||||
|
||||
it("shows controls when globally disabled but a whitelist site rule matches", async () => {
|
||||
@@ -70,7 +76,7 @@ describe("popup UI", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("toggles enabled state and updates the browser action icons", async () => {
|
||||
it("toggles enabled state without owning background icon state", async () => {
|
||||
const chrome = await setupPopup();
|
||||
chrome.storage.sync.set.mockClear();
|
||||
chrome.browserAction.setIcon.mockClear();
|
||||
@@ -84,13 +90,10 @@ describe("popup UI", () => {
|
||||
expect(document.getElementById("enable").classList.contains("hide")).toBe(
|
||||
false
|
||||
);
|
||||
expect(chrome.browserAction.setIcon).toHaveBeenCalledWith({
|
||||
path: {
|
||||
19: "assets/icons/icon19_disabled.png",
|
||||
38: "assets/icons/icon38_disabled.png",
|
||||
48: "assets/icons/icon48_disabled.png"
|
||||
}
|
||||
});
|
||||
expect(document.getElementById("status").textContent).toBe(
|
||||
"Disabled. Open pages update automatically."
|
||||
);
|
||||
expect(chrome.browserAction.setIcon).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles refresh responses for unsupported and successful pages", async () => {
|
||||
|
||||
+129
-10
@@ -18,6 +18,7 @@ function bootPopup(options) {
|
||||
manifest: { version: "9.9.9-test" },
|
||||
syncData: config.syncData,
|
||||
localData: config.localData,
|
||||
runtimeSendMessageImpl: config.runtimeSendMessageImpl,
|
||||
tabsQueryResult: [
|
||||
config.activeTab || { id: 99, active: true, url: "https://example.com/" }
|
||||
]
|
||||
@@ -107,10 +108,10 @@ describe("popup.js", () => {
|
||||
executeScriptImpl: (tabId, details, callback) => {
|
||||
speedQueryCount += 1;
|
||||
callback(
|
||||
speedQueryCount <= 2
|
||||
speedQueryCount === 1
|
||||
? [
|
||||
{ speed: 1.25, preferred: false },
|
||||
{ speed: 1.5, preferred: true }
|
||||
{ speed: 1.5, frameToken: "playing-frame", preferred: true }
|
||||
]
|
||||
: [{ speed: 1.75, preferred: true }]
|
||||
);
|
||||
@@ -138,12 +139,136 @@ describe("popup.js", () => {
|
||||
|
||||
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(
|
||||
99,
|
||||
{ action: "run_action", actionName: "faster" },
|
||||
{
|
||||
action: "run_action",
|
||||
actionName: "faster",
|
||||
targetFrameToken: "playing-frame"
|
||||
},
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(document.querySelector("#popupSpeed").textContent).toBe("1.75");
|
||||
});
|
||||
|
||||
it("keeps all-video popup actions intentionally untargeted", async () => {
|
||||
const chrome = bootPopup({
|
||||
syncData: { shortcutTargetMode: "all" },
|
||||
executeScriptImpl: (tabId, details, callback) => {
|
||||
callback([{ speed: 1.5, frameToken: "playing-frame", preferred: true }]);
|
||||
}
|
||||
});
|
||||
await flushAsyncWork();
|
||||
chrome.tabs.sendMessage.mockClear();
|
||||
|
||||
document.querySelector("#popupControlBar button").click();
|
||||
|
||||
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(
|
||||
99,
|
||||
{ action: "run_action", actionName: "rewind" },
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it("copies redacted diagnostics for the active media frame", async () => {
|
||||
bootPopup({
|
||||
activeTab: {
|
||||
id: 12,
|
||||
active: true,
|
||||
url: "https://video.example/watch?private_token=secret"
|
||||
},
|
||||
executeScriptImpl: (tabId, details, callback) => {
|
||||
callback([
|
||||
{
|
||||
speed: 1.5,
|
||||
preferred: true,
|
||||
diagnostics: {
|
||||
mediaType: "video",
|
||||
fullscreen: { active: true, element: "div", ownsMedia: true },
|
||||
controller: { present: true, hidden: false }
|
||||
}
|
||||
}
|
||||
]);
|
||||
}
|
||||
});
|
||||
const writeText = vi.fn(() => Promise.resolve());
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText }
|
||||
});
|
||||
await flushAsyncWork();
|
||||
|
||||
document.querySelector("#copyDiagnostics").click();
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(writeText).toHaveBeenCalled();
|
||||
const reportText = writeText.mock.calls.at(-1)[0];
|
||||
const report = JSON.parse(reportText);
|
||||
expect(report.page).toEqual({
|
||||
protocol: "https:",
|
||||
hostname: "video.example"
|
||||
});
|
||||
expect(report.frame.mediaType).toBe("video");
|
||||
expect(reportText).not.toContain("private_token");
|
||||
expect(reportText).not.toContain("secret");
|
||||
expect(document.querySelector("#status").textContent).toContain("copied");
|
||||
});
|
||||
|
||||
it("adds one safe origin rule for the current site", async () => {
|
||||
const chrome = bootPopup({
|
||||
activeTab: {
|
||||
id: 18,
|
||||
active: true,
|
||||
url: "https://courses.example:8443/watch/lesson?token=private"
|
||||
}
|
||||
});
|
||||
await flushAsyncWork();
|
||||
|
||||
document.querySelector("#addSiteRule").click();
|
||||
await flushAsyncWork();
|
||||
document.querySelector("#addSiteRule").click();
|
||||
await flushAsyncWork();
|
||||
|
||||
const rules = window.vscExpandStoredSettings(
|
||||
chrome.storage.sync._dump()
|
||||
).siteRules.filter(function(rule) {
|
||||
return rule.pattern === "https://courses.example:8443";
|
||||
});
|
||||
expect(rules).toEqual([
|
||||
{
|
||||
title: "courses.example:8443",
|
||||
pattern: "https://courses.example:8443",
|
||||
enabled: true
|
||||
}
|
||||
]);
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
"moz-extension://options/options.html"
|
||||
);
|
||||
});
|
||||
|
||||
it("pauses only the active tab for the browser session", async () => {
|
||||
let paused = false;
|
||||
const chrome = bootPopup({
|
||||
runtimeSendMessageImpl: (message, callback) => {
|
||||
if (message.action === "set_tab_paused") paused = message.paused === true;
|
||||
callback({ paused });
|
||||
}
|
||||
});
|
||||
await flushAsyncWork();
|
||||
|
||||
document.querySelector("#pauseTab").click();
|
||||
|
||||
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith(
|
||||
{ action: "set_tab_paused", tabId: 99, paused: true },
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(document.querySelector("#pauseTab").textContent).toBe(
|
||||
"Resume on this tab"
|
||||
);
|
||||
expect(document.querySelector("#popupControlBar").style.display).toBe(
|
||||
"none"
|
||||
);
|
||||
expect(document.querySelector("#status").textContent).toContain("paused");
|
||||
});
|
||||
|
||||
it("toggles enablement and closes after a successful refresh", async () => {
|
||||
const chrome = bootPopup({
|
||||
syncData: {
|
||||
@@ -162,13 +287,7 @@ describe("popup.js", () => {
|
||||
expect(
|
||||
window.vscExpandStoredSettings(chrome.storage.sync._dump()).enabled
|
||||
).toBe(true);
|
||||
expect(chrome.browserAction.setIcon).toHaveBeenCalledWith({
|
||||
path: {
|
||||
19: "assets/icons/icon19.png",
|
||||
38: "assets/icons/icon38.png",
|
||||
48: "assets/icons/icon48.png"
|
||||
}
|
||||
});
|
||||
expect(chrome.browserAction.setIcon).not.toHaveBeenCalled();
|
||||
|
||||
document.querySelector("#refresh").click();
|
||||
expect(document.querySelector("#status").textContent).toContain("Closing");
|
||||
|
||||
@@ -244,7 +244,7 @@ describe("canonical settings storage", () => {
|
||||
forceLastSavedSpeed: "true",
|
||||
controllerOpacity: 3,
|
||||
subtitleNudgeInterval: -5,
|
||||
preferredSpeed: 50
|
||||
preferredSpeed: 500
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
@@ -22,6 +22,13 @@ describe("shared helpers", () => {
|
||||
])
|
||||
).toBeNull();
|
||||
expect(siteRules.isSiteRuleDisabled({ enabled: false })).toBe(true);
|
||||
expect(siteRules.compileSiteRulePattern("/youtube")).toBeNull();
|
||||
expect(
|
||||
siteRules.siteRuleMatchesUrl(
|
||||
{ pattern: "/youtube" },
|
||||
"https://youtube.com/watch"
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("matches plain and regex rules against safely decoded URL text", () => {
|
||||
@@ -134,6 +141,15 @@ describe("shared helpers", () => {
|
||||
).toEqual(["advance"]);
|
||||
});
|
||||
|
||||
it("keeps the selected frame token with the displayed popup speed", () => {
|
||||
expect(
|
||||
popupControls.pickBestFrameSpeedResult([
|
||||
{ speed: 1.25, frameToken: "background", preferred: false },
|
||||
{ speed: 1.75, frameToken: "playing", preferred: true }
|
||||
])
|
||||
).toEqual({ speed: 1.75, frameToken: "playing" });
|
||||
});
|
||||
|
||||
it("normalizes controller locations and margins", () => {
|
||||
expect(controllerUtils.normalizeControllerLocation("top-right")).toBe(
|
||||
"top-right"
|
||||
@@ -158,6 +174,14 @@ describe("shared helpers", () => {
|
||||
"NumpadAdd"
|
||||
);
|
||||
expect(keyBindingUtils.getLegacyKeyCode({ key: 65 })).toBe(65);
|
||||
expect(keyBindingUtils.getActionValueError("fast", 0.1)).toBeNull();
|
||||
expect(keyBindingUtils.getActionValueError("fast", 16)).toBeNull();
|
||||
expect(keyBindingUtils.getActionValueError("fast", 0.099)).toContain(
|
||||
"between 0.1 and 16"
|
||||
);
|
||||
expect(keyBindingUtils.getActionValueError("fast", 16.001)).toContain(
|
||||
"between 0.1 and 16"
|
||||
);
|
||||
});
|
||||
|
||||
it("builds and parses import/export payloads", () => {
|
||||
|
||||
Reference in New Issue
Block a user