mirror of
https://github.com/SoPat712/Speeder.git
synced 2026-08-21 20:32:07 -04:00
Compare commits
62
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8073014bce
|
||
|
|
f8cfbf05c8
|
||
|
|
b4234e0387
|
||
|
|
d55006dd0a
|
||
|
|
745af2f1b9
|
||
|
|
7e9ce436d3
|
||
|
|
7e3054f6fa
|
||
|
|
98546adf41
|
||
|
|
daeff63d2a
|
||
|
|
80aa00670c
|
||
|
|
aa4f1d15e7
|
||
|
|
5af0200008
|
||
|
|
1d9f6f7e13
|
||
|
|
0f64fd5ab1
|
||
|
|
e555c4c116
|
||
|
|
91d530e29b
|
||
|
|
34f2561b50
|
||
|
|
fd29883d84
|
||
|
|
e083fdcafe
|
||
|
|
2fa7d8963a
|
||
|
|
e4edf10440
|
||
|
|
d5c2d0e343
|
||
|
|
5cd559b883
|
||
|
|
0c9a6a1a1b
|
||
|
|
3ee970a54b
|
||
|
|
e12375f2d2
|
||
|
|
8d7d31a4db
|
||
|
|
ed21281fa3
|
||
|
|
13350e5cea
|
||
|
|
39b7ba27fb
|
||
|
|
ee346ee9d8
|
||
|
|
461c8c448f
|
||
|
|
bff65230bb
|
||
|
|
0afb56abe0
|
||
|
|
a02b7b57f7
|
||
|
|
39c1c76db1
|
||
|
|
9b6d1ccd3c
|
||
|
|
a2d7f0d9d6
|
||
|
|
f8cbcd4368
|
||
|
|
04f1414118
|
||
|
|
dad223d4ad
|
||
|
|
e5ae1ed4ba
|
||
|
|
cb892a36a4
|
||
|
|
722eb0754d
|
||
|
|
1ae7bbdee3
|
||
|
|
6b2a5ed472
|
||
|
|
29689ae4ec
|
||
|
|
9c10183ab6
|
||
|
|
f1e8f44f42
|
||
|
|
7073330916
|
||
|
|
f10818964c
|
||
|
|
7fd8e5d92d
|
||
|
|
2adafb51f2
|
||
|
|
22c8f9d793
|
||
|
|
c0bd997287
|
||
|
|
85d4a0588a
|
||
|
|
fd9911855a
|
||
|
|
cd3088a6db
|
||
|
|
a2ab1ad261
|
||
|
|
5b8f2422ab
|
||
|
|
477e5ba8b2
|
||
|
|
8b1820823b
|
@@ -14,7 +14,18 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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
|
run: npm install -g web-ext
|
||||||
|
|
||||||
- name: Lint
|
- 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"
|
preferences. For example, you can assign multiple different "preferred speed"
|
||||||
shortcuts with different values, which will allow you to quickly toggle between
|
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
|
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" />
|
<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 —
|
Some sites may assign other functionality to one of the assigned shortcut keys.
|
||||||
these collisions are inevitable, unfortunately. As a workaround, the extension
|
You can record `Shift+<shortcut>` as a separate, exact binding, or use a site
|
||||||
listens both for lower and upper case values (i.e. you can use
|
rule to block the site from capturing a particular Speeder shortcut. Shift is
|
||||||
`Shift-<shortcut>`) if there is other functionality assigned to the lowercase
|
not applied automatically to an unshifted binding.
|
||||||
key. This is not a perfect solution, as some sites may listen to both, but works
|
|
||||||
most of the time.
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -81,12 +79,10 @@ npx --yes web-ext lint --source-dir extension
|
|||||||
|
|
||||||
### The video controls are not showing up?
|
### The video controls are not showing up?
|
||||||
|
|
||||||
This extension is only compatible
|
Speeder works with HTML5 video and, when enabled in settings, HTML5 audio. Check
|
||||||
with HTML5 video. If you don't see the controls showing up, chances are you are
|
that Speeder is enabled for the current site, then use the popup's "Rescan page
|
||||||
viewing a Flash video. If you want to confirm, try right-clicking on the video
|
for videos" action after a player loads dynamically. Browser-internal pages and
|
||||||
and inspect the menu: if it mentions flash, then that's the issue. That said,
|
players that do not expose HTML5 media to extensions cannot be controlled.
|
||||||
most sites will fallback to HTML5 if they detect that Flash is not available.
|
|
||||||
You can try manually disabling Flash from the browser.
|
|
||||||
|
|
||||||
### What is this fork all about?
|
### What is this fork all about?
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,67 @@
|
|||||||
chrome.runtime.onMessage.addListener(function (request) {
|
function setToolbarIcon(enabled) {
|
||||||
if (request.action === "openOptions") {
|
var suffix = enabled === false ? "_disabled" : "";
|
||||||
chrome.tabs.create({ url: chrome.runtime.getURL("options/options.html") });
|
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,7 +8,23 @@
|
|||||||
if (!v) return null;
|
if (!v) return null;
|
||||||
return {
|
return {
|
||||||
speed: v.playbackRate,
|
speed: v.playbackRate,
|
||||||
preferred: !v.paused
|
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
|
||||||
|
),
|
||||||
|
forceLastSavedSpeedControlledBySiteRule: Boolean(
|
||||||
|
typeof tc === "object" &&
|
||||||
|
tc.activeSiteRule &&
|
||||||
|
tc.activeSiteRule.forceLastSavedSpeed !== undefined
|
||||||
|
)
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -12,6 +12,24 @@
|
|||||||
overflow: visible !important;
|
overflow: visible !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Native media controls can fullscreen the replaced <video> element without
|
||||||
|
calling the page's requestFullscreen method. A manual popover keeps the
|
||||||
|
controller in the browser top layer without nesting it inside <video>. */
|
||||||
|
.vsc-controller.vsc-fullscreen-popover {
|
||||||
|
position: fixed !important;
|
||||||
|
inset: auto !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
border: 0 !important;
|
||||||
|
background: transparent !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vsc-controller.vsc-fullscreen-popover::backdrop {
|
||||||
|
pointer-events: none !important;
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
.vsc-nosource {
|
.vsc-nosource {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|||||||
+2660
-443
@@ -5,62 +5,274 @@ var speederShared =
|
|||||||
var controllerUtils = speederShared.controllerUtils || {};
|
var controllerUtils = speederShared.controllerUtils || {};
|
||||||
var keyBindingUtils = speederShared.keyBindings || {};
|
var keyBindingUtils = speederShared.keyBindings || {};
|
||||||
var siteRuleUtils = speederShared.siteRules || {};
|
var siteRuleUtils = speederShared.siteRules || {};
|
||||||
|
var sharedSettingsDefaults =
|
||||||
|
typeof vscGetSettingsDefaults === "function"
|
||||||
|
? vscGetSettingsDefaults()
|
||||||
|
: null;
|
||||||
|
|
||||||
function getPrimaryVideoElement() {
|
function getSharedDefault(key, fallback) {
|
||||||
if (!tc.mediaElements || tc.mediaElements.length === 0) return null;
|
if (
|
||||||
for (var i = 0; i < tc.mediaElements.length; i++) {
|
sharedSettingsDefaults &&
|
||||||
var el = tc.mediaElements[i];
|
Object.prototype.hasOwnProperty.call(sharedSettingsDefaults, key)
|
||||||
if (el && !el.paused) return el;
|
) {
|
||||||
|
var value = sharedSettingsDefaults[key];
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(function(item) {
|
||||||
|
return item && typeof item === "object"
|
||||||
|
? Object.assign({}, item)
|
||||||
|
: item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return value;
|
||||||
}
|
}
|
||||||
return tc.mediaElements[0];
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrimaryVideoElement(mediaElements) {
|
||||||
|
var candidates = Array.isArray(mediaElements)
|
||||||
|
? mediaElements
|
||||||
|
: tc.mediaElements;
|
||||||
|
if (!candidates || candidates.length === 0) return null;
|
||||||
|
|
||||||
|
var best = null;
|
||||||
|
var bestScore = -Infinity;
|
||||||
|
candidates.forEach(function(el, index) {
|
||||||
|
if (!el || !el.vsc || !el.isConnected) return;
|
||||||
|
|
||||||
|
var rect = null;
|
||||||
|
try {
|
||||||
|
rect = el.getBoundingClientRect();
|
||||||
|
} catch (_error) {}
|
||||||
|
|
||||||
|
var width = rect && Number(rect.width) > 0 ? Number(rect.width) : 0;
|
||||||
|
var height = rect && Number(rect.height) > 0 ? Number(rect.height) : 0;
|
||||||
|
var win = el.ownerDocument && el.ownerDocument.defaultView;
|
||||||
|
var viewportWidth = (win && win.innerWidth) || width;
|
||||||
|
var viewportHeight = (win && win.innerHeight) || height;
|
||||||
|
var visibleWidth = rect
|
||||||
|
? Math.max(0, Math.min(rect.right, viewportWidth) - Math.max(rect.left, 0))
|
||||||
|
: 0;
|
||||||
|
var visibleHeight = rect
|
||||||
|
? Math.max(0, Math.min(rect.bottom, viewportHeight) - Math.max(rect.top, 0))
|
||||||
|
: 0;
|
||||||
|
var visibleArea = visibleWidth * visibleHeight;
|
||||||
|
var visuallyAvailable = visibleArea > 0;
|
||||||
|
try {
|
||||||
|
var computed = win && win.getComputedStyle(el);
|
||||||
|
if (
|
||||||
|
computed &&
|
||||||
|
(computed.display === "none" ||
|
||||||
|
computed.visibility === "hidden" ||
|
||||||
|
Number(computed.opacity) === 0)
|
||||||
|
) {
|
||||||
|
visuallyAvailable = false;
|
||||||
|
}
|
||||||
|
} catch (_error) {}
|
||||||
|
|
||||||
|
var score = visibleArea;
|
||||||
|
if (visuallyAvailable) score += 1e12;
|
||||||
|
if (!el.paused && !el.ended) {
|
||||||
|
score += visuallyAvailable ? 1e11 : 1e6;
|
||||||
|
}
|
||||||
|
if (el.vsc.lastActivityAt) {
|
||||||
|
score += Math.max(0, 1e9 - (Date.now() - el.vsc.lastActivityAt));
|
||||||
|
}
|
||||||
|
if (el.ended) score -= 1e11;
|
||||||
|
// Stable tie-break: prefer the most recently registered media element.
|
||||||
|
score += index / 1000;
|
||||||
|
|
||||||
|
if (score > bestScore) {
|
||||||
|
best = el;
|
||||||
|
bestScore = score;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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 = {
|
var tc = {
|
||||||
settings: {
|
settings: {
|
||||||
lastSpeed: 1.0,
|
lastSpeed: getSharedDefault("lastSpeed", 1.0),
|
||||||
enabled: true,
|
enabled: getSharedDefault("enabled", true),
|
||||||
speeds: {},
|
speeds: {},
|
||||||
rememberSpeed: false,
|
rememberSpeed: getSharedDefault("rememberSpeed", false),
|
||||||
forceLastSavedSpeed: false,
|
forceLastSavedSpeed: getSharedDefault("forceLastSavedSpeed", false),
|
||||||
audioBoolean: false,
|
audioBoolean: getSharedDefault("audioBoolean", false),
|
||||||
startHidden: false,
|
showAmbientLoopControls: getSharedDefault(
|
||||||
hideWithYouTubeControls: false,
|
"showAmbientLoopControls",
|
||||||
hideWithControls: false,
|
false
|
||||||
hideWithControlsTimer: 2.0,
|
),
|
||||||
controllerLocation: "top-left",
|
startHidden: getSharedDefault("startHidden", false),
|
||||||
controllerOpacity: 0.3,
|
hideWithYouTubeControls: getSharedDefault(
|
||||||
controllerMarginTop: 0,
|
"hideWithYouTubeControls",
|
||||||
controllerMarginRight: 0,
|
false
|
||||||
controllerMarginBottom: 65,
|
),
|
||||||
controllerMarginLeft: 0,
|
hideWithControls: getSharedDefault("hideWithControls", false),
|
||||||
keyBindings: [],
|
hideWithControlsTimer: getSharedDefault("hideWithControlsTimer", 2.0),
|
||||||
siteRules: [],
|
controllerLocation: getSharedDefault("controllerLocation", "top-left"),
|
||||||
controllerButtons: ["rewind", "slower", "faster", "advance", "display"],
|
controllerOpacity: getSharedDefault("controllerOpacity", 0.3),
|
||||||
|
controllerMarginTop: getSharedDefault("controllerMarginTop", 0),
|
||||||
|
controllerMarginRight: getSharedDefault("controllerMarginRight", 0),
|
||||||
|
controllerMarginBottom: getSharedDefault("controllerMarginBottom", 65),
|
||||||
|
controllerMarginLeft: getSharedDefault("controllerMarginLeft", 0),
|
||||||
|
shortcutTargetMode: getSharedDefault("shortcutTargetMode", "closest"),
|
||||||
|
keyBindings: getSharedDefault("keyBindings", []),
|
||||||
|
siteRules: getSharedDefault("siteRules", []),
|
||||||
|
controllerButtons: getSharedDefault("controllerButtons", [
|
||||||
|
"rewind",
|
||||||
|
"slower",
|
||||||
|
"faster",
|
||||||
|
"advance",
|
||||||
|
"display"
|
||||||
|
]),
|
||||||
defaultLogLevel: 3,
|
defaultLogLevel: 3,
|
||||||
logLevel: 3,
|
logLevel: 3,
|
||||||
enableSubtitleNudge: false,
|
enableSubtitleNudge: getSharedDefault("enableSubtitleNudge", false),
|
||||||
subtitleNudgeEnabledByDefault: true,
|
subtitleNudgeEnabledByDefault: getSharedDefault(
|
||||||
subtitleNudgeInterval: 50, // Default 50ms balances subtitle tracking with CPU cost
|
"subtitleNudgeEnabledByDefault",
|
||||||
subtitleNudgeAmount: 0.001,
|
true
|
||||||
|
),
|
||||||
|
subtitleNudgeInterval: getSharedDefault("subtitleNudgeInterval", 250),
|
||||||
|
subtitleNudgeAmount: getSharedDefault("subtitleNudgeAmount", 0.001),
|
||||||
customButtonIcons: {}
|
customButtonIcons: {}
|
||||||
},
|
},
|
||||||
mediaElements: [],
|
mediaElements: [],
|
||||||
isNudging: false,
|
isNudging: false,
|
||||||
pendingLastSpeedSave: null,
|
pendingLastSpeedSave: null,
|
||||||
pendingLastSpeedValue: null,
|
pendingLastSpeedValue: null,
|
||||||
|
lastSpeedSaveRetries: 0,
|
||||||
|
lastSpeedWriteInFlight: false,
|
||||||
|
lastSpeedWriteValue: null,
|
||||||
|
pendingRememberedSpeedsSave: null,
|
||||||
|
rememberedSpeedsSaveRetries: 0,
|
||||||
|
rememberedSpeedsWriteEpoch: 0,
|
||||||
|
rememberedSpeedsWriteInFlight: null,
|
||||||
|
rememberedSpeedsResetAt: 0,
|
||||||
|
speedAccessTimes: {},
|
||||||
persistedLastSpeed: 1.0,
|
persistedLastSpeed: 1.0,
|
||||||
activeSiteRule: null,
|
activeSiteRule: null,
|
||||||
siteRuleBase: null
|
tabPaused: false,
|
||||||
|
siteRuleBase: null,
|
||||||
|
runtimeSettingsHydrated: false,
|
||||||
|
pendingMediaCandidates: [],
|
||||||
|
settingsReloadRetries: 0,
|
||||||
|
lastPointerPosition: 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;
|
function isSpeederActiveForCurrentPage() {
|
||||||
var MAX_SPEED = 16;
|
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_MIN = 0.25;
|
||||||
var YT_NATIVE_MAX = 2.0;
|
var YT_NATIVE_MAX = 2.0;
|
||||||
var YT_NATIVE_STEP = 0.05;
|
var YT_NATIVE_STEP = 0.05;
|
||||||
var vscObservedRoots = new WeakSet();
|
var vscObservedRoots = new WeakSet();
|
||||||
|
var vscObservedRootList = [];
|
||||||
var vscConnectedScannedRoots = new WeakSet();
|
var vscConnectedScannedRoots = new WeakSet();
|
||||||
var vscInitializedDocuments = new Set();
|
var vscInitializedDocuments = new WeakSet();
|
||||||
|
var vscSourceObjectIds = new WeakMap();
|
||||||
|
var vscNextSourceObjectId = 1;
|
||||||
var requestIdle =
|
var requestIdle =
|
||||||
typeof window.requestIdleCallback === "function"
|
typeof window.requestIdleCallback === "function"
|
||||||
? window.requestIdleCallback.bind(window)
|
? window.requestIdleCallback.bind(window)
|
||||||
@@ -126,21 +338,25 @@ var controllerLocationStyles = {
|
|||||||
|
|
||||||
/* `label` fallback only when ui-icons has no path for the action. */
|
/* `label` fallback only when ui-icons has no path for the action. */
|
||||||
var controllerButtonDefs = {
|
var controllerButtonDefs = {
|
||||||
rewind: { label: "", className: "rw" },
|
rewind: { label: "", name: "Rewind", className: "rw" },
|
||||||
slower: { label: "", className: "" },
|
slower: { label: "", name: "Decrease speed", className: "" },
|
||||||
faster: { label: "", className: "" },
|
faster: { label: "", name: "Increase speed", className: "" },
|
||||||
advance: { label: "", className: "rw" },
|
advance: { label: "", name: "Advance", className: "rw" },
|
||||||
display: { label: "", className: "hideButton" },
|
display: {
|
||||||
reset: { label: "\u21BB", className: "" },
|
label: "",
|
||||||
fast: { label: "", className: "" },
|
name: "Show or hide controller",
|
||||||
nudge: { label: "", className: "" },
|
className: "hideButton"
|
||||||
pause: { label: "", className: "" },
|
},
|
||||||
muted: { label: "", className: "" },
|
reset: { label: "\u21BB", name: "Reset speed", className: "" },
|
||||||
louder: { label: "", className: "" },
|
fast: { label: "", name: "Toggle preferred speed", className: "" },
|
||||||
softer: { label: "", className: "" },
|
nudge: { label: "", name: "Toggle subtitle nudge", className: "" },
|
||||||
mark: { label: "", className: "" },
|
pause: { label: "", name: "Play or pause", className: "" },
|
||||||
jump: { label: "", className: "" },
|
muted: { label: "", name: "Mute or unmute", className: "" },
|
||||||
settings: { label: "", 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) {
|
function createDefaultBinding(action, code, value) {
|
||||||
@@ -383,9 +599,11 @@ function captureSiteRuleBase() {
|
|||||||
rememberSpeed: tc.settings.rememberSpeed,
|
rememberSpeed: tc.settings.rememberSpeed,
|
||||||
forceLastSavedSpeed: tc.settings.forceLastSavedSpeed,
|
forceLastSavedSpeed: tc.settings.forceLastSavedSpeed,
|
||||||
audioBoolean: tc.settings.audioBoolean,
|
audioBoolean: tc.settings.audioBoolean,
|
||||||
|
showAmbientLoopControls: tc.settings.showAmbientLoopControls,
|
||||||
controllerOpacity: tc.settings.controllerOpacity,
|
controllerOpacity: tc.settings.controllerOpacity,
|
||||||
controllerMarginTop: tc.settings.controllerMarginTop,
|
controllerMarginTop: tc.settings.controllerMarginTop,
|
||||||
controllerMarginBottom: tc.settings.controllerMarginBottom,
|
controllerMarginBottom: tc.settings.controllerMarginBottom,
|
||||||
|
shortcutTargetMode: tc.settings.shortcutTargetMode,
|
||||||
enableSubtitleNudge: tc.settings.enableSubtitleNudge,
|
enableSubtitleNudge: tc.settings.enableSubtitleNudge,
|
||||||
subtitleNudgeEnabledByDefault: tc.settings.subtitleNudgeEnabledByDefault,
|
subtitleNudgeEnabledByDefault: tc.settings.subtitleNudgeEnabledByDefault,
|
||||||
subtitleNudgeInterval: tc.settings.subtitleNudgeInterval,
|
subtitleNudgeInterval: tc.settings.subtitleNudgeInterval,
|
||||||
@@ -410,9 +628,11 @@ function resetSettingsFromSiteRuleBase() {
|
|||||||
tc.settings.rememberSpeed = base.rememberSpeed;
|
tc.settings.rememberSpeed = base.rememberSpeed;
|
||||||
tc.settings.forceLastSavedSpeed = base.forceLastSavedSpeed;
|
tc.settings.forceLastSavedSpeed = base.forceLastSavedSpeed;
|
||||||
tc.settings.audioBoolean = base.audioBoolean;
|
tc.settings.audioBoolean = base.audioBoolean;
|
||||||
|
tc.settings.showAmbientLoopControls = base.showAmbientLoopControls;
|
||||||
tc.settings.controllerOpacity = base.controllerOpacity;
|
tc.settings.controllerOpacity = base.controllerOpacity;
|
||||||
tc.settings.controllerMarginTop = base.controllerMarginTop;
|
tc.settings.controllerMarginTop = base.controllerMarginTop;
|
||||||
tc.settings.controllerMarginBottom = base.controllerMarginBottom;
|
tc.settings.controllerMarginBottom = base.controllerMarginBottom;
|
||||||
|
tc.settings.shortcutTargetMode = base.shortcutTargetMode;
|
||||||
tc.settings.enableSubtitleNudge = base.enableSubtitleNudge;
|
tc.settings.enableSubtitleNudge = base.enableSubtitleNudge;
|
||||||
tc.settings.subtitleNudgeEnabledByDefault = base.subtitleNudgeEnabledByDefault;
|
tc.settings.subtitleNudgeEnabledByDefault = base.subtitleNudgeEnabledByDefault;
|
||||||
tc.settings.subtitleNudgeInterval = base.subtitleNudgeInterval;
|
tc.settings.subtitleNudgeInterval = base.subtitleNudgeInterval;
|
||||||
@@ -521,7 +741,11 @@ function normalizeStoredBinding(binding, fallbackCode) {
|
|||||||
action: binding.action,
|
action: binding.action,
|
||||||
code: null,
|
code: null,
|
||||||
disabled: true,
|
disabled: true,
|
||||||
value: Number(binding.value),
|
value: keyBindingUtils.sanitizeActionValue(
|
||||||
|
binding.action,
|
||||||
|
binding.value,
|
||||||
|
0
|
||||||
|
),
|
||||||
force: String(binding.force) === "true" ? "true" : "false",
|
force: String(binding.force) === "true" ? "true" : "false",
|
||||||
predefined: Boolean(binding.predefined)
|
predefined: Boolean(binding.predefined)
|
||||||
};
|
};
|
||||||
@@ -535,8 +759,13 @@ function normalizeStoredBinding(binding, fallbackCode) {
|
|||||||
var normalized = {
|
var normalized = {
|
||||||
action: binding.action,
|
action: binding.action,
|
||||||
code: normalizedCode,
|
code: normalizedCode,
|
||||||
|
shiftKey: binding.shiftKey === true,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
value: Number(binding.value),
|
value: keyBindingUtils.sanitizeActionValue(
|
||||||
|
binding.action,
|
||||||
|
binding.value,
|
||||||
|
0
|
||||||
|
),
|
||||||
force: String(binding.force) === "true" ? "true" : "false",
|
force: String(binding.force) === "true" ? "true" : "false",
|
||||||
predefined: Boolean(binding.predefined)
|
predefined: Boolean(binding.predefined)
|
||||||
};
|
};
|
||||||
@@ -554,7 +783,20 @@ function sanitizeSpeed(speed, fallback) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getVideoSourceKey(video) {
|
function getVideoSourceKey(video) {
|
||||||
return (video && (video.currentSrc || video.src)) || "unknown_src";
|
if (!video) return "unknown_src";
|
||||||
|
|
||||||
|
var sourceObject = video.srcObject;
|
||||||
|
if (
|
||||||
|
sourceObject &&
|
||||||
|
(typeof sourceObject === "object" || typeof sourceObject === "function")
|
||||||
|
) {
|
||||||
|
if (!vscSourceObjectIds.has(sourceObject)) {
|
||||||
|
vscSourceObjectIds.set(sourceObject, vscNextSourceObjectId++);
|
||||||
|
}
|
||||||
|
return "src_object:" + vscSourceObjectIds.get(sourceObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
return video.currentSrc || video.src || "unknown_src";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getControllerTargetSpeed(video) {
|
function getControllerTargetSpeed(video) {
|
||||||
@@ -589,21 +831,30 @@ function getRememberedSpeed(video) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Force is deliberately global and must outrank stale per-source memory.
|
||||||
|
if (tc.settings.forceLastSavedSpeed && isValidSpeed(tc.settings.lastSpeed)) {
|
||||||
|
return tc.settings.lastSpeed;
|
||||||
|
}
|
||||||
|
|
||||||
var sourceKey = getVideoSourceKey(video);
|
var sourceKey = getVideoSourceKey(video);
|
||||||
if (sourceKey !== "unknown_src") {
|
if (sourceKey !== "unknown_src") {
|
||||||
var videoSpeed = tc.settings.speeds[sourceKey];
|
var videoSpeed = tc.settings.speeds[sourceKey];
|
||||||
if (isValidSpeed(videoSpeed)) return videoSpeed;
|
if (isValidSpeed(videoSpeed)) return videoSpeed;
|
||||||
}
|
}
|
||||||
if (tc.settings.forceLastSavedSpeed && isValidSpeed(tc.settings.lastSpeed)) {
|
|
||||||
return tc.settings.lastSpeed;
|
// No per-source entry yet — fall back to the most recently used speed.
|
||||||
}
|
// This ensures e.g. YouTube Shorts carry the user's chosen speed to the
|
||||||
if (tc.settings.rememberSpeed && isValidSpeed(tc.settings.lastSpeed)) {
|
// next Short even when that exact source URL hasn't been visited before.
|
||||||
|
if (isValidSpeed(tc.settings.lastSpeed)) {
|
||||||
return tc.settings.lastSpeed;
|
return tc.settings.lastSpeed;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDesiredSpeed(video) {
|
function getDesiredSpeed(video) {
|
||||||
|
if (tc.settings.forceLastSavedSpeed && isValidSpeed(tc.settings.lastSpeed)) {
|
||||||
|
return tc.settings.lastSpeed;
|
||||||
|
}
|
||||||
return getControllerTargetSpeed(video) || getRememberedSpeed(video) || 1.0;
|
return getControllerTargetSpeed(video) || getRememberedSpeed(video) || 1.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -774,24 +1025,227 @@ function updateSubtitleNudgeIndicator(video) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function flushPendingLastSpeed() {
|
||||||
|
tc.pendingLastSpeedSave = null;
|
||||||
|
if (tc.lastSpeedWriteInFlight) return;
|
||||||
|
|
||||||
|
var speedToPersist = tc.pendingLastSpeedValue;
|
||||||
|
tc.pendingLastSpeedValue = null;
|
||||||
|
|
||||||
|
if (!isValidSpeed(speedToPersist) || tc.persistedLastSpeed === speedToPersist) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tc.lastSpeedWriteInFlight = true;
|
||||||
|
tc.lastSpeedWriteValue = speedToPersist;
|
||||||
|
chrome.storage.sync.set({ lastSpeed: speedToPersist }, function() {
|
||||||
|
tc.lastSpeedWriteInFlight = false;
|
||||||
|
tc.lastSpeedWriteValue = null;
|
||||||
|
var retryDelay = 250;
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
if (tc.lastSpeedSaveRetries < 3) {
|
||||||
|
tc.lastSpeedSaveRetries += 1;
|
||||||
|
if (!isValidSpeed(tc.pendingLastSpeedValue)) {
|
||||||
|
tc.pendingLastSpeedValue = speedToPersist;
|
||||||
|
}
|
||||||
|
retryDelay = 500 * tc.lastSpeedSaveRetries;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tc.lastSpeedSaveRetries = 0;
|
||||||
|
tc.persistedLastSpeed = speedToPersist;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isValidSpeed(tc.pendingLastSpeedValue) &&
|
||||||
|
tc.pendingLastSpeedValue !== tc.persistedLastSpeed &&
|
||||||
|
tc.pendingLastSpeedSave === null
|
||||||
|
) {
|
||||||
|
tc.pendingLastSpeedSave = setTimeout(
|
||||||
|
flushPendingLastSpeed,
|
||||||
|
retryDelay
|
||||||
|
);
|
||||||
|
} else if (tc.pendingLastSpeedValue === tc.persistedLastSpeed) {
|
||||||
|
tc.pendingLastSpeedValue = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function schedulePersistLastSpeed(speed) {
|
function schedulePersistLastSpeed(speed) {
|
||||||
if (!isValidSpeed(speed)) return;
|
if (!isValidSpeed(speed)) return;
|
||||||
|
|
||||||
tc.pendingLastSpeedValue = speed;
|
tc.pendingLastSpeedValue = speed;
|
||||||
if (tc.pendingLastSpeedSave !== null) return;
|
if (tc.pendingLastSpeedSave !== null || tc.lastSpeedWriteInFlight) return;
|
||||||
|
tc.pendingLastSpeedSave = setTimeout(flushPendingLastSpeed, 250);
|
||||||
|
}
|
||||||
|
|
||||||
tc.pendingLastSpeedSave = setTimeout(function() {
|
var MAX_PERSISTED_SOURCE_SPEEDS = 200;
|
||||||
var speedToPersist = tc.pendingLastSpeedValue;
|
|
||||||
tc.pendingLastSpeedSave = null;
|
|
||||||
|
|
||||||
if (!isValidSpeed(speedToPersist) || tc.persistedLastSpeed === speedToPersist) {
|
function isPersistableSourceKey(sourceKey) {
|
||||||
|
return Boolean(
|
||||||
|
sourceKey &&
|
||||||
|
sourceKey !== "unknown_src" &&
|
||||||
|
sourceKey.indexOf("src_object:") !== 0 &&
|
||||||
|
sourceKey.indexOf("blob:") !== 0 &&
|
||||||
|
sourceKey.length <= 2048
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRememberedSpeedsPayload() {
|
||||||
|
var entries = Object.keys(tc.settings.speeds)
|
||||||
|
.filter(function(sourceKey) {
|
||||||
|
return (
|
||||||
|
isPersistableSourceKey(sourceKey) &&
|
||||||
|
isValidSpeed(Number(tc.settings.speeds[sourceKey]))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map(function(sourceKey) {
|
||||||
|
return {
|
||||||
|
sourceKey: sourceKey,
|
||||||
|
speed: Number(tc.settings.speeds[sourceKey]),
|
||||||
|
updatedAt: Number(tc.speedAccessTimes[sourceKey]) || 0
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort(function(a, b) {
|
||||||
|
return b.updatedAt - a.updatedAt;
|
||||||
|
})
|
||||||
|
.slice(0, MAX_PERSISTED_SOURCE_SPEEDS);
|
||||||
|
|
||||||
|
var payload = {};
|
||||||
|
entries.forEach(function(entry) {
|
||||||
|
payload[entry.sourceKey] = {
|
||||||
|
speed: entry.speed,
|
||||||
|
updatedAt: entry.updatedAt
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconcileRememberedSpeedsAfterStaleWrite() {
|
||||||
|
var repairEpoch = tc.rememberedSpeedsWriteEpoch;
|
||||||
|
chrome.storage.local.get({ rememberedSpeeds: {} }, function(localStorage) {
|
||||||
|
if (
|
||||||
|
chrome.runtime.lastError ||
|
||||||
|
repairEpoch !== tc.rememberedSpeedsWriteEpoch
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
mergeRememberedSpeeds(localStorage && localStorage.rememberedSpeeds);
|
||||||
|
var repairedPayload = buildRememberedSpeedsPayload();
|
||||||
|
chrome.storage.local.set({ rememberedSpeeds: repairedPayload });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
chrome.storage.sync.set({ lastSpeed: speedToPersist }, function() {
|
function schedulePersistRememberedSpeeds(forceSave) {
|
||||||
|
if (!tc.settings.rememberSpeed && forceSave !== true) return;
|
||||||
|
if (tc.pendingRememberedSpeedsSave !== null) return;
|
||||||
|
if (tc.rememberedSpeedsWriteInFlight) {
|
||||||
|
tc.pendingRememberedSpeedsSave = setTimeout(function() {
|
||||||
|
tc.pendingRememberedSpeedsSave = null;
|
||||||
|
schedulePersistRememberedSpeeds(true);
|
||||||
|
}, 100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tc.pendingRememberedSpeedsSave = setTimeout(function() {
|
||||||
|
tc.pendingRememberedSpeedsSave = null;
|
||||||
|
var currentPayload = buildRememberedSpeedsPayload();
|
||||||
|
var writeEpoch = tc.rememberedSpeedsWriteEpoch;
|
||||||
|
tc.rememberedSpeedsWriteInFlight = {
|
||||||
|
epoch: writeEpoch,
|
||||||
|
payload: null
|
||||||
|
};
|
||||||
|
|
||||||
|
// Merge at write time so independent frame content scripts do not erase
|
||||||
|
// each other's more recent source entries.
|
||||||
|
chrome.storage.local.get({ rememberedSpeeds: {} }, function(localStorage) {
|
||||||
|
if (writeEpoch !== tc.rememberedSpeedsWriteEpoch) {
|
||||||
|
tc.rememberedSpeedsWriteInFlight = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
tc.rememberedSpeedsWriteInFlight = null;
|
||||||
|
if (tc.rememberedSpeedsSaveRetries < 3) {
|
||||||
|
tc.rememberedSpeedsSaveRetries += 1;
|
||||||
|
schedulePersistRememberedSpeeds(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var merged = {};
|
||||||
|
var existing =
|
||||||
|
localStorage &&
|
||||||
|
localStorage.rememberedSpeeds &&
|
||||||
|
typeof localStorage.rememberedSpeeds === "object"
|
||||||
|
? localStorage.rememberedSpeeds
|
||||||
|
: {};
|
||||||
|
|
||||||
|
[existing, currentPayload].forEach(function(sourceMap) {
|
||||||
|
Object.keys(sourceMap).forEach(function(sourceKey) {
|
||||||
|
var entry = sourceMap[sourceKey];
|
||||||
|
if (
|
||||||
|
!isPersistableSourceKey(sourceKey) ||
|
||||||
|
!entry ||
|
||||||
|
!isValidSpeed(Number(entry.speed)) ||
|
||||||
|
(tc.rememberedSpeedsResetAt > 0 &&
|
||||||
|
(Number(entry.updatedAt) || 0) <= tc.rememberedSpeedsResetAt)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var previous = merged[sourceKey];
|
||||||
|
if (
|
||||||
|
!previous ||
|
||||||
|
Number(entry.updatedAt) >= Number(previous.updatedAt)
|
||||||
|
) {
|
||||||
|
merged[sourceKey] = {
|
||||||
|
speed: Number(entry.speed),
|
||||||
|
updatedAt: Number(entry.updatedAt) || 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var pruned = {};
|
||||||
|
Object.keys(merged)
|
||||||
|
.sort(function(a, b) {
|
||||||
|
return merged[b].updatedAt - merged[a].updatedAt;
|
||||||
|
})
|
||||||
|
.slice(0, MAX_PERSISTED_SOURCE_SPEEDS)
|
||||||
|
.forEach(function(sourceKey) {
|
||||||
|
pruned[sourceKey] = merged[sourceKey];
|
||||||
|
});
|
||||||
|
tc.rememberedSpeedsWriteInFlight.payload = pruned;
|
||||||
|
chrome.storage.local.set({ rememberedSpeeds: pruned }, function() {
|
||||||
|
var writeBecameStale =
|
||||||
|
writeEpoch !== tc.rememberedSpeedsWriteEpoch;
|
||||||
|
tc.rememberedSpeedsWriteInFlight = null;
|
||||||
|
if (writeBecameStale) {
|
||||||
|
reconcileRememberedSpeedsAfterStaleWrite();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
if (tc.rememberedSpeedsSaveRetries < 3) {
|
||||||
|
tc.rememberedSpeedsSaveRetries += 1;
|
||||||
|
schedulePersistRememberedSpeeds(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tc.rememberedSpeedsSaveRetries = 0;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
tc.persistedLastSpeed = speedToPersist;
|
}, 500);
|
||||||
}, 250);
|
}
|
||||||
|
|
||||||
|
function rememberSourceSpeed(video, speed) {
|
||||||
|
if (!tc.settings.rememberSpeed || !isValidSpeed(speed)) return;
|
||||||
|
var sourceKey = getVideoSourceKey(video);
|
||||||
|
if (sourceKey === "unknown_src") return;
|
||||||
|
tc.settings.speeds[sourceKey] = Number(speed);
|
||||||
|
tc.speedAccessTimes[sourceKey] = Math.max(
|
||||||
|
Date.now(),
|
||||||
|
tc.rememberedSpeedsResetAt + 1
|
||||||
|
);
|
||||||
|
if (isPersistableSourceKey(sourceKey)) {
|
||||||
|
schedulePersistRememberedSpeeds();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function suppressNextNudgeRateChanges(controller, count) {
|
function suppressNextNudgeRateChanges(controller, count) {
|
||||||
@@ -829,29 +1283,34 @@ function resolveTargetSpeed(video) {
|
|||||||
function applySourceTransitionPolicy(video, forceUpdate) {
|
function applySourceTransitionPolicy(video, forceUpdate) {
|
||||||
if (!video || !video.vsc) return;
|
if (!video || !video.vsc) return;
|
||||||
|
|
||||||
|
// A same-document navigation can change the applicable playback rule before
|
||||||
|
// or after the player swaps its source. Always resolve the URL first.
|
||||||
|
if (!reapplySiteRulesAndControllerGeometry()) return;
|
||||||
|
if (!video.vsc) return;
|
||||||
|
|
||||||
var sourceKey = getVideoSourceKey(video);
|
var sourceKey = getVideoSourceKey(video);
|
||||||
if (!forceUpdate && video.vsc.mediaSourceKey === sourceKey) return;
|
if (!forceUpdate && video.vsc.mediaSourceKey === sourceKey) return;
|
||||||
|
|
||||||
video.vsc.mediaSourceKey = sourceKey;
|
video.vsc.mediaSourceKey = sourceKey;
|
||||||
|
|
||||||
var desiredSpeed =
|
var rememberedSpeed = getRememberedSpeed(video);
|
||||||
tc.settings.rememberSpeed || tc.settings.forceLastSavedSpeed
|
var desiredSpeed = isValidSpeed(rememberedSpeed) ? rememberedSpeed : 1.0;
|
||||||
? sanitizeSpeed(tc.settings.lastSpeed, 1.0)
|
|
||||||
: 1.0;
|
|
||||||
|
|
||||||
video.vsc.targetSpeed = desiredSpeed;
|
video.vsc.targetSpeed = desiredSpeed;
|
||||||
video.vsc.targetSpeedSourceKey = sourceKey;
|
video.vsc.targetSpeedSourceKey = sourceKey;
|
||||||
|
video.vsc.targetSpeedOrigin = isValidSpeed(rememberedSpeed)
|
||||||
|
? "policy"
|
||||||
|
: "source-reset";
|
||||||
if (video.vsc.speedIndicator) {
|
if (video.vsc.speedIndicator) {
|
||||||
video.vsc.speedIndicator.textContent = desiredSpeed.toFixed(2);
|
video.vsc.speedIndicator.textContent = desiredSpeed.toFixed(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isValidSpeed(rememberedSpeed)) {
|
||||||
|
extendSpeedRestoreWindow(video);
|
||||||
|
}
|
||||||
if (Math.abs(video.playbackRate - desiredSpeed) > 0.01) {
|
if (Math.abs(video.playbackRate - desiredSpeed) > 0.01) {
|
||||||
setSpeed(video, desiredSpeed, false, false);
|
setSpeed(video, desiredSpeed, false, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same-tab SPA (e.g. YouTube watch → Shorts): URL can change while remember-speed
|
|
||||||
// already ran on src mutation — re-apply margins / location / opacity for new rules.
|
|
||||||
reapplySiteRulesAndControllerGeometry();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function extendSpeedRestoreWindow(video, duration) {
|
function extendSpeedRestoreWindow(video, duration) {
|
||||||
@@ -864,6 +1323,91 @@ function extendSpeedRestoreWindow(video, duration) {
|
|||||||
video.vsc.speedRestoreUntil = Math.max(currentUntil, restoreUntil);
|
video.vsc.speedRestoreUntil = Math.max(currentUntil, restoreUntil);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearAllSpeedRestoreEnforcement() {
|
||||||
|
tc.mediaElements.slice().forEach(function(video) {
|
||||||
|
if (!video || !video.vsc) return;
|
||||||
|
video.vsc.speedRestoreUntil = 0;
|
||||||
|
if (video.vsc.restoreSpeedTimer) {
|
||||||
|
clearTimeout(video.vsc.restoreSpeedTimer);
|
||||||
|
video.vsc.restoreSpeedTimer = null;
|
||||||
|
}
|
||||||
|
clearSpeedVerification(video.vsc);
|
||||||
|
if (video.vsc.targetSpeedOrigin === "policy") {
|
||||||
|
video.vsc.targetSpeed = null;
|
||||||
|
video.vsc.targetSpeedSourceKey = null;
|
||||||
|
video.vsc.targetSpeedOrigin = null;
|
||||||
|
video.vsc.stopSubtitleNudge();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSpeedVerification(controller) {
|
||||||
|
if (!controller) return;
|
||||||
|
if (controller.speedVerificationTimer) {
|
||||||
|
clearTimeout(controller.speedVerificationTimer);
|
||||||
|
}
|
||||||
|
controller.speedVerificationTimer = null;
|
||||||
|
controller.speedVerificationTarget = null;
|
||||||
|
controller.speedVerificationAttempts = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSpeedVerification(video, desiredSpeed, resetAttempts) {
|
||||||
|
if (!video || !video.vsc || !isValidSpeed(desiredSpeed)) return;
|
||||||
|
var controller = video.vsc;
|
||||||
|
if (
|
||||||
|
resetAttempts === true ||
|
||||||
|
controller.speedVerificationTarget !== desiredSpeed
|
||||||
|
) {
|
||||||
|
controller.speedVerificationAttempts = 0;
|
||||||
|
}
|
||||||
|
controller.speedVerificationTarget = desiredSpeed;
|
||||||
|
if (controller.speedVerificationTimer) {
|
||||||
|
clearTimeout(controller.speedVerificationTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.speedVerificationTimer = setTimeout(function verifySpeed() {
|
||||||
|
if (!video.vsc || video.vsc !== controller) return;
|
||||||
|
controller.speedVerificationTimer = null;
|
||||||
|
var actualSpeed;
|
||||||
|
try {
|
||||||
|
actualSpeed = Number(video.playbackRate);
|
||||||
|
} catch (error) {
|
||||||
|
actualSpeed = NaN;
|
||||||
|
}
|
||||||
|
if (isValidSpeed(actualSpeed) && Math.abs(actualSpeed - desiredSpeed) <= 0.01) {
|
||||||
|
clearSpeedVerification(controller);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var restoreIsActive =
|
||||||
|
Number(controller.speedRestoreUntil) > Date.now();
|
||||||
|
var policyIsActive =
|
||||||
|
tc.settings.forceLastSavedSpeed ||
|
||||||
|
(tc.settings.rememberSpeed && controller.targetSpeedOrigin === "policy");
|
||||||
|
if (
|
||||||
|
(!restoreIsActive && !policyIsActive) ||
|
||||||
|
controller.speedVerificationAttempts >= 3
|
||||||
|
) {
|
||||||
|
clearSpeedVerification(controller);
|
||||||
|
log(
|
||||||
|
`Player rejected playbackRate ${desiredSpeed.toFixed(2)} after verification`,
|
||||||
|
3
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.speedVerificationAttempts += 1;
|
||||||
|
rememberPendingRateChange(video, desiredSpeed, false);
|
||||||
|
try {
|
||||||
|
video.playbackRate = desiredSpeed;
|
||||||
|
} catch (error) {
|
||||||
|
controller.pendingRateChange = null;
|
||||||
|
log(`Playback speed verification failed: ${error.message}`, 3);
|
||||||
|
}
|
||||||
|
scheduleSpeedVerification(video, desiredSpeed, false);
|
||||||
|
}, 125 * (Number(controller.speedVerificationAttempts) + 1));
|
||||||
|
}
|
||||||
|
|
||||||
function scheduleSpeedRestore(video, desiredSpeed, reason) {
|
function scheduleSpeedRestore(video, desiredSpeed, reason) {
|
||||||
if (!video || !video.vsc || !isValidSpeed(desiredSpeed)) return;
|
if (!video || !video.vsc || !isValidSpeed(desiredSpeed)) return;
|
||||||
|
|
||||||
@@ -888,11 +1432,12 @@ function scheduleSpeedRestore(video, desiredSpeed, reason) {
|
|||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function rememberPendingRateChange(video, speed) {
|
function rememberPendingRateChange(video, speed, shouldPersist) {
|
||||||
if (!video || !video.vsc || !isValidSpeed(speed)) return;
|
if (!video || !video.vsc || !isValidSpeed(speed)) return;
|
||||||
|
|
||||||
video.vsc.pendingRateChange = {
|
video.vsc.pendingRateChange = {
|
||||||
speed: Number(speed),
|
speed: Number(speed),
|
||||||
|
shouldPersist: shouldPersist === true,
|
||||||
expiresAt: Date.now() + 1000
|
expiresAt: Date.now() + 1000
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -923,7 +1468,8 @@ function matchesKeyBinding(binding, event) {
|
|||||||
binding.disabled !== true &&
|
binding.disabled !== true &&
|
||||||
typeof binding.code === "string" &&
|
typeof binding.code === "string" &&
|
||||||
binding.code.length > 0 &&
|
binding.code.length > 0 &&
|
||||||
binding.code === event.code
|
binding.code === event.code &&
|
||||||
|
(binding.shiftKey === true) === event.shiftKey
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -940,6 +1486,82 @@ function isMediaElement(node) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasInteractivePlayerChrome(node) {
|
||||||
|
if (!node || node.nodeName !== "VIDEO") return false;
|
||||||
|
if (node.controls === true) return true;
|
||||||
|
|
||||||
|
if (typeof node.closest === "function") {
|
||||||
|
var knownPlayer = node.closest(
|
||||||
|
".html5-video-player, .video-js, .jwplayer, .plyr, " +
|
||||||
|
"[class~='video-player'], [data-testid*='video-player'], " +
|
||||||
|
"media-player, amp-video"
|
||||||
|
);
|
||||||
|
if (knownPlayer) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ancestor = node.parentElement;
|
||||||
|
var videoRect = node.getBoundingClientRect();
|
||||||
|
var depth = 0;
|
||||||
|
while (ancestor && depth < 4) {
|
||||||
|
var ancestorRect = ancestor.getBoundingClientRect();
|
||||||
|
if (
|
||||||
|
videoRect.width > 0 &&
|
||||||
|
videoRect.height > 0 &&
|
||||||
|
(ancestorRect.width > Math.max(videoRect.width * 1.75, videoRect.width + 160) ||
|
||||||
|
ancestorRect.height > Math.max(videoRect.height * 1.75, videoRect.height + 160))
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(videoRect.width <= 0 || videoRect.height <= 0) &&
|
||||||
|
depth > 1
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var controls = ancestor.querySelectorAll(
|
||||||
|
"button, [role='button'], input[type='range'], [role='slider']"
|
||||||
|
);
|
||||||
|
for (var i = 0; i < Math.min(controls.length, 40); i += 1) {
|
||||||
|
var control = controls[i];
|
||||||
|
var description = [
|
||||||
|
control.getAttribute("aria-label"),
|
||||||
|
control.getAttribute("title"),
|
||||||
|
control.getAttribute("data-title-no-tooltip"),
|
||||||
|
control.className
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
if (
|
||||||
|
/(^|\b)(play|pause|mute|volume|seek|captions?|subtitles?|fullscreen|full screen)(\b|$)/i.test(
|
||||||
|
description
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_error) {}
|
||||||
|
ancestor = ancestor.parentElement;
|
||||||
|
depth += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAmbientLoopMedia(node) {
|
||||||
|
var hasAmbientSignature = Boolean(
|
||||||
|
node &&
|
||||||
|
node.nodeName === "VIDEO" &&
|
||||||
|
node.autoplay === true &&
|
||||||
|
(node.muted === true || node.defaultMuted === true) &&
|
||||||
|
node.loop === true &&
|
||||||
|
node.playsInline === true &&
|
||||||
|
node.controls !== true
|
||||||
|
);
|
||||||
|
if (!hasAmbientSignature || tc.settings.showAmbientLoopControls) return false;
|
||||||
|
return !hasInteractivePlayerChrome(node);
|
||||||
|
}
|
||||||
|
|
||||||
function hasUsableMediaSource(node) {
|
function hasUsableMediaSource(node) {
|
||||||
if (!isMediaElement(node) || !node.isConnected) return false;
|
if (!isMediaElement(node) || !node.isConnected) return false;
|
||||||
if (node.currentSrc || node.src || node.srcObject) return true;
|
if (node.currentSrc || node.src || node.srcObject) return true;
|
||||||
@@ -965,8 +1587,80 @@ function hasUsableMediaSource(node) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getControllerStructureSignature() {
|
||||||
|
return JSON.stringify({
|
||||||
|
buttons: Array.isArray(tc.settings.controllerButtons)
|
||||||
|
? tc.settings.controllerButtons
|
||||||
|
: [],
|
||||||
|
startHidden: tc.settings.startHidden === true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function ensureController(node, parent) {
|
function ensureController(node, parent) {
|
||||||
if (!isMediaElement(node) || node.vsc) return node && node.vsc;
|
if (!tc.runtimeSettingsHydrated) {
|
||||||
|
if (
|
||||||
|
node &&
|
||||||
|
(node.nodeName === "VIDEO" || node.nodeName === "AUDIO") &&
|
||||||
|
!tc.pendingMediaCandidates.some(function(candidate) {
|
||||||
|
return candidate.node === node;
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
tc.pendingMediaCandidates.push({ node: node, parent: parent });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!isMediaElement(node)) return null;
|
||||||
|
if (typeof tc.videoController === "undefined") defineVideoController();
|
||||||
|
|
||||||
|
// href selects site rules; re-run on every new/usable media so all runtime
|
||||||
|
// paths agree on activation and effective settings.
|
||||||
|
applySiteRuleOverrides();
|
||||||
|
if (!isSpeederActiveForCurrentPage()) {
|
||||||
|
if (node.vsc) removeController(node);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (isAmbientLoopMedia(node)) {
|
||||||
|
if (node.vsc) removeController(node);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
node.vsc &&
|
||||||
|
(
|
||||||
|
!node.vsc.div ||
|
||||||
|
typeof node.vsc.remove !== "function" ||
|
||||||
|
node.vsc.structureSignature !== getControllerStructureSignature()
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
removeController(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.vsc) {
|
||||||
|
var existingController = node.vsc;
|
||||||
|
var hasSource = hasUsableMediaSource(node);
|
||||||
|
if (existingController.div) {
|
||||||
|
existingController.div.classList.toggle("vsc-nosource", !hasSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
var fullscreenElement = getFullscreenElement(node.ownerDocument);
|
||||||
|
var normalMount = getControllerMount(node) || parent || node.parentNode;
|
||||||
|
if (normalMount) existingController.normalControllerMount = normalMount;
|
||||||
|
if (
|
||||||
|
fullscreenElement &&
|
||||||
|
(fullscreenElement === node ||
|
||||||
|
isComposedDescendant(node, fullscreenElement))
|
||||||
|
) {
|
||||||
|
syncControllerFullscreenMount(existingController);
|
||||||
|
} else if (normalMount) {
|
||||||
|
remountControllerHost(existingController, normalMount);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasSource) {
|
||||||
|
applySourceTransitionPolicy(node, false);
|
||||||
|
}
|
||||||
|
return existingController;
|
||||||
|
}
|
||||||
|
|
||||||
if (!hasUsableMediaSource(node)) {
|
if (!hasUsableMediaSource(node)) {
|
||||||
log(
|
log(
|
||||||
`Deferring controller creation for ${node.tagName}: no usable source yet`,
|
`Deferring controller creation for ${node.tagName}: no usable source yet`,
|
||||||
@@ -975,26 +1669,74 @@ function ensureController(node, parent) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// href selects site rules; re-run on every new/usable media so margins/opacity match current URL.
|
|
||||||
applySiteRuleOverrides();
|
|
||||||
if (!siteRuleUtils.isSpeederActiveForSite(tc.settings.enabled, tc.activeSiteRule)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
refreshAllControllerGeometry();
|
refreshAllControllerGeometry();
|
||||||
|
|
||||||
log(
|
log(
|
||||||
`Creating controller for ${node.tagName}: ${node.src || node.currentSrc || "no src"}`,
|
`Creating controller for ${node.tagName}: ${node.src || node.currentSrc || "no src"}`,
|
||||||
4
|
4
|
||||||
);
|
);
|
||||||
node.vsc = new tc.videoController(
|
try {
|
||||||
node,
|
node.vsc = new tc.videoController(
|
||||||
parent || node.parentElement || node.parentNode
|
node,
|
||||||
);
|
parent || node.parentElement || node.parentNode
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
log(`Unable to create media controller: ${error.message}`, 2);
|
||||||
|
removeController(node);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!node.vsc || !node.vsc.div) {
|
||||||
|
log("Controller initialization returned without a control host", 3);
|
||||||
|
removeController(node);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
node.vsc &&
|
||||||
|
(tc.settings.rememberSpeed || tc.settings.forceLastSavedSpeed)
|
||||||
|
) {
|
||||||
|
scheduleSpeedVerification(node, node.vsc.targetSpeed, true);
|
||||||
|
}
|
||||||
|
if (node.vsc) syncControllerFullscreenMount(node.vsc);
|
||||||
return node.vsc;
|
return node.vsc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function flushPendingMediaCandidates() {
|
||||||
|
var pending = tc.pendingMediaCandidates.splice(0);
|
||||||
|
pending.forEach(function(candidate) {
|
||||||
|
if (!candidate.node || !candidate.node.isConnected) return;
|
||||||
|
ensureController(
|
||||||
|
candidate.node,
|
||||||
|
candidate.node.parentElement || candidate.parent || candidate.node.parentNode
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureControllerForMediaChild(node) {
|
||||||
|
if (!node || node.nodeType !== Node.ELEMENT_NODE) return null;
|
||||||
|
if (node.nodeName !== "SOURCE") return null;
|
||||||
|
|
||||||
|
var media = node.parentElement;
|
||||||
|
if (!isMediaElement(media)) return null;
|
||||||
|
return ensureController(media, media.parentElement || media.parentNode);
|
||||||
|
}
|
||||||
|
|
||||||
function removeController(node) {
|
function removeController(node) {
|
||||||
if (node && node.vsc) node.vsc.remove();
|
if (!node || !node.vsc) return;
|
||||||
|
var controller = node.vsc;
|
||||||
|
if (typeof controller.remove === "function") {
|
||||||
|
try {
|
||||||
|
controller.remove();
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
log(`Partial controller cleanup failed: ${error.message}`, 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (controller.div && typeof controller.div.remove === "function") {
|
||||||
|
controller.div.remove();
|
||||||
|
}
|
||||||
|
var index = tc.mediaElements.indexOf(node);
|
||||||
|
if (index !== -1) tc.mediaElements.splice(index, 1);
|
||||||
|
delete node.vsc;
|
||||||
}
|
}
|
||||||
|
|
||||||
function scanNodeForMedia(node, parent, added) {
|
function scanNodeForMedia(node, parent, added) {
|
||||||
@@ -1019,6 +1761,17 @@ function scanNodeForMedia(node, parent, added) {
|
|||||||
if (isMediaElement(node)) {
|
if (isMediaElement(node)) {
|
||||||
if (added) ensureController(node, parent);
|
if (added) ensureController(node, parent);
|
||||||
else removeController(node);
|
else removeController(node);
|
||||||
|
} else if (node.nodeName === "SOURCE") {
|
||||||
|
// Players such as Vidstack often connect an empty <video> and append its
|
||||||
|
// <source> later. Retry the owning video when that source becomes usable.
|
||||||
|
var owningMedia = isMediaElement(parent)
|
||||||
|
? parent
|
||||||
|
: node.parentElement;
|
||||||
|
if (owningMedia) {
|
||||||
|
ensureController(owningMedia, owningMedia.parentNode);
|
||||||
|
} else if (added) {
|
||||||
|
ensureControllerForMediaChild(node);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use querySelectorAll instead of recursive child walking — the browser's
|
// Use querySelectorAll instead of recursive child walking — the browser's
|
||||||
@@ -1042,21 +1795,6 @@ function scanNodeForMedia(node, parent, added) {
|
|||||||
observeRoot(node.shadowRoot);
|
observeRoot(node.shadowRoot);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deep-scan descendant elements for shadow roots we haven't observed yet.
|
|
||||||
// This catches custom elements (like archive.org's <play-av>) whose shadow
|
|
||||||
// roots were created before our attachShadow patch was installed.
|
|
||||||
if (added && typeof node.querySelectorAll === "function") {
|
|
||||||
try {
|
|
||||||
var allElements = node.querySelectorAll("*");
|
|
||||||
for (var j = 0; j < allElements.length; j++) {
|
|
||||||
if (allElements[j].shadowRoot && !vscObservedRoots.has(allElements[j].shadowRoot)) {
|
|
||||||
observeRoot(allElements[j].shadowRoot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// querySelectorAll may throw on detached or unusual nodes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getScanNodeForRoot(root) {
|
function getScanNodeForRoot(root) {
|
||||||
@@ -1086,9 +1824,53 @@ function scanRootForMedia(root) {
|
|||||||
var scanRoot = getScanNodeForRoot(root);
|
var scanRoot = getScanNodeForRoot(root);
|
||||||
if (!scanRoot) return;
|
if (!scanRoot) return;
|
||||||
scanNodeForMedia(scanRoot, root.host || scanRoot.parentNode || scanRoot, true);
|
scanNodeForMedia(scanRoot, root.host || scanRoot.parentNode || scanRoot, true);
|
||||||
if (root.nodeType === Node.DOCUMENT_NODE) {
|
}
|
||||||
attachIframeListeners(root);
|
|
||||||
|
function rescanOpenShadowRoots(root, visited, rescanObserved) {
|
||||||
|
var scanRoot = getScanNodeForRoot(root);
|
||||||
|
if (!scanRoot || typeof scanRoot.querySelectorAll !== "function") return;
|
||||||
|
var seen = visited || new WeakSet();
|
||||||
|
var hosts = [];
|
||||||
|
|
||||||
|
if (scanRoot.nodeType === Node.ELEMENT_NODE && scanRoot.shadowRoot) {
|
||||||
|
hosts.push(scanRoot);
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
var elements = scanRoot.querySelectorAll("*");
|
||||||
|
for (var i = 0; i < elements.length; i++) {
|
||||||
|
if (elements[i].shadowRoot) hosts.push(elements[i]);
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hosts.forEach(function(host) {
|
||||||
|
var shadowRoot = host.shadowRoot;
|
||||||
|
if (!shadowRoot || seen.has(shadowRoot)) return;
|
||||||
|
seen.add(shadowRoot);
|
||||||
|
if (rescanObserved !== false || !vscObservedRoots.has(shadowRoot)) {
|
||||||
|
observeRoot(shadowRoot);
|
||||||
|
}
|
||||||
|
rescanOpenShadowRoots(shadowRoot, seen, rescanObserved);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function rescanObservedMediaRoots(doc) {
|
||||||
|
var retainedRoots = [];
|
||||||
|
vscObservedRootList.forEach(function(root) {
|
||||||
|
if (!root || root.nodeType === Node.DOCUMENT_NODE) return;
|
||||||
|
var connected = Boolean(root.host ? root.host.isConnected : root.isConnected);
|
||||||
|
if (!connected) return;
|
||||||
|
retainedRoots.push(root);
|
||||||
|
if (
|
||||||
|
!doc ||
|
||||||
|
root.ownerDocument === doc ||
|
||||||
|
(root.host && root.host.ownerDocument === doc)
|
||||||
|
) {
|
||||||
|
scanRootForMedia(root);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
vscObservedRootList = retainedRoots;
|
||||||
}
|
}
|
||||||
|
|
||||||
function observeRoot(root) {
|
function observeRoot(root) {
|
||||||
@@ -1106,12 +1888,16 @@ function observeRoot(root) {
|
|||||||
|
|
||||||
if (!vscObservedRoots.has(root)) {
|
if (!vscObservedRoots.has(root)) {
|
||||||
vscObservedRoots.add(root);
|
vscObservedRoots.add(root);
|
||||||
|
vscObservedRootList.push(root);
|
||||||
setupListener(root);
|
setupListener(root);
|
||||||
attachMutationObserver(root);
|
attachMutationObserver(root);
|
||||||
attachMediaDetectionListeners(root);
|
attachMediaDetectionListeners(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isConnected && !vscConnectedScannedRoots.has(root)) {
|
if (
|
||||||
|
isConnected &&
|
||||||
|
(!vscConnectedScannedRoots.has(root) || isShadowRootNode(root))
|
||||||
|
) {
|
||||||
vscConnectedScannedRoots.add(root);
|
vscConnectedScannedRoots.add(root);
|
||||||
if (rootMayContainMedia(root)) {
|
if (rootMayContainMedia(root)) {
|
||||||
scanRootForMedia(root);
|
scanRootForMedia(root);
|
||||||
@@ -1143,6 +1929,109 @@ function patchAttachShadow() {
|
|||||||
window.vscAttachShadowPatched = true;
|
window.vscAttachShadowPatched = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function installPageShadowBridge() {
|
||||||
|
if (window.vscPageShadowBridgeRequested) return;
|
||||||
|
|
||||||
|
if (!window.vscPageBridgeListenersAttached) {
|
||||||
|
window.vscPageBridgeListenersAttached = true;
|
||||||
|
document.addEventListener(
|
||||||
|
"speeder-shadow-root-attached",
|
||||||
|
function(event) {
|
||||||
|
var host = event.target;
|
||||||
|
if (!host || !host.shadowRoot) return;
|
||||||
|
try {
|
||||||
|
observeRoot(host.shadowRoot);
|
||||||
|
} catch (error) {
|
||||||
|
log(`Unable to observe page-created shadow root: ${error.message}`, 3);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
document.addEventListener(
|
||||||
|
"speeder-location-changed",
|
||||||
|
function() {
|
||||||
|
scheduleNavigationRescan();
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startBoundedShadowFallback() {
|
||||||
|
if (window.vscBoundedShadowFallbackStarted) return;
|
||||||
|
window.vscBoundedShadowFallbackStarted = true;
|
||||||
|
window.vscBoundedShadowFallbackTimers = [3000, 10000, 30000].map(
|
||||||
|
function(delay) {
|
||||||
|
return setTimeout(function() {
|
||||||
|
requestIdle(
|
||||||
|
function() {
|
||||||
|
if (
|
||||||
|
!document.body ||
|
||||||
|
(delay !== 3000 && window.vscPageShadowBridgeLoaded)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rescanOpenShadowRoots(document, undefined, false);
|
||||||
|
},
|
||||||
|
{ timeout: 2000 }
|
||||||
|
);
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBridgeFailure() {
|
||||||
|
window.vscPageShadowBridgeRequested = false;
|
||||||
|
window.vscPageShadowBridgeRetries =
|
||||||
|
(Number(window.vscPageShadowBridgeRetries) || 0) + 1;
|
||||||
|
startBoundedShadowFallback();
|
||||||
|
if (window.vscPageShadowBridgeRetries > 3) return;
|
||||||
|
clearTimeout(window.vscPageShadowBridgeRetryTimer);
|
||||||
|
window.vscPageShadowBridgeRetryTimer = setTimeout(function() {
|
||||||
|
installPageShadowBridge();
|
||||||
|
}, 250 * window.vscPageShadowBridgeRetries);
|
||||||
|
}
|
||||||
|
|
||||||
|
var parent = document.documentElement || document.head;
|
||||||
|
if (!parent) {
|
||||||
|
if (!window.vscPageShadowBridgeRetryAttached) {
|
||||||
|
window.vscPageShadowBridgeRetryAttached = true;
|
||||||
|
var retryBridgeInstall = function() {
|
||||||
|
if (document.documentElement || document.head) {
|
||||||
|
document.removeEventListener("readystatechange", retryBridgeInstall);
|
||||||
|
document.removeEventListener("DOMContentLoaded", retryBridgeInstall);
|
||||||
|
installPageShadowBridge();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("readystatechange", retryBridgeInstall);
|
||||||
|
document.addEventListener("DOMContentLoaded", retryBridgeInstall);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var bridge = document.createElement("script");
|
||||||
|
bridge.src = chrome.runtime.getURL("content/shadow-bridge.js");
|
||||||
|
bridge.async = false;
|
||||||
|
bridge.addEventListener("load", function() {
|
||||||
|
bridge.remove();
|
||||||
|
window.vscPageShadowBridgeLoaded = true;
|
||||||
|
window.vscPageShadowBridgeRetries = 0;
|
||||||
|
clearTimeout(window.vscPageShadowBridgeRetryTimer);
|
||||||
|
});
|
||||||
|
bridge.addEventListener("error", function() {
|
||||||
|
bridge.remove();
|
||||||
|
handleBridgeFailure();
|
||||||
|
});
|
||||||
|
window.vscPageShadowBridgeRequested = true;
|
||||||
|
parent.appendChild(bridge);
|
||||||
|
// The bridge reports future roots, but cannot report roots that existed
|
||||||
|
// before the extension loaded. Keep one bounded initial discovery pass.
|
||||||
|
startBoundedShadowFallback();
|
||||||
|
} catch (error) {
|
||||||
|
handleBridgeFailure();
|
||||||
|
log(`Unable to install page shadow bridge: ${error.message}`, 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Log levels */
|
/* Log levels */
|
||||||
function log(message, level) {
|
function log(message, level) {
|
||||||
verbosity = tc.settings.logLevel;
|
verbosity = tc.settings.logLevel;
|
||||||
@@ -1160,123 +2049,398 @@ function log(message, level) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Patch attachShadow immediately — before any async operations — so we
|
function hydrateRuntimeSettings(rawStorage, options) {
|
||||||
// catch shadow roots created while chrome.storage.sync.get is pending.
|
var config = options || {};
|
||||||
// Sites like archive.org create Lit/LitElement shadow DOMs during page load;
|
var storage =
|
||||||
// waiting for the storage callback would miss them entirely.
|
typeof vscExpandStoredSettings === "function"
|
||||||
patchAttachShadow();
|
? vscExpandStoredSettings(rawStorage || {})
|
||||||
|
: Object.assign({}, tc.settings, rawStorage || {});
|
||||||
chrome.storage.sync.get(tc.settings, function(storage) {
|
|
||||||
var storedBindings = Array.isArray(storage.keyBindings)
|
var storedBindings = Array.isArray(storage.keyBindings)
|
||||||
? storage.keyBindings
|
? storage.keyBindings
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
tc.settings.keyBindings = storedBindings
|
tc.settings.keyBindings = storedBindings
|
||||||
.map((binding) => normalizeStoredBinding(binding))
|
.map(function(binding) {
|
||||||
|
return normalizeStoredBinding(binding);
|
||||||
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
if (tc.settings.keyBindings.length === 0) {
|
if (tc.settings.keyBindings.length === 0) {
|
||||||
tc.settings.keyBindings = defaultKeyBindings(storage);
|
tc.settings.keyBindings = defaultKeyBindings(storage);
|
||||||
tc.settings.version = "0.5.3";
|
|
||||||
chrome.storage.sync.set({
|
|
||||||
keyBindings: tc.settings.keyBindings,
|
|
||||||
version: tc.settings.version,
|
|
||||||
rememberSpeed: tc.settings.rememberSpeed,
|
|
||||||
forceLastSavedSpeed: tc.settings.forceLastSavedSpeed,
|
|
||||||
audioBoolean: tc.settings.audioBoolean,
|
|
||||||
startHidden: tc.settings.startHidden,
|
|
||||||
enabled: tc.settings.enabled,
|
|
||||||
controllerLocation: tc.settings.controllerLocation,
|
|
||||||
controllerOpacity: tc.settings.controllerOpacity
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
tc.settings.lastSpeed = Number(storage.lastSpeed);
|
|
||||||
if (!isValidSpeed(tc.settings.lastSpeed) && tc.settings.lastSpeed !== 1.0) {
|
var unsavedLastSpeed = isValidSpeed(tc.pendingLastSpeedValue)
|
||||||
|
? Number(tc.pendingLastSpeedValue)
|
||||||
|
: tc.lastSpeedWriteInFlight && isValidSpeed(tc.lastSpeedWriteValue)
|
||||||
|
? Number(tc.lastSpeedWriteValue)
|
||||||
|
: null;
|
||||||
|
var hasPendingLastSpeed = Boolean(
|
||||||
|
config.preservePendingLastSpeed && isValidSpeed(unsavedLastSpeed)
|
||||||
|
);
|
||||||
|
var storedLastSpeed = Number(storage.lastSpeed);
|
||||||
|
var nextLastSpeed = hasPendingLastSpeed
|
||||||
|
? unsavedLastSpeed
|
||||||
|
: storedLastSpeed;
|
||||||
|
|
||||||
|
if (!isValidSpeed(nextLastSpeed)) {
|
||||||
log(`Invalid lastSpeed detected: ${storage.lastSpeed}, resetting to 1.0`, 3);
|
log(`Invalid lastSpeed detected: ${storage.lastSpeed}, resetting to 1.0`, 3);
|
||||||
tc.settings.lastSpeed = 1.0;
|
nextLastSpeed = 1.0;
|
||||||
tc.persistedLastSpeed = 1.0;
|
if (!hasPendingLastSpeed) {
|
||||||
chrome.storage.sync.set({ lastSpeed: 1.0 });
|
chrome.storage.sync.set({ lastSpeed: 1.0 });
|
||||||
} else if (!isValidSpeed(tc.settings.lastSpeed)) {
|
}
|
||||||
tc.settings.lastSpeed = 1.0;
|
|
||||||
}
|
}
|
||||||
tc.persistedLastSpeed = tc.settings.lastSpeed;
|
tc.settings.lastSpeed = nextLastSpeed;
|
||||||
tc.settings.rememberSpeed = Boolean(storage.rememberSpeed);
|
if (isValidSpeed(storedLastSpeed)) {
|
||||||
tc.settings.forceLastSavedSpeed = Boolean(storage.forceLastSavedSpeed);
|
tc.persistedLastSpeed = storedLastSpeed;
|
||||||
tc.settings.audioBoolean = Boolean(storage.audioBoolean);
|
} else if (!hasPendingLastSpeed) {
|
||||||
tc.settings.enabled = Boolean(storage.enabled);
|
tc.persistedLastSpeed = nextLastSpeed;
|
||||||
tc.settings.startHidden = Boolean(storage.startHidden);
|
}
|
||||||
|
|
||||||
|
tc.settings.rememberSpeed = storage.rememberSpeed === true;
|
||||||
|
tc.settings.forceLastSavedSpeed = storage.forceLastSavedSpeed === true;
|
||||||
|
tc.settings.audioBoolean = storage.audioBoolean === true;
|
||||||
|
tc.settings.showAmbientLoopControls =
|
||||||
|
storage.showAmbientLoopControls === true;
|
||||||
|
tc.settings.enabled = storage.enabled !== false;
|
||||||
|
tc.settings.startHidden = storage.startHidden === true;
|
||||||
tc.settings.hideWithControls =
|
tc.settings.hideWithControls =
|
||||||
typeof storage.hideWithControls !== "undefined"
|
typeof storage.hideWithControls !== "undefined"
|
||||||
? Boolean(storage.hideWithControls)
|
? Boolean(storage.hideWithControls)
|
||||||
: Boolean(storage.hideWithYouTubeControls);
|
: Boolean(storage.hideWithYouTubeControls);
|
||||||
tc.settings.hideWithControlsTimer =
|
tc.settings.hideWithControlsTimer = Math.min(
|
||||||
Math.min(15, Math.max(0.1, Number(storage.hideWithControlsTimer) || 2.0));
|
15,
|
||||||
|
Math.max(0.1, Number(storage.hideWithControlsTimer) || 2.0)
|
||||||
|
);
|
||||||
tc.settings.hideWithYouTubeControls = tc.settings.hideWithControls;
|
tc.settings.hideWithYouTubeControls = tc.settings.hideWithControls;
|
||||||
tc.settings.controllerLocation = normalizeControllerLocation(
|
tc.settings.controllerLocation = normalizeControllerLocation(
|
||||||
storage.controllerLocation
|
storage.controllerLocation
|
||||||
);
|
);
|
||||||
tc.settings.controllerOpacity = Number(storage.controllerOpacity);
|
tc.settings.controllerOpacity = Number(storage.controllerOpacity);
|
||||||
|
if (!Number.isFinite(tc.settings.controllerOpacity)) {
|
||||||
|
tc.settings.controllerOpacity = getSharedDefault("controllerOpacity", 0.3);
|
||||||
|
}
|
||||||
tc.settings.controllerMarginTop = normalizeControllerMarginPx(
|
tc.settings.controllerMarginTop = normalizeControllerMarginPx(
|
||||||
storage.controllerMarginTop,
|
storage.controllerMarginTop,
|
||||||
0
|
getSharedDefault("controllerMarginTop", 0)
|
||||||
);
|
);
|
||||||
tc.settings.controllerMarginRight = normalizeControllerMarginPx(
|
tc.settings.controllerMarginRight = normalizeControllerMarginPx(
|
||||||
0,
|
storage.controllerMarginRight,
|
||||||
0
|
getSharedDefault("controllerMarginRight", 0)
|
||||||
);
|
);
|
||||||
tc.settings.controllerMarginBottom = normalizeControllerMarginPx(
|
tc.settings.controllerMarginBottom = normalizeControllerMarginPx(
|
||||||
storage.controllerMarginBottom,
|
storage.controllerMarginBottom,
|
||||||
typeof storage.controllerMarginBottom !== "undefined" ? 0 : 65
|
getSharedDefault("controllerMarginBottom", 65)
|
||||||
);
|
);
|
||||||
tc.settings.controllerMarginLeft = normalizeControllerMarginPx(
|
tc.settings.controllerMarginLeft = normalizeControllerMarginPx(
|
||||||
0,
|
storage.controllerMarginLeft,
|
||||||
0
|
getSharedDefault("controllerMarginLeft", 0)
|
||||||
);
|
);
|
||||||
|
tc.settings.shortcutTargetMode =
|
||||||
|
storage.shortcutTargetMode === "all" ? "all" : "closest";
|
||||||
tc.settings.siteRules = Array.isArray(storage.siteRules)
|
tc.settings.siteRules = Array.isArray(storage.siteRules)
|
||||||
? storage.siteRules
|
? storage.siteRules.map(function(rule) {
|
||||||
: [];
|
return rule && typeof rule === "object" ? Object.assign({}, rule) : rule;
|
||||||
|
})
|
||||||
|
: getSharedDefault("siteRules", []);
|
||||||
tc.settings.controllerButtons = Array.isArray(storage.controllerButtons)
|
tc.settings.controllerButtons = Array.isArray(storage.controllerButtons)
|
||||||
? storage.controllerButtons
|
? storage.controllerButtons.slice()
|
||||||
: tc.settings.controllerButtons;
|
: getSharedDefault("controllerButtons", [
|
||||||
|
"rewind",
|
||||||
tc.settings.enableSubtitleNudge =
|
"slower",
|
||||||
typeof storage.enableSubtitleNudge !== "undefined"
|
"faster",
|
||||||
? Boolean(storage.enableSubtitleNudge)
|
"advance",
|
||||||
: tc.settings.enableSubtitleNudge;
|
"display"
|
||||||
|
]);
|
||||||
|
tc.settings.enableSubtitleNudge = Boolean(storage.enableSubtitleNudge);
|
||||||
tc.settings.subtitleNudgeEnabledByDefault =
|
tc.settings.subtitleNudgeEnabledByDefault =
|
||||||
typeof storage.subtitleNudgeEnabledByDefault !== "undefined"
|
typeof storage.subtitleNudgeEnabledByDefault !== "undefined"
|
||||||
? Boolean(storage.subtitleNudgeEnabledByDefault)
|
? Boolean(storage.subtitleNudgeEnabledByDefault)
|
||||||
: tc.settings.subtitleNudgeEnabledByDefault;
|
: getSharedDefault("subtitleNudgeEnabledByDefault", true);
|
||||||
tc.settings.subtitleNudgeInterval = Math.min(
|
tc.settings.subtitleNudgeInterval = Math.min(
|
||||||
1000,
|
1000,
|
||||||
Math.max(10, Number(storage.subtitleNudgeInterval) || 50)
|
Math.max(250, Number(storage.subtitleNudgeInterval) || 250)
|
||||||
);
|
);
|
||||||
tc.settings.subtitleNudgeAmount =
|
tc.settings.subtitleNudgeAmount =
|
||||||
Number(storage.subtitleNudgeAmount) || tc.settings.subtitleNudgeAmount;
|
Number(storage.subtitleNudgeAmount) ||
|
||||||
|
getSharedDefault("subtitleNudgeAmount", 0.001);
|
||||||
|
|
||||||
var addedDefaultBinding = false;
|
var addedDefaultBinding = false;
|
||||||
addedDefaultBinding =
|
addedDefaultBinding =
|
||||||
ensureDefaultKeyBinding(
|
ensureDefaultKeyBinding("display", "KeyV", 0) || addedDefaultBinding;
|
||||||
"display",
|
|
||||||
"KeyV",
|
|
||||||
0
|
|
||||||
) || addedDefaultBinding;
|
|
||||||
addedDefaultBinding =
|
addedDefaultBinding =
|
||||||
ensureDefaultKeyBinding("move", "KeyP", 0) || addedDefaultBinding;
|
ensureDefaultKeyBinding("move", "KeyP", 0) || addedDefaultBinding;
|
||||||
addedDefaultBinding =
|
addedDefaultBinding =
|
||||||
ensureDefaultKeyBinding("toggleSubtitleNudge", "KeyN", 0) ||
|
ensureDefaultKeyBinding("toggleSubtitleNudge", "KeyN", 0) ||
|
||||||
addedDefaultBinding;
|
addedDefaultBinding;
|
||||||
|
|
||||||
if (addedDefaultBinding) {
|
if (addedDefaultBinding && config.persistMissingBindings !== false) {
|
||||||
chrome.storage.sync.set({ keyBindings: tc.settings.keyBindings });
|
chrome.storage.sync.set({ keyBindings: tc.settings.keyBindings });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capture only freshly hydrated global values. Calling this while a current
|
||||||
|
// route's overrides are still applied would promote those overrides into the
|
||||||
|
// global base and leak them into the next SPA route.
|
||||||
captureSiteRuleBase();
|
captureSiteRuleBase();
|
||||||
|
return storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceRememberedSpeeds(rememberedSpeeds) {
|
||||||
|
Object.keys(tc.settings.speeds).forEach(function(sourceKey) {
|
||||||
|
if (isPersistableSourceKey(sourceKey)) {
|
||||||
|
delete tc.settings.speeds[sourceKey];
|
||||||
|
delete tc.speedAccessTimes[sourceKey];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var sourceMap =
|
||||||
|
rememberedSpeeds && typeof rememberedSpeeds === "object"
|
||||||
|
? rememberedSpeeds
|
||||||
|
: {};
|
||||||
|
Object.keys(sourceMap).forEach(function(sourceKey) {
|
||||||
|
var storedEntry = sourceMap[sourceKey];
|
||||||
|
var storedSpeed =
|
||||||
|
storedEntry && typeof storedEntry === "object"
|
||||||
|
? Number(storedEntry.speed)
|
||||||
|
: Number(storedEntry);
|
||||||
|
var storedUpdatedAt =
|
||||||
|
storedEntry && typeof storedEntry === "object"
|
||||||
|
? Number(storedEntry.updatedAt) || 0
|
||||||
|
: 0;
|
||||||
|
if (
|
||||||
|
!isPersistableSourceKey(sourceKey) ||
|
||||||
|
!isValidSpeed(storedSpeed) ||
|
||||||
|
(tc.rememberedSpeedsResetAt > 0 &&
|
||||||
|
storedUpdatedAt <= tc.rememberedSpeedsResetAt)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tc.settings.speeds[sourceKey] = storedSpeed;
|
||||||
|
tc.speedAccessTimes[sourceKey] = storedUpdatedAt;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeRememberedSpeeds(rememberedSpeeds) {
|
||||||
|
var sourceMap =
|
||||||
|
rememberedSpeeds && typeof rememberedSpeeds === "object"
|
||||||
|
? rememberedSpeeds
|
||||||
|
: {};
|
||||||
|
Object.keys(sourceMap).forEach(function(sourceKey) {
|
||||||
|
var storedEntry = sourceMap[sourceKey];
|
||||||
|
var storedSpeed =
|
||||||
|
storedEntry && typeof storedEntry === "object"
|
||||||
|
? Number(storedEntry.speed)
|
||||||
|
: Number(storedEntry);
|
||||||
|
var storedUpdatedAt =
|
||||||
|
storedEntry && typeof storedEntry === "object"
|
||||||
|
? Number(storedEntry.updatedAt) || 0
|
||||||
|
: 0;
|
||||||
|
if (
|
||||||
|
!isPersistableSourceKey(sourceKey) ||
|
||||||
|
!isValidSpeed(storedSpeed) ||
|
||||||
|
(tc.rememberedSpeedsResetAt > 0 &&
|
||||||
|
storedUpdatedAt <= tc.rememberedSpeedsResetAt)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentUpdatedAt = Number(tc.speedAccessTimes[sourceKey]) || 0;
|
||||||
|
if (
|
||||||
|
!isValidSpeed(Number(tc.settings.speeds[sourceKey])) ||
|
||||||
|
storedUpdatedAt >= currentUpdatedAt
|
||||||
|
) {
|
||||||
|
tc.settings.speeds[sourceKey] = storedSpeed;
|
||||||
|
tc.speedAccessTimes[sourceKey] = storedUpdatedAt;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberedSpeedsPayloadMatches(sourceMap, expectedPayload) {
|
||||||
|
var actual = sourceMap && typeof sourceMap === "object" ? sourceMap : {};
|
||||||
|
var expected =
|
||||||
|
expectedPayload && typeof expectedPayload === "object"
|
||||||
|
? expectedPayload
|
||||||
|
: {};
|
||||||
|
var actualKeys = Object.keys(actual).filter(function(sourceKey) {
|
||||||
|
return isPersistableSourceKey(sourceKey);
|
||||||
|
});
|
||||||
|
var expectedKeys = Object.keys(expected);
|
||||||
|
if (actualKeys.length !== expectedKeys.length) return false;
|
||||||
|
|
||||||
|
return expectedKeys.every(function(sourceKey) {
|
||||||
|
var actualEntry = actual[sourceKey];
|
||||||
|
var expectedEntry = expected[sourceKey];
|
||||||
|
return Boolean(
|
||||||
|
actualEntry &&
|
||||||
|
expectedEntry &&
|
||||||
|
Number(actualEntry.speed) === Number(expectedEntry.speed) &&
|
||||||
|
(Number(actualEntry.updatedAt) || 0) ===
|
||||||
|
(Number(expectedEntry.updatedAt) || 0)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRememberedSpeedsInMemory(resetAt) {
|
||||||
|
tc.rememberedSpeedsWriteEpoch += 1;
|
||||||
|
if (tc.pendingRememberedSpeedsSave !== null) {
|
||||||
|
clearTimeout(tc.pendingRememberedSpeedsSave);
|
||||||
|
tc.pendingRememberedSpeedsSave = null;
|
||||||
|
}
|
||||||
|
tc.rememberedSpeedsSaveRetries = 0;
|
||||||
|
var preserveAfter = Number(resetAt) || 0;
|
||||||
|
Object.keys(tc.settings.speeds).forEach(function(sourceKey) {
|
||||||
|
if (!isPersistableSourceKey(sourceKey)) return;
|
||||||
|
if (
|
||||||
|
preserveAfter > 0 &&
|
||||||
|
(Number(tc.speedAccessTimes[sourceKey]) || 0) > preserveAfter
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
delete tc.settings.speeds[sourceKey];
|
||||||
|
delete tc.speedAccessTimes[sourceKey];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRememberedSpeedsResetMarker(value) {
|
||||||
|
var resetAt = Number(value) || 0;
|
||||||
|
if (resetAt <= tc.rememberedSpeedsResetAt) return false;
|
||||||
|
tc.rememberedSpeedsResetAt = resetAt;
|
||||||
|
clearRememberedSpeedsInMemory(resetAt);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCustomButtonIcons(customButtonIcons) {
|
||||||
|
tc.settings.customButtonIcons =
|
||||||
|
customButtonIcons && typeof customButtonIcons === "object"
|
||||||
|
? customButtonIcons
|
||||||
|
: {};
|
||||||
|
if (!tc.mediaElements || !tc.mediaElements.length) return;
|
||||||
|
|
||||||
|
tc.mediaElements.forEach(function(video) {
|
||||||
|
if (!video.vsc || !video.vsc.div) return;
|
||||||
|
var doc = video.ownerDocument;
|
||||||
|
var shadow = video.vsc.div.shadowRoot;
|
||||||
|
if (!shadow) return;
|
||||||
|
shadow.querySelectorAll("button[data-action]").forEach(function(btn) {
|
||||||
|
var act = btn.dataset.action;
|
||||||
|
if (!act) return;
|
||||||
|
var svg =
|
||||||
|
tc.settings.customButtonIcons &&
|
||||||
|
tc.settings.customButtonIcons[act] &&
|
||||||
|
tc.settings.customButtonIcons[act].svg;
|
||||||
|
vscClearElement(btn);
|
||||||
|
if (svg) {
|
||||||
|
var customWrap = vscCreateSvgWrap(doc, svg, "vsc-btn-icon");
|
||||||
|
if (customWrap) {
|
||||||
|
btn.appendChild(customWrap);
|
||||||
|
} else {
|
||||||
|
var customDef = controllerButtonDefs[act];
|
||||||
|
btn.textContent = (customDef && customDef.label) || "?";
|
||||||
|
}
|
||||||
|
} else if (typeof vscIconWrap === "function") {
|
||||||
|
var wrap = vscIconWrap(doc, act, 14);
|
||||||
|
if (wrap) {
|
||||||
|
btn.appendChild(wrap);
|
||||||
|
} else {
|
||||||
|
var defaultDef = controllerButtonDefs[act];
|
||||||
|
btn.textContent = (defaultDef && defaultDef.label) || "?";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var fallbackDef = controllerButtonDefs[act];
|
||||||
|
btn.textContent = (fallbackDef && fallbackDef.label) || "?";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
updateSubtitleNudgeIndicator(video);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRuntimeSettingsReload() {
|
||||||
|
var reloadGeneration =
|
||||||
|
(Number(window.vscSettingsReloadGeneration) || 0) + 1;
|
||||||
|
window.vscSettingsReloadGeneration = reloadGeneration;
|
||||||
|
clearTimeout(window.vscSettingsReloadTimer);
|
||||||
|
clearTimeout(window.vscSettingsReloadRetryTimer);
|
||||||
|
window.vscSettingsReloadTimer = setTimeout(function() {
|
||||||
|
chrome.storage.sync.get(null, function(rawStorage) {
|
||||||
|
if (reloadGeneration !== window.vscSettingsReloadGeneration) return;
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
if (tc.settingsReloadRetries < 3) {
|
||||||
|
tc.settingsReloadRetries += 1;
|
||||||
|
window.vscSettingsReloadRetryTimer = setTimeout(function() {
|
||||||
|
if (reloadGeneration === window.vscSettingsReloadGeneration) {
|
||||||
|
scheduleRuntimeSettingsReload();
|
||||||
|
}
|
||||||
|
}, 250 * tc.settingsReloadRetries);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tc.settingsReloadRetries = 0;
|
||||||
|
var wasForceEnabled = tc.settings.forceLastSavedSpeed === true;
|
||||||
|
var wasRememberEnabled = tc.settings.rememberSpeed === true;
|
||||||
|
hydrateRuntimeSettings(rawStorage || {}, {
|
||||||
|
preservePendingLastSpeed: true,
|
||||||
|
persistMissingBindings: false
|
||||||
|
});
|
||||||
|
applySiteRuleOverrides();
|
||||||
|
if (
|
||||||
|
(wasForceEnabled && !tc.settings.forceLastSavedSpeed) ||
|
||||||
|
(wasRememberEnabled && !tc.settings.rememberSpeed)
|
||||||
|
) {
|
||||||
|
clearAllSpeedRestoreEnforcement();
|
||||||
|
}
|
||||||
|
initializeWhenReady(document, true);
|
||||||
|
});
|
||||||
|
}, 75);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadInitialLocalSettings(attempt, callback) {
|
||||||
|
chrome.storage.local.get(
|
||||||
|
["customButtonIcons", "rememberedSpeeds", "rememberedSpeedsResetAt"],
|
||||||
|
function(localStorage) {
|
||||||
|
if (chrome.runtime.lastError && attempt < 3) {
|
||||||
|
setTimeout(function() {
|
||||||
|
loadInitialLocalSettings(attempt + 1, callback);
|
||||||
|
}, 100 * (attempt + 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback(chrome.runtime.lastError ? {} : localStorage || {});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch attachShadow immediately — before any async operations — so we
|
||||||
|
// catch shadow roots created while chrome.storage.sync.get is pending.
|
||||||
|
// Sites like archive.org create Lit/LitElement shadow DOMs during page load;
|
||||||
|
// waiting for the storage callback would miss them entirely.
|
||||||
|
patchAttachShadow();
|
||||||
|
installPageShadowBridge();
|
||||||
|
|
||||||
|
function loadInitialRuntimeSettings(attempt) {
|
||||||
|
chrome.storage.sync.get(null, function(rawStorage) {
|
||||||
|
if (chrome.runtime.lastError && attempt < 3) {
|
||||||
|
setTimeout(function() {
|
||||||
|
loadInitialRuntimeSettings(attempt + 1);
|
||||||
|
}, 100 * (attempt + 1));
|
||||||
|
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
|
// patchAttachShadow() is now called at top-level before this callback
|
||||||
// Add a listener for messages from the popup.
|
// Add a listener for messages from the popup.
|
||||||
// We use a global flag to ensure the listener is only attached once.
|
// We use a global flag to ensure the listener is only attached once.
|
||||||
if (!window.vscMessageListener) {
|
if (!window.vscMessageListener) {
|
||||||
chrome.runtime.onMessage.addListener(
|
chrome.runtime.onMessage.addListener(
|
||||||
function(request, sender, sendResponse) {
|
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") {
|
if (request.action === "rescan_page") {
|
||||||
log("Re-scan command received from popup.", 4);
|
log("Re-scan command received from popup.", 4);
|
||||||
initializeWhenReady(document, true);
|
initializeWhenReady(document, true);
|
||||||
@@ -1289,7 +2453,14 @@ chrome.storage.sync.get(tc.settings, function(storage) {
|
|||||||
var videoGs = getPrimaryVideoElement();
|
var videoGs = getPrimaryVideoElement();
|
||||||
if (!videoGs) return false;
|
if (!videoGs) return false;
|
||||||
sendResponse({
|
sendResponse({
|
||||||
speed: videoGs.playbackRate
|
speed: videoGs.playbackRate,
|
||||||
|
frameToken: tc.frameToken,
|
||||||
|
diagnostics: getDiagnosticsSnapshot(videoGs),
|
||||||
|
forceLastSavedSpeed: tc.settings.forceLastSavedSpeed === true,
|
||||||
|
forceLastSavedSpeedControlledBySiteRule: Boolean(
|
||||||
|
tc.activeSiteRule &&
|
||||||
|
tc.activeSiteRule.forceLastSavedSpeed !== undefined
|
||||||
|
)
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -1298,26 +2469,40 @@ chrome.storage.sync.get(tc.settings, function(storage) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (request.action === "set_force_last_saved_speed") {
|
if (request.action === "set_force_last_saved_speed") {
|
||||||
tc.settings.forceLastSavedSpeed = Boolean(request.enabled);
|
var forceEnabled = Boolean(request.enabled);
|
||||||
if (isValidSpeed(Number(request.speed))) {
|
if (tc.siteRuleBase) {
|
||||||
tc.settings.lastSpeed = Number(request.speed);
|
tc.siteRuleBase.forceLastSavedSpeed = forceEnabled;
|
||||||
}
|
}
|
||||||
|
// Site rules own the effective per-page value. The popup changes the
|
||||||
|
// global base, then this reapplication keeps both paths consistent.
|
||||||
|
applySiteRuleOverrides();
|
||||||
var forceVideo = getPrimaryVideoElement();
|
var forceVideo = getPrimaryVideoElement();
|
||||||
if (!forceVideo) return false;
|
|
||||||
if (tc.settings.forceLastSavedSpeed) {
|
if (tc.settings.forceLastSavedSpeed) {
|
||||||
tc.mediaElements.forEach(function(video) {
|
tc.mediaElements.forEach(function(video) {
|
||||||
if (!video || !video.vsc) return;
|
if (!video || !video.vsc) return;
|
||||||
setSpeed(video, tc.settings.lastSpeed, false, true);
|
setSpeed(video, tc.settings.lastSpeed, false, false);
|
||||||
|
video.vsc.targetSpeedOrigin = "policy";
|
||||||
extendSpeedRestoreWindow(video);
|
extendSpeedRestoreWindow(video);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
sendResponse({
|
sendResponse({
|
||||||
enabled: tc.settings.forceLastSavedSpeed,
|
enabled: tc.settings.forceLastSavedSpeed,
|
||||||
speed: forceVideo.playbackRate
|
speed: forceVideo ? forceVideo.playbackRate : null
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (request.action === "run_action") {
|
if (request.action === "run_action") {
|
||||||
|
if (
|
||||||
|
request.targetFrameToken &&
|
||||||
|
request.targetFrameToken !== tc.frameToken
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!isSpeederActiveForCurrentPage()
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
var value = request.value;
|
var value = request.value;
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null) {
|
||||||
value = getKeyBindings(request.actionName, "value");
|
value = getKeyBindings(request.actionName, "value");
|
||||||
@@ -1337,64 +2522,178 @@ chrome.storage.sync.get(tc.settings, function(storage) {
|
|||||||
// Set the flag to prevent adding the listener again.
|
// Set the flag to prevent adding the listener again.
|
||||||
window.vscMessageListener = true;
|
window.vscMessageListener = true;
|
||||||
}
|
}
|
||||||
chrome.storage.local.get(["customButtonIcons"], function(loc) {
|
loadInitialLocalSettings(0, function(loc) {
|
||||||
tc.settings.customButtonIcons =
|
applyCustomButtonIcons(loc && loc.customButtonIcons);
|
||||||
loc &&
|
|
||||||
loc.customButtonIcons &&
|
applyRememberedSpeedsResetMarker(
|
||||||
typeof loc.customButtonIcons === "object"
|
loc && loc.rememberedSpeedsResetAt
|
||||||
? loc.customButtonIcons
|
);
|
||||||
: {};
|
replaceRememberedSpeeds(loc && loc.rememberedSpeeds);
|
||||||
|
|
||||||
if (!window.vscCustomIconListener) {
|
if (!window.vscCustomIconListener) {
|
||||||
window.vscCustomIconListener = true;
|
window.vscCustomIconListener = true;
|
||||||
chrome.storage.onChanged.addListener(function(changes, area) {
|
chrome.storage.onChanged.addListener(function(changes, area) {
|
||||||
if (area !== "local" || !changes.customButtonIcons) return;
|
if (area === "sync") {
|
||||||
var nv = changes.customButtonIcons.newValue;
|
var changedKeys = Object.keys(changes || {});
|
||||||
tc.settings.customButtonIcons =
|
var isLastSpeedOnly =
|
||||||
nv && typeof nv === "object" ? nv : {};
|
changedKeys.length === 1 && changedKeys[0] === "lastSpeed";
|
||||||
if (tc.mediaElements && tc.mediaElements.length) {
|
if (isLastSpeedOnly) {
|
||||||
tc.mediaElements.forEach(function(video) {
|
var changedLastSpeed = Number(changes.lastSpeed.newValue);
|
||||||
if (!video.vsc || !video.vsc.div) return;
|
if (isValidSpeed(changedLastSpeed)) {
|
||||||
var doc = video.ownerDocument;
|
tc.persistedLastSpeed = changedLastSpeed;
|
||||||
var shadow = video.vsc.div.shadowRoot;
|
var newerLocalLastSpeed = isValidSpeed(tc.pendingLastSpeedValue)
|
||||||
if (!shadow) return;
|
? Number(tc.pendingLastSpeedValue)
|
||||||
shadow.querySelectorAll("button[data-action]").forEach(function(btn) {
|
: tc.lastSpeedWriteInFlight &&
|
||||||
var act = btn.dataset.action;
|
isValidSpeed(tc.lastSpeedWriteValue)
|
||||||
if (!act) return;
|
? Number(tc.lastSpeedWriteValue)
|
||||||
var svg =
|
: null;
|
||||||
tc.settings.customButtonIcons &&
|
if (
|
||||||
tc.settings.customButtonIcons[act] &&
|
isValidSpeed(newerLocalLastSpeed) &&
|
||||||
tc.settings.customButtonIcons[act].svg;
|
newerLocalLastSpeed !== changedLastSpeed
|
||||||
vscClearElement(btn);
|
) {
|
||||||
if (svg) {
|
return;
|
||||||
var cw = vscCreateSvgWrap(doc, svg, "vsc-btn-icon");
|
|
||||||
if (cw) {
|
|
||||||
btn.appendChild(cw);
|
|
||||||
} else {
|
|
||||||
var cdf = controllerButtonDefs[act];
|
|
||||||
btn.textContent = (cdf && cdf.label) || "?";
|
|
||||||
}
|
|
||||||
} else if (typeof vscIconWrap === "function") {
|
|
||||||
var wrap = vscIconWrap(doc, act, 14);
|
|
||||||
if (wrap) {
|
|
||||||
btn.appendChild(wrap);
|
|
||||||
} else {
|
|
||||||
var cdf = controllerButtonDefs[act];
|
|
||||||
btn.textContent = (cdf && cdf.label) || "?";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
var cdf2 = controllerButtonDefs[act];
|
|
||||||
btn.textContent = (cdf2 && cdf2.label) || "?";
|
|
||||||
}
|
}
|
||||||
});
|
tc.settings.lastSpeed = changedLastSpeed;
|
||||||
updateSubtitleNudgeIndicator(video);
|
if (tc.settings.forceLastSavedSpeed) {
|
||||||
});
|
tc.mediaElements.slice().forEach(applyRememberedSpeedPolicy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var managedKeys =
|
||||||
|
typeof vscGetManagedSyncKeys === "function"
|
||||||
|
? vscGetManagedSyncKeys()
|
||||||
|
: [
|
||||||
|
"enabled",
|
||||||
|
"rememberSpeed",
|
||||||
|
"forceLastSavedSpeed",
|
||||||
|
"siteRules"
|
||||||
|
];
|
||||||
|
if (
|
||||||
|
changedKeys.some(function(key) {
|
||||||
|
return managedKeys.indexOf(key) !== -1;
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
// Options persistence may emit a set followed by a remove. Re-read
|
||||||
|
// the final sparse state after the burst instead of reconstructing
|
||||||
|
// settings from partial change records.
|
||||||
|
scheduleRuntimeSettingsReload();
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (area !== "local") return;
|
||||||
|
if (changes.rememberedSpeedsResetAt) {
|
||||||
|
window.vscRememberedSpeedsGeneration =
|
||||||
|
(Number(window.vscRememberedSpeedsGeneration) || 0) + 1;
|
||||||
|
applyRememberedSpeedsResetMarker(
|
||||||
|
changes.rememberedSpeedsResetAt.newValue
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (changes.rememberedSpeeds) {
|
||||||
|
window.vscRememberedSpeedsGeneration =
|
||||||
|
(Number(window.vscRememberedSpeedsGeneration) || 0) + 1;
|
||||||
|
var changedRememberedSpeeds = changes.rememberedSpeeds.newValue;
|
||||||
|
var inFlightRememberedSpeedsWrite =
|
||||||
|
tc.rememberedSpeedsWriteInFlight;
|
||||||
|
if (
|
||||||
|
inFlightRememberedSpeedsWrite &&
|
||||||
|
inFlightRememberedSpeedsWrite.epoch !==
|
||||||
|
tc.rememberedSpeedsWriteEpoch &&
|
||||||
|
inFlightRememberedSpeedsWrite.payload &&
|
||||||
|
rememberedSpeedsPayloadMatches(
|
||||||
|
changedRememberedSpeeds,
|
||||||
|
inFlightRememberedSpeedsWrite.payload
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!changedRememberedSpeeds ||
|
||||||
|
typeof changedRememberedSpeeds !== "object" ||
|
||||||
|
Object.keys(changedRememberedSpeeds).length === 0
|
||||||
|
) {
|
||||||
|
clearRememberedSpeedsInMemory(tc.rememberedSpeedsResetAt);
|
||||||
|
if (Object.keys(buildRememberedSpeedsPayload()).length > 0) {
|
||||||
|
schedulePersistRememberedSpeeds(true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Merge rather than replace: another frame can write while this
|
||||||
|
// frame still has a newer per-source speed queued. If the storage
|
||||||
|
// snapshot is missing one of our newer entries, self-heal it with a
|
||||||
|
// merged write instead of silently losing either frame's intent.
|
||||||
|
mergeRememberedSpeeds(changedRememberedSpeeds);
|
||||||
|
var expectedRememberedSpeeds = buildRememberedSpeedsPayload();
|
||||||
|
if (
|
||||||
|
!rememberedSpeedsPayloadMatches(
|
||||||
|
changedRememberedSpeeds,
|
||||||
|
expectedRememberedSpeeds
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
schedulePersistRememberedSpeeds(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!changes.customButtonIcons) return;
|
||||||
|
window.vscCustomIconGeneration =
|
||||||
|
(Number(window.vscCustomIconGeneration) || 0) + 1;
|
||||||
|
var nv = changes.customButtonIcons.newValue;
|
||||||
|
applyCustomButtonIcons(nv);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
initializeWhenReady(document);
|
// Close the small gap between the initial sync/local reads and listener
|
||||||
|
// registration. A final read also converges if Options saved while this
|
||||||
|
// content script was starting.
|
||||||
|
scheduleRuntimeSettingsReload();
|
||||||
|
var customIconGeneration =
|
||||||
|
Number(window.vscCustomIconGeneration) || 0;
|
||||||
|
var rememberedSpeedsGeneration =
|
||||||
|
Number(window.vscRememberedSpeedsGeneration) || 0;
|
||||||
|
chrome.storage.local.get(
|
||||||
|
["customButtonIcons", "rememberedSpeeds", "rememberedSpeedsResetAt"],
|
||||||
|
function(latestLocal) {
|
||||||
|
if (chrome.runtime.lastError) return;
|
||||||
|
if (
|
||||||
|
customIconGeneration ===
|
||||||
|
(Number(window.vscCustomIconGeneration) || 0)
|
||||||
|
) {
|
||||||
|
applyCustomButtonIcons(latestLocal && latestLocal.customButtonIcons);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
rememberedSpeedsGeneration ===
|
||||||
|
(Number(window.vscRememberedSpeedsGeneration) || 0)
|
||||||
|
) {
|
||||||
|
applyRememberedSpeedsResetMarker(
|
||||||
|
latestLocal && latestLocal.rememberedSpeedsResetAt
|
||||||
|
);
|
||||||
|
mergeRememberedSpeeds(latestLocal && latestLocal.rememberedSpeeds);
|
||||||
|
var latestRememberedSpeeds =
|
||||||
|
latestLocal && latestLocal.rememberedSpeeds;
|
||||||
|
if (
|
||||||
|
!rememberedSpeedsPayloadMatches(
|
||||||
|
latestRememberedSpeeds,
|
||||||
|
buildRememberedSpeedsPayload()
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
schedulePersistRememberedSpeeds(true);
|
||||||
|
}
|
||||||
|
if (tc.settings.rememberSpeed || tc.settings.forceLastSavedSpeed) {
|
||||||
|
tc.mediaElements.slice().forEach(applyRememberedSpeedPolicy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
tc.runtimeSettingsHydrated = true;
|
||||||
|
initializeWhenReady(document);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
|
// 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") {
|
function getKeyBindings(action, what = "value") {
|
||||||
try {
|
try {
|
||||||
@@ -1405,13 +2704,21 @@ function getKeyBindings(action, what = "value") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setKeyBindings(action, value) {
|
function setKeyBindings(action, value) {
|
||||||
tc.settings.keyBindings.find((item) => item.action === action)["value"] =
|
var binding = tc.settings.keyBindings.find(function(item) {
|
||||||
value;
|
return item.action === action;
|
||||||
|
});
|
||||||
|
if (!binding) return false;
|
||||||
|
binding.value = value;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createControllerButton(doc, action, label, className) {
|
function createControllerButton(doc, action, label, className) {
|
||||||
var button = doc.createElement("button");
|
var button = doc.createElement("button");
|
||||||
|
var name = controllerButtonDefs[action] && controllerButtonDefs[action].name;
|
||||||
|
button.type = "button";
|
||||||
button.dataset.action = action;
|
button.dataset.action = action;
|
||||||
|
button.setAttribute("aria-label", name || action);
|
||||||
|
button.title = name || action;
|
||||||
var custom =
|
var custom =
|
||||||
tc.settings.customButtonIcons &&
|
tc.settings.customButtonIcons &&
|
||||||
tc.settings.customButtonIcons[action] &&
|
tc.settings.customButtonIcons[action] &&
|
||||||
@@ -1439,19 +2746,182 @@ function createControllerButton(doc, action, label, className) {
|
|||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getControllerMount(video) {
|
function createsControllerStackingContext(element) {
|
||||||
if (!video || !video.parentElement) return null;
|
if (!element || !element.ownerDocument) return false;
|
||||||
|
|
||||||
|
var win = element.ownerDocument.defaultView || window;
|
||||||
|
var style = win.getComputedStyle(element);
|
||||||
|
var position = style.position;
|
||||||
|
var zIndex = style.zIndex;
|
||||||
|
var contain = style.contain || "";
|
||||||
|
var willChange = style.willChange || "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
style.isolation === "isolate" ||
|
||||||
|
position === "fixed" ||
|
||||||
|
position === "sticky" ||
|
||||||
|
(position && position !== "static" && zIndex && zIndex !== "auto") ||
|
||||||
|
(style.opacity !== "" && Number(style.opacity) < 1) ||
|
||||||
|
(style.transform && style.transform !== "none") ||
|
||||||
|
(style.filter && style.filter !== "none") ||
|
||||||
|
(style.perspective && style.perspective !== "none") ||
|
||||||
|
contain.indexOf("paint") !== -1 ||
|
||||||
|
contain.indexOf("layout") !== -1 ||
|
||||||
|
willChange.indexOf("transform") !== -1 ||
|
||||||
|
willChange.indexOf("opacity") !== -1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSharedOverlayMount(mount, videoRect) {
|
||||||
|
if (!mount || !mount.parentElement || !videoRect) return null;
|
||||||
|
if (videoRect.width <= 0 || videoRect.height <= 0) return null;
|
||||||
|
|
||||||
|
var parent = mount.parentElement;
|
||||||
|
var parentRect = parent.getBoundingClientRect();
|
||||||
|
var widthLimit = Math.max(videoRect.width * 1.35, videoRect.width + 80);
|
||||||
|
var heightLimit = Math.max(videoRect.height * 1.35, videoRect.height + 80);
|
||||||
|
var tightlyContainsVideo =
|
||||||
|
parentRect.left <= videoRect.left + 1 &&
|
||||||
|
parentRect.top <= videoRect.top + 1 &&
|
||||||
|
parentRect.right >= videoRect.right - 1 &&
|
||||||
|
parentRect.bottom >= videoRect.bottom - 1 &&
|
||||||
|
parentRect.width <= widthLimit &&
|
||||||
|
parentRect.height <= heightLimit;
|
||||||
|
if (!tightlyContainsVideo) return null;
|
||||||
|
|
||||||
|
var siblings = Array.from(parent.children || []);
|
||||||
|
var hasCoveringSibling = siblings.some(function(sibling) {
|
||||||
|
if (sibling === mount || sibling.classList.contains("vsc-controller")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var rect = sibling.getBoundingClientRect();
|
||||||
|
var overlapWidth = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(rect.right, videoRect.right) - Math.max(rect.left, videoRect.left)
|
||||||
|
);
|
||||||
|
var overlapHeight = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(rect.bottom, videoRect.bottom) - Math.max(rect.top, videoRect.top)
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
overlapWidth >= videoRect.width * 0.75 &&
|
||||||
|
overlapHeight >= videoRect.height * 0.75
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return hasCoveringSibling ? parent : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isShadowRootNode(node) {
|
||||||
|
return Boolean(
|
||||||
|
node &&
|
||||||
|
node.nodeType === Node.DOCUMENT_FRAGMENT_NODE &&
|
||||||
|
node.host &&
|
||||||
|
node.host.nodeType === Node.ELEMENT_NODE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isComposedDescendant(node, ancestor) {
|
||||||
|
if (!node || !ancestor) return false;
|
||||||
|
if (node === ancestor) return true;
|
||||||
|
if (typeof ancestor.contains === "function" && ancestor.contains(node)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var current = node;
|
||||||
|
var depth = 0;
|
||||||
|
while (current && depth < 20) {
|
||||||
|
if (current === ancestor) return true;
|
||||||
|
if (current.parentElement) {
|
||||||
|
current = current.parentElement;
|
||||||
|
} else {
|
||||||
|
var root =
|
||||||
|
typeof current.getRootNode === "function" ? current.getRootNode() : null;
|
||||||
|
current = root && root.host ? root.host : null;
|
||||||
|
}
|
||||||
|
depth += 1;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getControllerGeometryMount(mount) {
|
||||||
|
return isShadowRootNode(mount) ? mount.host : mount;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getControllerMount(video, boundary) {
|
||||||
|
if (!video) return null;
|
||||||
|
|
||||||
|
// In fullscreen, mount at the promoted top-layer boundary. The normal size
|
||||||
|
// heuristic is intentionally skipped: letterboxed/portrait video providers
|
||||||
|
// can be much smaller than a full-player gesture pane sibling.
|
||||||
|
if (
|
||||||
|
boundary &&
|
||||||
|
(boundary === video || isComposedDescendant(video, boundary))
|
||||||
|
) {
|
||||||
|
// A VIDEO is a replaced element; author children appended inside it are
|
||||||
|
// fallback content and are not painted. Direct-media fullscreen uses a
|
||||||
|
// top-layer popover instead, while this function keeps a renderable DOM
|
||||||
|
// mount as its ownership/geometry anchor.
|
||||||
|
if (boundary === video) {
|
||||||
|
return video.vsc && video.vsc.normalControllerMount
|
||||||
|
? video.vsc.normalControllerMount
|
||||||
|
: getControllerMount(video);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
boundary.shadowRoot &&
|
||||||
|
isComposedDescendant(video, boundary.shadowRoot)
|
||||||
|
) {
|
||||||
|
return boundary.shadowRoot;
|
||||||
|
}
|
||||||
|
return boundary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// YouTube's .html5-video-container is often absolutely laid out and can
|
||||||
|
// report a zero-height box even while its video is visibly playing. The
|
||||||
|
// surrounding .html5-video-player is the real player/overlay boundary for
|
||||||
|
// watch pages, Shorts, and hover-preview #inline-preview-player instances.
|
||||||
|
// Keep every subsequent remount on that canonical boundary; otherwise an
|
||||||
|
// event-driven ensureController call can undo the constructor's YouTube
|
||||||
|
// mount and hide the controller in the zero-height inner container.
|
||||||
|
if (isOnYouTube() && typeof video.closest === "function") {
|
||||||
|
var youTubePlayerMount = video.closest(".html5-video-player");
|
||||||
|
if (youTubePlayerMount) return youTubePlayerMount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!video.parentElement) {
|
||||||
|
var directRoot =
|
||||||
|
typeof video.getRootNode === "function" ? video.getRootNode() : null;
|
||||||
|
return isShadowRootNode(directRoot) ? directRoot : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var mountBoundary = null;
|
||||||
|
|
||||||
var videoRect = video.getBoundingClientRect();
|
var videoRect = video.getBoundingClientRect();
|
||||||
var mount = video.parentElement;
|
var mount = video.parentElement;
|
||||||
var candidate = mount;
|
var candidate = mount;
|
||||||
var depth = 0;
|
var depth = 0;
|
||||||
|
|
||||||
|
// Usually stay inside the immediate local stacking boundary so a max-z-index
|
||||||
|
// host cannot escape over page headers. The exception is a tightly-sized
|
||||||
|
// shared player whose covering gesture/click pane is a sibling of that
|
||||||
|
// boundary; mount one level up so the controller can win hit-testing.
|
||||||
|
if (createsControllerStackingContext(mount)) {
|
||||||
|
return getSharedOverlayMount(mount, videoRect) || mount;
|
||||||
|
}
|
||||||
|
|
||||||
// Player click-catchers are often siblings of the video's immediate parent.
|
// Player click-catchers are often siblings of the video's immediate parent.
|
||||||
// Climb through tightly-sized wrappers so our host shares their stacking
|
// Climb through tightly-sized wrappers so our host shares their stacking
|
||||||
// context, but stop before broad page-layout containers.
|
// context, but stop before broad page-layout containers.
|
||||||
while (candidate && candidate.parentElement && depth < 5) {
|
while (candidate && candidate.parentElement && depth < 5) {
|
||||||
|
if (mountBoundary && candidate === mountBoundary) break;
|
||||||
var next = candidate.parentElement;
|
var next = candidate.parentElement;
|
||||||
|
if (
|
||||||
|
mountBoundary &&
|
||||||
|
next !== mountBoundary &&
|
||||||
|
!mountBoundary.contains(next)
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
var nextRect = next.getBoundingClientRect();
|
var nextRect = next.getBoundingClientRect();
|
||||||
var widthLimit = Math.max(videoRect.width * 1.35, videoRect.width + 80);
|
var widthLimit = Math.max(videoRect.width * 1.35, videoRect.width + 80);
|
||||||
var heightLimit = Math.max(videoRect.height * 1.35, videoRect.height + 80);
|
var heightLimit = Math.max(videoRect.height * 1.35, videoRect.height + 80);
|
||||||
@@ -1474,32 +2944,83 @@ function getControllerMount(video) {
|
|||||||
mount = next;
|
mount = next;
|
||||||
candidate = next;
|
candidate = next;
|
||||||
depth += 1;
|
depth += 1;
|
||||||
|
|
||||||
|
// In fullscreen, the wrapper must remain inside the exact subtree the
|
||||||
|
// browser promotes to its top layer.
|
||||||
|
if (mountBoundary && next === mountBoundary) break;
|
||||||
|
|
||||||
|
// Never climb out of a player-owned stacking context. Doing so lets the
|
||||||
|
// controller's high local z-index escape above sticky page headers.
|
||||||
|
if (createsControllerStackingContext(next)) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return mount;
|
return mount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getFullscreenElement(doc) {
|
||||||
|
if (!doc) return null;
|
||||||
|
return (
|
||||||
|
doc.fullscreenElement ||
|
||||||
|
doc.webkitFullscreenElement ||
|
||||||
|
doc.mozFullScreenElement ||
|
||||||
|
doc.msFullscreenElement ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function positionControllerHost(wrapper, video, mount) {
|
function positionControllerHost(wrapper, video, mount) {
|
||||||
if (!wrapper || !video || !mount || !wrapper.isConnected) return;
|
if (!wrapper || !video || !mount || !wrapper.isConnected) return;
|
||||||
|
|
||||||
|
var geometryMount = getControllerGeometryMount(mount);
|
||||||
|
if (!geometryMount || typeof geometryMount.getBoundingClientRect !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
var videoRect = video.getBoundingClientRect();
|
var videoRect = video.getBoundingClientRect();
|
||||||
var mountRect = mount.getBoundingClientRect();
|
if (wrapper.classList.contains("vsc-fullscreen-popover")) {
|
||||||
if (videoRect.width <= 0 || videoRect.height <= 0) return;
|
if (videoRect.width <= 0 || videoRect.height <= 0) {
|
||||||
|
wrapper.classList.add("vsc-geometry-hidden");
|
||||||
|
wrapper.style.setProperty("display", "none", "important");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wrapper.classList.remove("vsc-geometry-hidden");
|
||||||
|
wrapper.style.removeProperty("display");
|
||||||
|
wrapper.style.setProperty("left", videoRect.left + "px", "important");
|
||||||
|
wrapper.style.setProperty("top", videoRect.top + "px", "important");
|
||||||
|
wrapper.style.setProperty("width", videoRect.width + "px", "important");
|
||||||
|
wrapper.style.setProperty("height", videoRect.height + "px", "important");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var mountRect = geometryMount.getBoundingClientRect();
|
||||||
|
if (
|
||||||
|
videoRect.width <= 0 ||
|
||||||
|
videoRect.height <= 0 ||
|
||||||
|
mountRect.width <= 0 ||
|
||||||
|
mountRect.height <= 0
|
||||||
|
) {
|
||||||
|
wrapper.classList.add("vsc-geometry-hidden");
|
||||||
|
wrapper.style.setProperty("display", "none", "important");
|
||||||
|
wrapper.style.setProperty("width", "0px", "important");
|
||||||
|
wrapper.style.setProperty("height", "0px", "important");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapper.classList.remove("vsc-geometry-hidden");
|
||||||
|
wrapper.style.removeProperty("display");
|
||||||
|
|
||||||
// Convert viewport pixels back into the mount's CSS pixel space. This keeps
|
// Convert viewport pixels back into the mount's CSS pixel space. This keeps
|
||||||
// the overlay aligned even when a player or ancestor is scaled.
|
// the overlay aligned even when a player or ancestor is scaled.
|
||||||
var mountWidth = mount.offsetWidth || mountRect.width || 1;
|
var mountWidth = geometryMount.offsetWidth || mountRect.width || 1;
|
||||||
var mountHeight = mount.offsetHeight || mountRect.height || 1;
|
var mountHeight = geometryMount.offsetHeight || mountRect.height || 1;
|
||||||
var scaleX = mountRect.width > 0 ? mountRect.width / mountWidth : 1;
|
var scaleX = mountRect.width > 0 ? mountRect.width / mountWidth : 1;
|
||||||
var scaleY = mountRect.height > 0 ? mountRect.height / mountHeight : 1;
|
var scaleY = mountRect.height > 0 ? mountRect.height / mountHeight : 1;
|
||||||
var left =
|
var left =
|
||||||
(videoRect.left - mountRect.left) / scaleX -
|
(videoRect.left - mountRect.left) / scaleX -
|
||||||
(mount.clientLeft || 0) +
|
(geometryMount.clientLeft || 0) +
|
||||||
(mount.scrollLeft || 0);
|
(geometryMount.scrollLeft || 0);
|
||||||
var top =
|
var top =
|
||||||
(videoRect.top - mountRect.top) / scaleY -
|
(videoRect.top - mountRect.top) / scaleY -
|
||||||
(mount.clientTop || 0) +
|
(geometryMount.clientTop || 0) +
|
||||||
(mount.scrollTop || 0);
|
(geometryMount.scrollTop || 0);
|
||||||
|
|
||||||
wrapper.style.setProperty("left", left + "px", "important");
|
wrapper.style.setProperty("left", left + "px", "important");
|
||||||
wrapper.style.setProperty("top", top + "px", "important");
|
wrapper.style.setProperty("top", top + "px", "important");
|
||||||
@@ -1515,65 +3036,318 @@ function positionControllerHost(wrapper, video, mount) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function configureControllerAutoHide(videoController, wrapper, force) {
|
||||||
|
if (!videoController || !wrapper) return;
|
||||||
|
var signature = JSON.stringify([
|
||||||
|
tc.settings.hideWithControls === true,
|
||||||
|
Number(tc.settings.hideWithControlsTimer) || 2
|
||||||
|
]);
|
||||||
|
if (force !== true && videoController.autoHideSettingsSignature === signature) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoController.youTubeAutoHideObserver) {
|
||||||
|
videoController.youTubeAutoHideObserver.disconnect();
|
||||||
|
videoController.youTubeAutoHideObserver = null;
|
||||||
|
}
|
||||||
|
if (videoController.youTubeAutoHideCleanup) {
|
||||||
|
videoController.youTubeAutoHideCleanup();
|
||||||
|
videoController.youTubeAutoHideCleanup = null;
|
||||||
|
}
|
||||||
|
if (videoController.genericAutoHideCleanup) {
|
||||||
|
videoController.genericAutoHideCleanup();
|
||||||
|
videoController.genericAutoHideCleanup = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapper.classList.remove("ytp-autohide", "vsc-idle-hidden");
|
||||||
|
videoController.autoHideSettingsSignature = signature;
|
||||||
|
if (!tc.settings.hideWithControls) return;
|
||||||
|
|
||||||
|
if (isOnYouTube()) {
|
||||||
|
videoController.setupYouTubeAutoHide(wrapper);
|
||||||
|
} else {
|
||||||
|
videoController.setupGenericAutoHide(wrapper);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setupControllerHostTracking(videoController, wrapper, mount) {
|
function setupControllerHostTracking(videoController, wrapper, mount) {
|
||||||
if (!videoController || !wrapper || !mount) return;
|
if (!videoController || !wrapper || !mount) return;
|
||||||
|
|
||||||
var doc = videoController.video.ownerDocument;
|
var doc = videoController.video.ownerDocument;
|
||||||
var win = doc.defaultView || window;
|
var win = doc.defaultView || window;
|
||||||
|
var geometryMount = getControllerGeometryMount(mount);
|
||||||
|
if (!geometryMount || !geometryMount.style) return;
|
||||||
var frameId = null;
|
var frameId = null;
|
||||||
|
var geometryRetryTimer = null;
|
||||||
|
var geometryRetryAttempts = 0;
|
||||||
|
var maxGeometryRetryAttempts = 12;
|
||||||
var update = function() {
|
var update = function() {
|
||||||
frameId = null;
|
frameId = null;
|
||||||
positionControllerHost(wrapper, videoController.video, mount);
|
positionControllerHost(wrapper, videoController.video, mount);
|
||||||
|
|
||||||
|
// Content scripts run at document_start, so a connected player can still
|
||||||
|
// report a 0x0 rect while its stylesheet, route, or custom element is
|
||||||
|
// settling. ResizeObserver normally repairs this, but it is not guaranteed
|
||||||
|
// to exist (and some players only change ancestor layout). Keep a bounded
|
||||||
|
// fallback alive long enough for those players instead of leaving the
|
||||||
|
// inline display:none geometry guard stuck forever.
|
||||||
|
if (
|
||||||
|
wrapper.classList.contains("vsc-geometry-hidden") &&
|
||||||
|
wrapper.isConnected &&
|
||||||
|
videoController.video.isConnected &&
|
||||||
|
geometryRetryAttempts < maxGeometryRetryAttempts
|
||||||
|
) {
|
||||||
|
geometryRetryAttempts += 1;
|
||||||
|
if (geometryRetryTimer === null) {
|
||||||
|
geometryRetryTimer = win.setTimeout(function() {
|
||||||
|
geometryRetryTimer = null;
|
||||||
|
schedule();
|
||||||
|
}, Math.min(500, 25 * geometryRetryAttempts));
|
||||||
|
}
|
||||||
|
} else if (!wrapper.classList.contains("vsc-geometry-hidden")) {
|
||||||
|
geometryRetryAttempts = 0;
|
||||||
|
if (geometryRetryTimer !== null) {
|
||||||
|
win.clearTimeout(geometryRetryTimer);
|
||||||
|
geometryRetryTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
var schedule = function() {
|
var schedule = function() {
|
||||||
if (frameId !== null) return;
|
if (frameId !== null) return;
|
||||||
frameId = win.requestAnimationFrame(update);
|
frameId = win.requestAnimationFrame(update);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (win.getComputedStyle(mount).position === "static") {
|
if (win.getComputedStyle(geometryMount).position === "static") {
|
||||||
mount.dataset.vscPositionOwner = "true";
|
geometryMount.dataset.vscPositionOwner = "true";
|
||||||
mount.dataset.vscOriginalPosition = mount.style.getPropertyValue("position");
|
geometryMount.dataset.vscOriginalPosition =
|
||||||
mount.dataset.vscOriginalPositionPriority =
|
geometryMount.style.getPropertyValue("position");
|
||||||
mount.style.getPropertyPriority("position");
|
geometryMount.dataset.vscOriginalPositionPriority =
|
||||||
mount.style.setProperty("position", "relative");
|
geometryMount.style.getPropertyPriority("position");
|
||||||
|
geometryMount.dataset.vscAppliedPosition = "relative";
|
||||||
|
geometryMount.style.setProperty("position", "relative");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!createsControllerStackingContext(geometryMount)) {
|
||||||
|
geometryMount.dataset.vscIsolationOwner = "true";
|
||||||
|
geometryMount.dataset.vscOriginalIsolation =
|
||||||
|
geometryMount.style.getPropertyValue("isolation");
|
||||||
|
geometryMount.dataset.vscOriginalIsolationPriority =
|
||||||
|
geometryMount.style.getPropertyPriority("isolation");
|
||||||
|
geometryMount.dataset.vscAppliedIsolation = "isolate";
|
||||||
|
geometryMount.style.setProperty("isolation", "isolate");
|
||||||
}
|
}
|
||||||
|
|
||||||
var resizeObserver = null;
|
var resizeObserver = null;
|
||||||
if (typeof win.ResizeObserver === "function") {
|
if (typeof win.ResizeObserver === "function") {
|
||||||
resizeObserver = new win.ResizeObserver(schedule);
|
resizeObserver = new win.ResizeObserver(schedule);
|
||||||
resizeObserver.observe(videoController.video);
|
resizeObserver.observe(videoController.video);
|
||||||
resizeObserver.observe(mount);
|
resizeObserver.observe(geometryMount);
|
||||||
}
|
}
|
||||||
|
|
||||||
win.addEventListener("resize", schedule, { passive: true });
|
win.addEventListener("resize", schedule, { passive: true });
|
||||||
doc.addEventListener("fullscreenchange", schedule, { passive: true });
|
doc.addEventListener("fullscreenchange", schedule, { passive: true });
|
||||||
mount.addEventListener("scroll", schedule, { passive: true });
|
geometryMount.addEventListener("scroll", schedule, { passive: true });
|
||||||
update();
|
update();
|
||||||
|
|
||||||
videoController.controllerHostCleanup = function() {
|
videoController.controllerHostMount = mount;
|
||||||
|
videoController.controllerHostCleanup = function(forceRestore) {
|
||||||
if (resizeObserver) resizeObserver.disconnect();
|
if (resizeObserver) resizeObserver.disconnect();
|
||||||
if (frameId !== null) win.cancelAnimationFrame(frameId);
|
if (frameId !== null) win.cancelAnimationFrame(frameId);
|
||||||
|
if (geometryRetryTimer !== null) win.clearTimeout(geometryRetryTimer);
|
||||||
win.removeEventListener("resize", schedule);
|
win.removeEventListener("resize", schedule);
|
||||||
doc.removeEventListener("fullscreenchange", schedule);
|
doc.removeEventListener("fullscreenchange", schedule);
|
||||||
mount.removeEventListener("scroll", schedule);
|
geometryMount.removeEventListener("scroll", schedule);
|
||||||
|
var hasOtherController = false;
|
||||||
|
try {
|
||||||
|
hasOtherController = Array.from(
|
||||||
|
mount.querySelectorAll(".vsc-controller")
|
||||||
|
).some(function(controllerHost) {
|
||||||
|
return controllerHost !== wrapper;
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
if (
|
if (
|
||||||
mount.dataset.vscPositionOwner === "true" &&
|
geometryMount.dataset.vscPositionOwner === "true" &&
|
||||||
!mount.querySelector(".vsc-controller")
|
!hasOtherController &&
|
||||||
|
(forceRestore === true || !wrapper.isConnected)
|
||||||
) {
|
) {
|
||||||
if (mount.dataset.vscOriginalPosition) {
|
if (
|
||||||
mount.style.setProperty(
|
geometryMount.style.getPropertyValue("position") ===
|
||||||
"position",
|
geometryMount.dataset.vscAppliedPosition &&
|
||||||
mount.dataset.vscOriginalPosition,
|
geometryMount.style.getPropertyPriority("position") === ""
|
||||||
mount.dataset.vscOriginalPositionPriority || ""
|
) {
|
||||||
);
|
if (geometryMount.dataset.vscOriginalPosition) {
|
||||||
} else {
|
geometryMount.style.setProperty(
|
||||||
mount.style.removeProperty("position");
|
"position",
|
||||||
|
geometryMount.dataset.vscOriginalPosition,
|
||||||
|
geometryMount.dataset.vscOriginalPositionPriority || ""
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
geometryMount.style.removeProperty("position");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
delete mount.dataset.vscPositionOwner;
|
delete geometryMount.dataset.vscPositionOwner;
|
||||||
delete mount.dataset.vscOriginalPosition;
|
delete geometryMount.dataset.vscOriginalPosition;
|
||||||
delete mount.dataset.vscOriginalPositionPriority;
|
delete geometryMount.dataset.vscOriginalPositionPriority;
|
||||||
|
delete geometryMount.dataset.vscAppliedPosition;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
geometryMount.dataset.vscIsolationOwner === "true" &&
|
||||||
|
!hasOtherController &&
|
||||||
|
(forceRestore === true || !wrapper.isConnected)
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
geometryMount.style.getPropertyValue("isolation") ===
|
||||||
|
geometryMount.dataset.vscAppliedIsolation &&
|
||||||
|
geometryMount.style.getPropertyPriority("isolation") === ""
|
||||||
|
) {
|
||||||
|
if (geometryMount.dataset.vscOriginalIsolation) {
|
||||||
|
geometryMount.style.setProperty(
|
||||||
|
"isolation",
|
||||||
|
geometryMount.dataset.vscOriginalIsolation,
|
||||||
|
geometryMount.dataset.vscOriginalIsolationPriority || ""
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
geometryMount.style.removeProperty("isolation");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete geometryMount.dataset.vscIsolationOwner;
|
||||||
|
delete geometryMount.dataset.vscOriginalIsolation;
|
||||||
|
delete geometryMount.dataset.vscOriginalIsolationPriority;
|
||||||
|
delete geometryMount.dataset.vscAppliedIsolation;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
configureControllerAutoHide(videoController, wrapper, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function remountControllerHost(videoController, mount) {
|
||||||
|
if (
|
||||||
|
!videoController ||
|
||||||
|
!videoController.div ||
|
||||||
|
!mount ||
|
||||||
|
!(mount.isConnected || (isShadowRootNode(mount) && mount.host.isConnected))
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
mount === videoController.controllerHostMount &&
|
||||||
|
videoController.div.isConnected &&
|
||||||
|
videoController.div.parentNode === mount
|
||||||
|
) {
|
||||||
|
// Keep the max-z-index host last among equal-z-index player overlays.
|
||||||
|
if (videoController.div.nextSibling) {
|
||||||
|
mount.appendChild(videoController.div);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
positionControllerHost(
|
||||||
|
videoController.div,
|
||||||
|
videoController.video,
|
||||||
|
mount
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoController.controllerHostCleanup) {
|
||||||
|
videoController.controllerHostCleanup(true);
|
||||||
|
videoController.controllerHostCleanup = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
mount.appendChild(videoController.div);
|
||||||
|
setupControllerHostTracking(videoController, videoController.div, mount);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function disableDirectFullscreenPopover(videoController) {
|
||||||
|
if (!videoController || !videoController.div) return;
|
||||||
|
var wrapper = videoController.div;
|
||||||
|
if (
|
||||||
|
!wrapper.vscFullscreenPopoverOpen &&
|
||||||
|
!wrapper.classList.contains("vsc-fullscreen-popover")
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof wrapper.hidePopover === "function") {
|
||||||
|
try {
|
||||||
|
wrapper.hidePopover();
|
||||||
|
} catch (_error) {}
|
||||||
|
}
|
||||||
|
wrapper.vscFullscreenPopoverOpen = false;
|
||||||
|
wrapper.classList.remove("vsc-fullscreen-popover");
|
||||||
|
wrapper.removeAttribute("popover");
|
||||||
|
}
|
||||||
|
|
||||||
|
function enableDirectFullscreenPopover(videoController) {
|
||||||
|
if (!videoController || !videoController.video || !videoController.div) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var wrapper = videoController.div;
|
||||||
|
if (typeof wrapper.showPopover !== "function") return false;
|
||||||
|
|
||||||
|
var normalMount = videoController.normalControllerMount;
|
||||||
|
var normalMountIsConnected = Boolean(
|
||||||
|
normalMount &&
|
||||||
|
(normalMount.isConnected ||
|
||||||
|
(isShadowRootNode(normalMount) && normalMount.host.isConnected))
|
||||||
|
);
|
||||||
|
if (!normalMountIsConnected) {
|
||||||
|
normalMount = getControllerMount(videoController.video);
|
||||||
|
videoController.normalControllerMount = normalMount;
|
||||||
|
}
|
||||||
|
if (!normalMount) return false;
|
||||||
|
if (wrapper.parentNode !== normalMount) {
|
||||||
|
remountControllerHost(videoController, normalMount);
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapper.setAttribute("popover", "manual");
|
||||||
|
wrapper.classList.add("vsc-fullscreen-popover");
|
||||||
|
if (!wrapper.vscFullscreenPopoverOpen) {
|
||||||
|
try {
|
||||||
|
wrapper.showPopover();
|
||||||
|
wrapper.vscFullscreenPopoverOpen = true;
|
||||||
|
} catch (_error) {
|
||||||
|
wrapper.classList.remove("vsc-fullscreen-popover");
|
||||||
|
wrapper.removeAttribute("popover");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
positionControllerHost(wrapper, videoController.video, normalMount);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncControllerFullscreenMount(videoController) {
|
||||||
|
if (!videoController || !videoController.video || !videoController.div) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var video = videoController.video;
|
||||||
|
var doc = video.ownerDocument;
|
||||||
|
var fullscreenElement = getFullscreenElement(doc);
|
||||||
|
var targetMount = videoController.normalControllerMount;
|
||||||
|
var ownsFullscreen = Boolean(
|
||||||
|
fullscreenElement &&
|
||||||
|
(fullscreenElement === video ||
|
||||||
|
isComposedDescendant(video, fullscreenElement))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ownsFullscreen) {
|
||||||
|
targetMount = getControllerMount(video, fullscreenElement);
|
||||||
|
} else if (!fullscreenElement && (!targetMount || !targetMount.isConnected)) {
|
||||||
|
targetMount = getControllerMount(video);
|
||||||
|
videoController.normalControllerMount = targetMount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetMount) return false;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
return remountControllerHost(videoController, targetMount);
|
||||||
}
|
}
|
||||||
|
|
||||||
function defineVideoController() {
|
function defineVideoController() {
|
||||||
@@ -1587,6 +3361,9 @@ function defineVideoController() {
|
|||||||
this.restoreSpeedTimer = null;
|
this.restoreSpeedTimer = null;
|
||||||
this.pendingRateChange = null;
|
this.pendingRateChange = null;
|
||||||
this.speedRestoreUntil = 0;
|
this.speedRestoreUntil = 0;
|
||||||
|
this.speedVerificationTimer = null;
|
||||||
|
this.speedVerificationTarget = null;
|
||||||
|
this.speedVerificationAttempts = 0;
|
||||||
this.subtitleNudgeEnabledOverride = null;
|
this.subtitleNudgeEnabledOverride = null;
|
||||||
this.suppressedRateChangeCount = 0;
|
this.suppressedRateChangeCount = 0;
|
||||||
this.suppressedRateChangeUntil = 0;
|
this.suppressedRateChangeUntil = 0;
|
||||||
@@ -1596,19 +3373,28 @@ function defineVideoController() {
|
|||||||
this.controllerLocation = normalizeControllerLocation(
|
this.controllerLocation = normalizeControllerLocation(
|
||||||
tc.settings.controllerLocation
|
tc.settings.controllerLocation
|
||||||
);
|
);
|
||||||
|
this.structureSignature = getControllerStructureSignature();
|
||||||
|
|
||||||
log(`Creating video controller for ${target.tagName} with src: ${target.src || target.currentSrc || "none"}`, 4);
|
log(`Creating video controller for ${target.tagName} with src: ${target.src || target.currentSrc || "none"}`, 4);
|
||||||
|
|
||||||
let storedSpeed = sanitizeSpeed(resolveTargetSpeed(target), 1.0);
|
let storedSpeed = sanitizeSpeed(resolveTargetSpeed(target), 1.0);
|
||||||
this.targetSpeed = storedSpeed;
|
this.targetSpeed = storedSpeed;
|
||||||
this.targetSpeedSourceKey = getVideoSourceKey(target);
|
this.targetSpeedSourceKey = getVideoSourceKey(target);
|
||||||
|
this.targetSpeedOrigin =
|
||||||
|
tc.settings.rememberSpeed || tc.settings.forceLastSavedSpeed
|
||||||
|
? "policy"
|
||||||
|
: "initial";
|
||||||
this.mediaSourceKey = getVideoSourceKey(target);
|
this.mediaSourceKey = getVideoSourceKey(target);
|
||||||
if (!tc.settings.rememberSpeed && !tc.settings.forceLastSavedSpeed) {
|
if (!tc.settings.rememberSpeed && !tc.settings.forceLastSavedSpeed) {
|
||||||
setKeyBindings("reset", getKeyBindings("fast"));
|
setKeyBindings("reset", getKeyBindings("fast"));
|
||||||
}
|
}
|
||||||
|
|
||||||
log("Explicitly setting playbackRate to: " + storedSpeed, 5);
|
log("Explicitly setting playbackRate to: " + storedSpeed, 5);
|
||||||
target.playbackRate = storedSpeed;
|
try {
|
||||||
|
target.playbackRate = storedSpeed;
|
||||||
|
} catch (error) {
|
||||||
|
log(`Unable to set initial playbackRate: ${error.message}`, 3);
|
||||||
|
}
|
||||||
|
|
||||||
this.div = this.initializeControls();
|
this.div = this.initializeControls();
|
||||||
|
|
||||||
@@ -1620,11 +3406,39 @@ function defineVideoController() {
|
|||||||
log(`Controller created and attached to DOM. Hidden: ${this.div.classList.contains("vsc-hidden")}`, 4);
|
log(`Controller created and attached to DOM. Hidden: ${this.div.classList.contains("vsc-hidden")}`, 4);
|
||||||
|
|
||||||
var mediaEventAction = function(event) {
|
var mediaEventAction = function(event) {
|
||||||
|
this.lastActivityAt = Date.now();
|
||||||
|
if (event.type === "emptied") {
|
||||||
|
var sourceAvailable = hasUsableMediaSource(event.target);
|
||||||
|
if (this.div) {
|
||||||
|
this.div.classList.toggle("vsc-nosource", !sourceAvailable);
|
||||||
|
}
|
||||||
|
this.stopSubtitleNudge();
|
||||||
|
if (!sourceAvailable) {
|
||||||
|
this.mediaSourceKey = "unknown_src";
|
||||||
|
this.targetSpeedSourceKey = "unknown_src";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applySourceTransitionPolicy(event.target, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "loadstart") {
|
||||||
|
if (this.div) {
|
||||||
|
this.div.classList.toggle(
|
||||||
|
"vsc-nosource",
|
||||||
|
!hasUsableMediaSource(event.target)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (hasUsableMediaSource(event.target)) {
|
||||||
|
applySourceTransitionPolicy(event.target, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
event.type === "loadedmetadata" ||
|
event.type === "loadedmetadata" ||
|
||||||
event.type === "loadeddata" ||
|
event.type === "loadeddata" ||
|
||||||
event.type === "canplay"
|
event.type === "canplay"
|
||||||
) {
|
) {
|
||||||
|
if (this.div) this.div.classList.remove("vsc-nosource");
|
||||||
applySourceTransitionPolicy(event.target, false);
|
applySourceTransitionPolicy(event.target, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1676,6 +3490,14 @@ function defineVideoController() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
target.addEventListener(
|
||||||
|
"loadstart",
|
||||||
|
(this.handleLoadStart = mediaEventAction.bind(this))
|
||||||
|
);
|
||||||
|
target.addEventListener(
|
||||||
|
"emptied",
|
||||||
|
(this.handleEmptied = mediaEventAction.bind(this))
|
||||||
|
);
|
||||||
target.addEventListener(
|
target.addEventListener(
|
||||||
"loadedmetadata",
|
"loadedmetadata",
|
||||||
(this.handleLoadedMetadata = mediaEventAction.bind(this))
|
(this.handleLoadedMetadata = mediaEventAction.bind(this))
|
||||||
@@ -1720,7 +3542,10 @@ function defineVideoController() {
|
|||||||
if (this.div) {
|
if (this.div) {
|
||||||
this.stopSubtitleNudge();
|
this.stopSubtitleNudge();
|
||||||
if (!mutation.target.src && !mutation.target.currentSrc) {
|
if (!mutation.target.src && !mutation.target.currentSrc) {
|
||||||
this.div.classList.add("vsc-nosource");
|
this.div.classList.toggle(
|
||||||
|
"vsc-nosource",
|
||||||
|
!hasUsableMediaSource(mutation.target)
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
this.div.classList.remove("vsc-nosource");
|
this.div.classList.remove("vsc-nosource");
|
||||||
applySourceTransitionPolicy(this.video, true);
|
applySourceTransitionPolicy(this.video, true);
|
||||||
@@ -1739,6 +3564,19 @@ function defineVideoController() {
|
|||||||
|
|
||||||
tc.videoController.prototype.remove = function() {
|
tc.videoController.prototype.remove = function() {
|
||||||
this.stopSubtitleNudge();
|
this.stopSubtitleNudge();
|
||||||
|
disableDirectFullscreenPopover(this);
|
||||||
|
if (
|
||||||
|
this.visibilityResumeHandler &&
|
||||||
|
this.video &&
|
||||||
|
this.video.ownerDocument
|
||||||
|
) {
|
||||||
|
this.video.ownerDocument.removeEventListener(
|
||||||
|
"visibilitychange",
|
||||||
|
this.visibilityResumeHandler,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
this.visibilityResumeHandler = null;
|
||||||
|
}
|
||||||
if (this.youTubeAutoHideObserver) {
|
if (this.youTubeAutoHideObserver) {
|
||||||
this.youTubeAutoHideObserver.disconnect();
|
this.youTubeAutoHideObserver.disconnect();
|
||||||
this.youTubeAutoHideObserver = null;
|
this.youTubeAutoHideObserver = null;
|
||||||
@@ -1757,7 +3595,10 @@ function defineVideoController() {
|
|||||||
this.controllerHostCleanup = null;
|
this.controllerHostCleanup = null;
|
||||||
}
|
}
|
||||||
if (this.restoreSpeedTimer) clearTimeout(this.restoreSpeedTimer);
|
if (this.restoreSpeedTimer) clearTimeout(this.restoreSpeedTimer);
|
||||||
|
clearSpeedVerification(this);
|
||||||
if (this.video) {
|
if (this.video) {
|
||||||
|
this.video.removeEventListener("loadstart", this.handleLoadStart);
|
||||||
|
this.video.removeEventListener("emptied", this.handleEmptied);
|
||||||
this.video.removeEventListener("loadedmetadata", this.handleLoadedMetadata);
|
this.video.removeEventListener("loadedmetadata", this.handleLoadedMetadata);
|
||||||
this.video.removeEventListener("loadeddata", this.handleLoadedData);
|
this.video.removeEventListener("loadeddata", this.handleLoadedData);
|
||||||
this.video.removeEventListener("canplay", this.handleCanPlay);
|
this.video.removeEventListener("canplay", this.handleCanPlay);
|
||||||
@@ -1824,17 +3665,35 @@ function defineVideoController() {
|
|||||||
|
|
||||||
// Cache values to avoid repeated property access
|
// Cache values to avoid repeated property access
|
||||||
const targetSpeed = this.targetSpeed;
|
const targetSpeed = this.targetSpeed;
|
||||||
const nudgeAmount = tc.settings.subtitleNudgeAmount;
|
const nudgeAmount = Math.min(
|
||||||
|
0.1,
|
||||||
|
Math.max(0.000001, Number(tc.settings.subtitleNudgeAmount) || 0.001)
|
||||||
|
);
|
||||||
|
|
||||||
// Apply nudge from the stored target speed (not current rate)
|
// Apply nudge from the stored target speed (not current rate)
|
||||||
this.video.playbackRate = targetSpeed + nudgeAmount;
|
try {
|
||||||
|
this.video.playbackRate = targetSpeed + nudgeAmount;
|
||||||
|
} catch (error) {
|
||||||
|
tc.isNudging = false;
|
||||||
|
this.suppressedRateChangeCount = 0;
|
||||||
|
this.stopSubtitleNudge();
|
||||||
|
log(`Subtitle nudge failed: ${error.message}`, 3);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Revert synchronously after a microtask to ensure it happens immediately
|
// Revert synchronously after a microtask to ensure it happens immediately
|
||||||
Promise.resolve().then(() => {
|
Promise.resolve().then(() => {
|
||||||
if (this.video && targetSpeed) {
|
try {
|
||||||
this.video.playbackRate = targetSpeed;
|
if (this.video && targetSpeed) {
|
||||||
|
this.video.playbackRate = targetSpeed;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.suppressedRateChangeCount = 0;
|
||||||
|
this.stopSubtitleNudge();
|
||||||
|
log(`Subtitle nudge restore failed: ${error.message}`, 3);
|
||||||
|
} finally {
|
||||||
|
tc.isNudging = false;
|
||||||
}
|
}
|
||||||
tc.isNudging = false;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Schedule next nudge
|
// Schedule next nudge
|
||||||
@@ -1877,18 +3736,34 @@ function defineVideoController() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const targetRate = this.targetSpeed || this.video.playbackRate;
|
const targetRate = this.targetSpeed || this.video.playbackRate;
|
||||||
const nudgeAmount = tc.settings.subtitleNudgeAmount;
|
const nudgeAmount = Math.min(
|
||||||
|
0.1,
|
||||||
|
Math.max(0.000001, Number(tc.settings.subtitleNudgeAmount) || 0.001)
|
||||||
|
);
|
||||||
|
|
||||||
tc.isNudging = true;
|
tc.isNudging = true;
|
||||||
suppressNextNudgeRateChanges(this, 2);
|
suppressNextNudgeRateChanges(this, 2);
|
||||||
this.video.playbackRate = targetRate + nudgeAmount;
|
try {
|
||||||
|
this.video.playbackRate = targetRate + nudgeAmount;
|
||||||
|
} catch (error) {
|
||||||
|
tc.isNudging = false;
|
||||||
|
this.suppressedRateChangeCount = 0;
|
||||||
|
log(`Immediate subtitle nudge failed: ${error.message}`, 3);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Revert synchronously via microtask
|
// Revert synchronously via microtask
|
||||||
Promise.resolve().then(() => {
|
Promise.resolve().then(() => {
|
||||||
if (this.video) {
|
try {
|
||||||
this.video.playbackRate = targetRate;
|
if (this.video) {
|
||||||
|
this.video.playbackRate = targetRate;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.suppressedRateChangeCount = 0;
|
||||||
|
log(`Immediate subtitle nudge restore failed: ${error.message}`, 3);
|
||||||
|
} finally {
|
||||||
|
tc.isNudging = false;
|
||||||
}
|
}
|
||||||
tc.isNudging = false;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
log(`Immediate nudge performed at rate ${targetRate.toFixed(2)}`, 5);
|
log(`Immediate nudge performed at rate ${targetRate.toFixed(2)}`, 5);
|
||||||
@@ -1908,12 +3783,19 @@ function defineVideoController() {
|
|||||||
// YouTube adds ytp-autohide class to the player when controls should be hidden
|
// YouTube adds ytp-autohide class to the player when controls should be hidden
|
||||||
// We mirror this class state to enable CSS-based hiding
|
// We mirror this class state to enable CSS-based hiding
|
||||||
// The vsc-hidden class (from V key) takes precedence via CSS specificity
|
// The vsc-hidden class (from V key) takes precedence via CSS specificity
|
||||||
if (ytPlayer.classList.contains("ytp-autohide")) {
|
if (
|
||||||
|
ytPlayer.classList.contains("ytp-autohide") &&
|
||||||
|
!this.controllerInteractionActive
|
||||||
|
) {
|
||||||
wrapper.classList.add("ytp-autohide");
|
wrapper.classList.add("ytp-autohide");
|
||||||
|
|
||||||
// Immediately end any temporary "vsc-show" state to hide with YouTube
|
// Preserve an active pointer reveal or shortcut-forced reveal. The
|
||||||
// UNLESS it was forced by a shortcut (vsc-forced-show)
|
// ytp-autohide class remains in place, so CSS hides the host as soon as
|
||||||
if (!wrapper.classList.contains("vsc-forced-show")) {
|
// vsc-show's bounded timer expires.
|
||||||
|
if (
|
||||||
|
!wrapper.classList.contains("vsc-forced-show") &&
|
||||||
|
!wrapper.classList.contains("vsc-show")
|
||||||
|
) {
|
||||||
wrapper.classList.remove("vsc-show");
|
wrapper.classList.remove("vsc-show");
|
||||||
if (wrapper.showTimeOut) {
|
if (wrapper.showTimeOut) {
|
||||||
clearTimeout(wrapper.showTimeOut);
|
clearTimeout(wrapper.showTimeOut);
|
||||||
@@ -1953,6 +3835,16 @@ function defineVideoController() {
|
|||||||
showController(wrapper, tc.settings.hideWithControlsTimer * 1000);
|
showController(wrapper, tc.settings.hideWithControlsTimer * 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleControllerInteractionChange = (active) => {
|
||||||
|
if (active) {
|
||||||
|
wrapper.classList.remove("ytp-autohide");
|
||||||
|
showController(wrapper, tc.settings.hideWithControlsTimer * 1000);
|
||||||
|
} else {
|
||||||
|
syncControllerVisibility();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.controllerInteractionChanged = handleControllerInteractionChange;
|
||||||
|
|
||||||
const activityEvents = ["mousemove", "mousedown", "touchstart"];
|
const activityEvents = ["mousemove", "mousedown", "touchstart"];
|
||||||
activityEvents.forEach((type) => {
|
activityEvents.forEach((type) => {
|
||||||
video.addEventListener(type, resetTimer, { passive: true });
|
video.addEventListener(type, resetTimer, { passive: true });
|
||||||
@@ -1967,6 +3859,9 @@ function defineVideoController() {
|
|||||||
wrapper.removeEventListener(type, resetTimer);
|
wrapper.removeEventListener(type, resetTimer);
|
||||||
ytPlayer.removeEventListener(type, resetTimer);
|
ytPlayer.removeEventListener(type, resetTimer);
|
||||||
});
|
});
|
||||||
|
if (this.controllerInteractionChanged === handleControllerInteractionChange) {
|
||||||
|
this.controllerInteractionChanged = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1983,23 +3878,41 @@ function defineVideoController() {
|
|||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
}
|
}
|
||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
|
timer = null;
|
||||||
|
if (this.controllerInteractionActive) return;
|
||||||
// Only hide if the video is not paused
|
// Only hide if the video is not paused
|
||||||
// (Many players keep controls visible while paused)
|
// (Many players keep controls visible while paused)
|
||||||
// However, the user said "Reveal on every mouse and keyboard input"
|
// 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.
|
// We'll follow the timer strictly.
|
||||||
wrapper.classList.add("vsc-idle-hidden");
|
wrapper.classList.add("vsc-idle-hidden");
|
||||||
log("Generic hide: controller hidden due to inactivity", 5);
|
log("Generic hide: controller hidden due to inactivity", 5);
|
||||||
}, tc.settings.hideWithControlsTimer * 1000);
|
}, tc.settings.hideWithControlsTimer * 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleControllerInteractionChange = (active) => {
|
||||||
|
if (active) {
|
||||||
|
wrapper.classList.remove("vsc-idle-hidden");
|
||||||
|
showController(wrapper, tc.settings.hideWithControlsTimer * 1000);
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resetTimer();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.controllerInteractionChanged = handleControllerInteractionChange;
|
||||||
|
|
||||||
// Initial show/timer
|
// Initial show/timer
|
||||||
resetTimer();
|
resetTimer();
|
||||||
|
|
||||||
// The wrapper covers the player area on most sites due to inject.css styles,
|
// The wrapper covers the player area on most sites due to inject.css styles,
|
||||||
// but we listen on both the video and the wrapper for maximum coverage.
|
// but we listen on both the video and the wrapper for maximum coverage.
|
||||||
const activityEvents = ["mousemove", "mousedown", "keydown", "touchstart"];
|
const activityEvents = ["mousemove", "mousedown", "keydown", "touchstart"];
|
||||||
const parentEl = video.parentElement;
|
const parentEl =
|
||||||
|
getControllerGeometryMount(this.controllerHostMount) ||
|
||||||
|
video.parentElement;
|
||||||
activityEvents.forEach((type) => {
|
activityEvents.forEach((type) => {
|
||||||
video.addEventListener(type, resetTimer, { passive: true });
|
video.addEventListener(type, resetTimer, { passive: true });
|
||||||
wrapper.addEventListener(type, resetTimer, { passive: true });
|
wrapper.addEventListener(type, resetTimer, { passive: true });
|
||||||
@@ -2024,6 +3937,9 @@ function defineVideoController() {
|
|||||||
});
|
});
|
||||||
video.removeEventListener("play", resetTimer);
|
video.removeEventListener("play", resetTimer);
|
||||||
video.removeEventListener("pause", resetTimer);
|
video.removeEventListener("pause", resetTimer);
|
||||||
|
if (this.controllerInteractionChanged === handleControllerInteractionChange) {
|
||||||
|
this.controllerInteractionChanged = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
log(`Generic auto-hide setup complete with ${tc.settings.hideWithControlsTimer}s timer`, 4);
|
log(`Generic auto-hide setup complete with ${tc.settings.hideWithControlsTimer}s timer`, 4);
|
||||||
@@ -2034,10 +3950,10 @@ function defineVideoController() {
|
|||||||
const speed = this.video.playbackRate.toFixed(2);
|
const speed = this.video.playbackRate.toFixed(2);
|
||||||
var wrapper = doc.createElement("div");
|
var wrapper = doc.createElement("div");
|
||||||
wrapper.classList.add("vsc-controller");
|
wrapper.classList.add("vsc-controller");
|
||||||
if (!this.video.src && !this.video.currentSrc)
|
if (!hasUsableMediaSource(this.video))
|
||||||
wrapper.classList.add("vsc-nosource");
|
wrapper.classList.add("vsc-nosource");
|
||||||
if (tc.settings.startHidden) wrapper.classList.add("vsc-hidden");
|
if (tc.settings.startHidden) wrapper.classList.add("vsc-hidden");
|
||||||
// z-index is handled by the base .vsc-controller CSS rule (2147483646).
|
// z-index is handled by the base .vsc-controller CSS rule (2147483647).
|
||||||
// The controller lives inside the video container, so high z-index only
|
// The controller lives inside the video container, so high z-index only
|
||||||
// makes it topmost within the local stacking context — it won't overlay
|
// makes it topmost within the local stacking context — it won't overlay
|
||||||
// page-level modals or dialogs.
|
// page-level modals or dialogs.
|
||||||
@@ -2071,11 +3987,14 @@ function defineVideoController() {
|
|||||||
|
|
||||||
buttonConfig.forEach(function(btnId) {
|
buttonConfig.forEach(function(btnId) {
|
||||||
if (btnId === "nudge") {
|
if (btnId === "nudge") {
|
||||||
subtitleNudgeIndicator = doc.createElement("span");
|
subtitleNudgeIndicator = createControllerButton(
|
||||||
|
doc,
|
||||||
|
btnId,
|
||||||
|
controllerButtonDefs.nudge.label,
|
||||||
|
controllerButtonDefs.nudge.className
|
||||||
|
);
|
||||||
subtitleNudgeIndicator.id = "nudge-indicator";
|
subtitleNudgeIndicator.id = "nudge-indicator";
|
||||||
subtitleNudgeIndicator.setAttribute("role", "button");
|
|
||||||
subtitleNudgeIndicator.setAttribute("aria-live", "polite");
|
subtitleNudgeIndicator.setAttribute("aria-live", "polite");
|
||||||
subtitleNudgeIndicator.setAttribute("tabindex", "0");
|
|
||||||
controls.appendChild(subtitleNudgeIndicator);
|
controls.appendChild(subtitleNudgeIndicator);
|
||||||
} else {
|
} else {
|
||||||
var def = controllerButtonDefs[btnId];
|
var def = controllerButtonDefs[btnId];
|
||||||
@@ -2097,6 +4016,27 @@ function defineVideoController() {
|
|||||||
controller.appendChild(nudgeFlashIndicator);
|
controller.appendChild(nudgeFlashIndicator);
|
||||||
shadow.appendChild(controller);
|
shadow.appendChild(controller);
|
||||||
|
|
||||||
|
const setControllerInteractionActive = (active) => {
|
||||||
|
this.controllerInteractionActive = active === true;
|
||||||
|
controller.classList.toggle(
|
||||||
|
"vsc-controls-hovered",
|
||||||
|
this.controllerInteractionActive
|
||||||
|
);
|
||||||
|
wrapper.classList.toggle(
|
||||||
|
"vsc-controls-hovered",
|
||||||
|
this.controllerInteractionActive
|
||||||
|
);
|
||||||
|
if (typeof this.controllerInteractionChanged === "function") {
|
||||||
|
this.controllerInteractionChanged(this.controllerInteractionActive);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
controller.addEventListener("pointerenter", () => {
|
||||||
|
setControllerInteractionActive(true);
|
||||||
|
});
|
||||||
|
controller.addEventListener("pointerleave", () => {
|
||||||
|
setControllerInteractionActive(false);
|
||||||
|
});
|
||||||
|
|
||||||
this.speedIndicator = dragHandle;
|
this.speedIndicator = dragHandle;
|
||||||
this.subtitleNudgeIndicator = subtitleNudgeIndicator;
|
this.subtitleNudgeIndicator = subtitleNudgeIndicator;
|
||||||
this.nudgeFlashIndicator = nudgeFlashIndicator;
|
this.nudgeFlashIndicator = nudgeFlashIndicator;
|
||||||
@@ -2138,41 +4078,23 @@ function defineVideoController() {
|
|||||||
true
|
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("click", (e) => e.stopPropagation(), false);
|
||||||
controller.addEventListener("mousedown", (e) => e.stopPropagation(), false);
|
controller.addEventListener("mousedown", (e) => e.stopPropagation(), false);
|
||||||
|
|
||||||
// Setup auto-hide observers if enabled
|
const parentEl =
|
||||||
if (tc.settings.hideWithControls) {
|
this.parent || this.video.parentElement || this.video.parentNode;
|
||||||
if (isOnYouTube()) {
|
|
||||||
this.setupYouTubeAutoHide(wrapper);
|
|
||||||
} else {
|
|
||||||
this.setupGenericAutoHide(wrapper);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const parentEl = this.parent || this.video.parentElement;
|
|
||||||
var mountEl = getControllerMount(this.video) || parentEl;
|
var mountEl = getControllerMount(this.video) || parentEl;
|
||||||
|
|
||||||
log(`Inserting controller: parentEl=${!!parentEl}, parentNode=${!!parentEl?.parentNode}, hostname=${location.hostname}`, 4);
|
log(`Inserting controller: parentEl=${!!parentEl}, hostname=${location.hostname}`, 4);
|
||||||
|
|
||||||
if (!parentEl || !parentEl.parentNode) {
|
if (
|
||||||
|
!mountEl ||
|
||||||
|
!(mountEl.isConnected ||
|
||||||
|
(isShadowRootNode(mountEl) && mountEl.host.isConnected))
|
||||||
|
) {
|
||||||
log("No suitable parent found, appending to body", 4);
|
log("No suitable parent found, appending to body", 4);
|
||||||
doc.body.appendChild(wrapper);
|
doc.body.appendChild(wrapper);
|
||||||
|
this.normalControllerMount = doc.body;
|
||||||
setupControllerHostTracking(this, wrapper, doc.body);
|
setupControllerHostTracking(this, wrapper, doc.body);
|
||||||
return wrapper;
|
return wrapper;
|
||||||
}
|
}
|
||||||
@@ -2203,16 +4125,20 @@ function defineVideoController() {
|
|||||||
case location.hostname === "www.youtube.com":
|
case location.hostname === "www.youtube.com":
|
||||||
case location.hostname === "m.youtube.com":
|
case location.hostname === "m.youtube.com":
|
||||||
case location.hostname === "music.youtube.com":
|
case location.hostname === "music.youtube.com":
|
||||||
mountEl = parentEl.closest(".html5-video-player") || mountEl;
|
mountEl =
|
||||||
|
(parentEl.closest && parentEl.closest(".html5-video-player")) ||
|
||||||
|
mountEl;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
mountEl.insertBefore(wrapper, mountEl.firstChild);
|
mountEl.appendChild(wrapper);
|
||||||
|
this.normalControllerMount = mountEl;
|
||||||
setupControllerHostTracking(this, wrapper, mountEl);
|
setupControllerHostTracking(this, wrapper, mountEl);
|
||||||
log("Controller successfully inserted into DOM", 4);
|
log("Controller successfully inserted into DOM", 4);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log(`Error inserting controller: ${error.message}`, 2);
|
log(`Error inserting controller: ${error.message}`, 2);
|
||||||
// Fallback to body insertion
|
// Fallback to body insertion
|
||||||
doc.body.appendChild(wrapper);
|
doc.body.appendChild(wrapper);
|
||||||
|
this.normalControllerMount = doc.body;
|
||||||
setupControllerHostTracking(this, wrapper, doc.body);
|
setupControllerHostTracking(this, wrapper, doc.body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2221,18 +4147,31 @@ function defineVideoController() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applySiteRuleOverrides() {
|
function applySiteRuleOverrides() {
|
||||||
|
var wasForceEnabled = tc.settings.forceLastSavedSpeed === true;
|
||||||
|
var wasRememberEnabled = tc.settings.rememberSpeed === true;
|
||||||
|
|
||||||
|
function finishSiteRuleApplication(result) {
|
||||||
|
if (
|
||||||
|
(wasForceEnabled && !tc.settings.forceLastSavedSpeed) ||
|
||||||
|
(wasRememberEnabled && !tc.settings.rememberSpeed)
|
||||||
|
) {
|
||||||
|
clearAllSpeedRestoreEnforcement();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
resetSettingsFromSiteRuleBase();
|
resetSettingsFromSiteRuleBase();
|
||||||
tc.activeSiteRule = null;
|
tc.activeSiteRule = null;
|
||||||
|
|
||||||
if (!Array.isArray(tc.settings.siteRules) || tc.settings.siteRules.length === 0) {
|
if (!Array.isArray(tc.settings.siteRules) || tc.settings.siteRules.length === 0) {
|
||||||
return false;
|
return finishSiteRuleApplication(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentUrl = location.href;
|
var currentUrl = location.href;
|
||||||
var matchedRule = siteRuleUtils.matchSiteRule(currentUrl, tc.settings.siteRules);
|
var matchedRule = siteRuleUtils.matchSiteRule(currentUrl, tc.settings.siteRules);
|
||||||
|
|
||||||
if (!matchedRule) {
|
if (!matchedRule) {
|
||||||
return false;
|
return finishSiteRuleApplication(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
tc.activeSiteRule = matchedRule;
|
tc.activeSiteRule = matchedRule;
|
||||||
@@ -2241,7 +4180,7 @@ function applySiteRuleOverrides() {
|
|||||||
// Check if extension should be enabled/disabled on this site
|
// Check if extension should be enabled/disabled on this site
|
||||||
if (siteRuleUtils.isSiteRuleDisabled(matchedRule)) {
|
if (siteRuleUtils.isSiteRuleDisabled(matchedRule)) {
|
||||||
log(`Extension disabled for site: ${currentUrl}`, 4);
|
log(`Extension disabled for site: ${currentUrl}`, 4);
|
||||||
return true;
|
return finishSiteRuleApplication(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override general settings with site-specific overrides
|
// Override general settings with site-specific overrides
|
||||||
@@ -2253,9 +4192,11 @@ function applySiteRuleOverrides() {
|
|||||||
"rememberSpeed",
|
"rememberSpeed",
|
||||||
"forceLastSavedSpeed",
|
"forceLastSavedSpeed",
|
||||||
"audioBoolean",
|
"audioBoolean",
|
||||||
|
"showAmbientLoopControls",
|
||||||
"controllerOpacity",
|
"controllerOpacity",
|
||||||
"controllerMarginTop",
|
"controllerMarginTop",
|
||||||
"controllerMarginBottom",
|
"controllerMarginBottom",
|
||||||
|
"shortcutTargetMode",
|
||||||
"enableSubtitleNudge",
|
"enableSubtitleNudge",
|
||||||
"subtitleNudgeEnabledByDefault",
|
"subtitleNudgeEnabledByDefault",
|
||||||
"subtitleNudgeInterval"
|
"subtitleNudgeInterval"
|
||||||
@@ -2297,7 +4238,19 @@ function applySiteRuleOverrides() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return finishSiteRuleApplication(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeIneligibleMediaControllers() {
|
||||||
|
tc.mediaElements.slice().forEach(function(media) {
|
||||||
|
if (
|
||||||
|
media &&
|
||||||
|
((media.nodeName === "AUDIO" && !tc.settings.audioBoolean) ||
|
||||||
|
isAmbientLoopMedia(media))
|
||||||
|
) {
|
||||||
|
removeController(media);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Apply current tc.settings controller layout/opacity to every attached controller (after site rules). */
|
/** Apply current tc.settings controller layout/opacity to every attached controller (after site rules). */
|
||||||
@@ -2309,16 +4262,40 @@ function refreshAllControllerGeometry() {
|
|||||||
if (controllerEl) {
|
if (controllerEl) {
|
||||||
controllerEl.style.opacity = String(tc.settings.controllerOpacity);
|
controllerEl.style.opacity = String(tc.settings.controllerOpacity);
|
||||||
}
|
}
|
||||||
|
configureControllerAutoHide(video.vsc, video.vsc.div, false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Re-match site rules for current URL and refresh controller position/opacity on every video. */
|
/** Re-match site rules for current URL and refresh controller position/opacity on every video. */
|
||||||
function reapplySiteRulesAndControllerGeometry() {
|
function reapplySiteRulesAndControllerGeometry() {
|
||||||
applySiteRuleOverrides();
|
applySiteRuleOverrides();
|
||||||
if (!siteRuleUtils.isSpeederActiveForSite(tc.settings.enabled, tc.activeSiteRule)) {
|
if (!isSpeederActiveForCurrentPage()) {
|
||||||
return;
|
tc.mediaElements.slice().forEach(function(video) {
|
||||||
|
removeController(video);
|
||||||
|
});
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
removeIneligibleMediaControllers();
|
||||||
refreshAllControllerGeometry();
|
refreshAllControllerGeometry();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRememberedSpeedPolicy(video) {
|
||||||
|
if (!video || !video.vsc) return false;
|
||||||
|
var rememberedSpeed = getRememberedSpeed(video);
|
||||||
|
if (!isValidSpeed(rememberedSpeed)) return false;
|
||||||
|
|
||||||
|
video.vsc.targetSpeed = rememberedSpeed;
|
||||||
|
video.vsc.targetSpeedSourceKey = getVideoSourceKey(video);
|
||||||
|
video.vsc.targetSpeedOrigin = "policy";
|
||||||
|
if (video.vsc.speedIndicator) {
|
||||||
|
video.vsc.speedIndicator.textContent = rememberedSpeed.toFixed(2);
|
||||||
|
}
|
||||||
|
extendSpeedRestoreWindow(video);
|
||||||
|
if (Math.abs(video.playbackRate - rememberedSpeed) > 0.01) {
|
||||||
|
setSpeed(video, rememberedSpeed, false, false);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldPreserveDesiredSpeed(video, speed) {
|
function shouldPreserveDesiredSpeed(video, speed) {
|
||||||
@@ -2328,8 +4305,13 @@ function shouldPreserveDesiredSpeed(video, speed) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var preservesPausedTarget =
|
||||||
|
video.paused === true &&
|
||||||
|
(video.vsc.targetSpeedOrigin === "user" ||
|
||||||
|
video.vsc.targetSpeedOrigin === "policy");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
video.paused === true ||
|
preservesPausedTarget ||
|
||||||
(typeof video.vsc.speedRestoreUntil === "number" &&
|
(typeof video.vsc.speedRestoreUntil === "number" &&
|
||||||
video.vsc.speedRestoreUntil > Date.now())
|
video.vsc.speedRestoreUntil > Date.now())
|
||||||
);
|
);
|
||||||
@@ -2339,21 +4321,30 @@ function setupListener(root) {
|
|||||||
root = root || document;
|
root = root || document;
|
||||||
if (root.vscRateListenerAttached) return;
|
if (root.vscRateListenerAttached) return;
|
||||||
|
|
||||||
function updateSpeedFromEvent(video, skipResetDisarm) {
|
function updateSpeedFromEvent(video, options) {
|
||||||
if (!video.vsc || !video.vsc.speedIndicator) return;
|
if (!video.vsc || !video.vsc.speedIndicator) return;
|
||||||
if (!skipResetDisarm) {
|
var config = options || {};
|
||||||
|
if (config.skipResetDisarm !== true) {
|
||||||
video.vsc.resetToggleArmed = false;
|
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.speedIndicator.textContent = speed.toFixed(2);
|
||||||
video.vsc.targetSpeed = speed;
|
video.vsc.targetSpeed = speed;
|
||||||
video.vsc.targetSpeedSourceKey = getVideoSourceKey(video);
|
video.vsc.targetSpeedSourceKey = getVideoSourceKey(video);
|
||||||
var sourceKey = getVideoSourceKey(video);
|
|
||||||
if (sourceKey !== "unknown_src") {
|
// A native ratechange has no trustworthy user-origin signal; sites emit the
|
||||||
tc.settings.speeds[sourceKey] = speed;
|
// same event when they clamp/reset speed. Durable intent is recorded only
|
||||||
|
// by explicit Speeder actions (which pass shouldPersist).
|
||||||
|
var shouldPersist = config.shouldPersist === true;
|
||||||
|
if (config.preserveTargetOrigin !== true) {
|
||||||
|
video.vsc.targetSpeedOrigin = shouldPersist ? "user" : "external";
|
||||||
}
|
}
|
||||||
tc.settings.lastSpeed = speed;
|
if (shouldPersist) {
|
||||||
schedulePersistLastSpeed(speed);
|
rememberSourceSpeed(video, speed);
|
||||||
|
tc.settings.lastSpeed = speed;
|
||||||
|
schedulePersistLastSpeed(speed);
|
||||||
|
}
|
||||||
|
|
||||||
if (video.vsc) {
|
if (video.vsc) {
|
||||||
if (speed === 1.0 || video.paused) video.vsc.stopSubtitleNudge();
|
if (speed === 1.0 || video.paused) video.vsc.stopSubtitleNudge();
|
||||||
else video.vsc.startSubtitleNudge();
|
else video.vsc.startSubtitleNudge();
|
||||||
@@ -2368,21 +4359,36 @@ function setupListener(root) {
|
|||||||
if (!video || typeof video.playbackRate === "undefined" || !video.vsc)
|
if (!video || typeof video.playbackRate === "undefined" || !video.vsc)
|
||||||
return;
|
return;
|
||||||
if (shouldIgnoreSuppressedRateChange(video)) return;
|
if (shouldIgnoreSuppressedRateChange(video)) return;
|
||||||
|
var currentSpeed = video.playbackRate; // Preserve full precision (e.g. 0.01)
|
||||||
|
var pendingRateChange = takePendingRateChange(video, currentSpeed);
|
||||||
if (tc.settings.forceLastSavedSpeed) {
|
if (tc.settings.forceLastSavedSpeed) {
|
||||||
if (event.detail && event.detail.origin === "videoSpeed") {
|
if (pendingRateChange) {
|
||||||
video.playbackRate = event.detail.speed;
|
updateSpeedFromEvent(video, {
|
||||||
updateSpeedFromEvent(video, true);
|
skipResetDisarm: true,
|
||||||
|
shouldPersist: pendingRateChange.shouldPersist === true,
|
||||||
|
preserveTargetOrigin: true
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
video.playbackRate = sanitizeSpeed(tc.settings.lastSpeed, 1.0);
|
var forcedSpeed = sanitizeSpeed(tc.settings.lastSpeed, 1.0);
|
||||||
|
if (Math.abs(currentSpeed - forcedSpeed) > 0.001) {
|
||||||
|
setSpeed(video, forcedSpeed, false, false);
|
||||||
|
} else {
|
||||||
|
updateSpeedFromEvent(video, {
|
||||||
|
shouldPersist: false,
|
||||||
|
preserveTargetOrigin: true
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
event.stopImmediatePropagation();
|
event.stopImmediatePropagation();
|
||||||
} else {
|
} else {
|
||||||
var currentSpeed = video.playbackRate; // Preserve full precision (e.g. 0.0625)
|
|
||||||
var desiredSpeed = getDesiredSpeed(video);
|
var desiredSpeed = getDesiredSpeed(video);
|
||||||
var pendingRateChange = takePendingRateChange(video, currentSpeed);
|
|
||||||
|
|
||||||
if (pendingRateChange) {
|
if (pendingRateChange) {
|
||||||
updateSpeedFromEvent(video, true);
|
updateSpeedFromEvent(video, {
|
||||||
|
skipResetDisarm: true,
|
||||||
|
shouldPersist: pendingRateChange.shouldPersist === true,
|
||||||
|
preserveTargetOrigin: true
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2397,7 +4403,7 @@ function setupListener(root) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSpeedFromEvent(video);
|
updateSpeedFromEvent(video, { shouldPersist: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
true
|
true
|
||||||
@@ -2422,7 +4428,11 @@ function clearPendingInitialization(doc) {
|
|||||||
|
|
||||||
function tryInitializeDocument(doc, forceReinit) {
|
function tryInitializeDocument(doc, forceReinit) {
|
||||||
if (!doc) return false;
|
if (!doc) return false;
|
||||||
if ((!forceReinit && vscInitializedDocuments.has(doc)) || !doc.body) {
|
if (
|
||||||
|
!tc.runtimeSettingsHydrated ||
|
||||||
|
(!forceReinit && vscInitializedDocuments.has(doc)) ||
|
||||||
|
!doc.body
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2465,16 +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) {
|
function attachKeydownListeners(doc) {
|
||||||
|
// Content scripts already run in every frame. Keeping each listener scoped
|
||||||
|
// to its own frame avoids duplicate shortcuts and stale iframe ownership.
|
||||||
var docs = [doc];
|
var docs = [doc];
|
||||||
try {
|
|
||||||
if (inIframe() && window.top.document !== doc) docs.push(window.top.document);
|
|
||||||
} catch (e) {
|
|
||||||
}
|
|
||||||
|
|
||||||
docs.forEach(function(keyDoc) {
|
docs.forEach(function(keyDoc) {
|
||||||
if (keyDoc.vscKeydownListenerAttached) return;
|
var keyTarget = keyDoc.defaultView || keyDoc;
|
||||||
keyDoc.addEventListener(
|
if (keyTarget.vscKeydownListenerAttached) return;
|
||||||
|
keyTarget.addEventListener(
|
||||||
"keydown",
|
"keydown",
|
||||||
function(event) {
|
function(event) {
|
||||||
if (
|
if (
|
||||||
@@ -2489,10 +4524,10 @@ function attachKeydownListeners(doc) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isEditableShortcutTarget(event)) return;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
event.target.nodeName === "INPUT" ||
|
!isSpeederActiveForCurrentPage()
|
||||||
event.target.nodeName === "TEXTAREA" ||
|
|
||||||
event.target.isContentEditable
|
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2506,18 +4541,40 @@ function attachKeydownListeners(doc) {
|
|||||||
if (item) {
|
if (item) {
|
||||||
runAction(item.action, item.value, event);
|
runAction(item.action, item.value, event);
|
||||||
|
|
||||||
// Always prevent default and stop propagation for Speeder shortcuts
|
// The per-site "Block site from capturing keypress" option owns
|
||||||
// to prevent the website (e.g. YouTube) from reacting to these keys.
|
// propagation. Speeder still runs unblocked shortcuts, but the page
|
||||||
event.preventDefault();
|
// can receive them too when the user left that option off.
|
||||||
event.stopPropagation();
|
if (item.force === true || item.force === "true") {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
keyDoc.vscKeydownListenerAttached = true;
|
keyTarget.vscKeydownListenerAttached = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function attachMediaTargetTracking(doc) {
|
||||||
|
if (!doc || doc.vscMediaTargetTrackingAttached) return;
|
||||||
|
doc.addEventListener(
|
||||||
|
"mousemove",
|
||||||
|
function(event) {
|
||||||
|
if (!Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tc.lastPointerPosition = {
|
||||||
|
document: doc,
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ capture: true, passive: true }
|
||||||
|
);
|
||||||
|
doc.vscMediaTargetTrackingAttached = true;
|
||||||
|
}
|
||||||
|
|
||||||
function attachMutationObserver(root) {
|
function attachMutationObserver(root) {
|
||||||
if (root.vscMutationObserverAttached) return;
|
if (root.vscMutationObserverAttached) return;
|
||||||
|
|
||||||
@@ -2532,9 +4589,11 @@ function attachMutationObserver(root) {
|
|||||||
function() {
|
function() {
|
||||||
var mutationsToProcess = pendingMutations.splice(0);
|
var mutationsToProcess = pendingMutations.splice(0);
|
||||||
mutationProcessingScheduled = false;
|
mutationProcessingScheduled = false;
|
||||||
|
var controllerMountTargets = new Set();
|
||||||
|
|
||||||
mutationsToProcess.forEach(function(mutation) {
|
mutationsToProcess.forEach(function(mutation) {
|
||||||
if (mutation.type === "childList") {
|
if (mutation.type === "childList") {
|
||||||
|
controllerMountTargets.add(mutation.target);
|
||||||
mutation.addedNodes.forEach(function(node) {
|
mutation.addedNodes.forEach(function(node) {
|
||||||
// Skip text nodes, comments, etc. — only elements can contain media
|
// Skip text nodes, comments, etc. — only elements can contain media
|
||||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||||
@@ -2544,6 +4603,7 @@ function attachMutationObserver(root) {
|
|||||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||||
scanNodeForMedia(node, node.parentNode || mutation.target, false);
|
scanNodeForMedia(node, node.parentNode || mutation.target, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2559,6 +4619,15 @@ function attachMutationObserver(root) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (
|
||||||
|
target.nodeName === "SOURCE" &&
|
||||||
|
mutation.attributeName === "src"
|
||||||
|
) {
|
||||||
|
ensureControllerForMediaChild(target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
mutation.attributeName === "aria-hidden" &&
|
mutation.attributeName === "aria-hidden" &&
|
||||||
target.attributes["aria-hidden"] &&
|
target.attributes["aria-hidden"] &&
|
||||||
@@ -2571,6 +4640,26 @@ function attachMutationObserver(root) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reconcile once per batch, not once per mutation. Large news/social
|
||||||
|
// pages can emit hundreds of childList records in one render pass.
|
||||||
|
if (controllerMountTargets.size > 0) {
|
||||||
|
tc.mediaElements.slice().forEach(function(video) {
|
||||||
|
if (!video || !video.vsc || !video.vsc.div) return;
|
||||||
|
var mount = video.vsc.controllerHostMount;
|
||||||
|
if (!controllerMountTargets.has(mount)) return;
|
||||||
|
remountControllerHost(video.vsc, mount);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Document selectors do not cross shadow boundaries. A detached
|
||||||
|
// custom-element host can therefore hide connected-looking media from
|
||||||
|
// the removed subtree scan; prune by composed connectivity as a final
|
||||||
|
// reconciliation step for every mutation batch.
|
||||||
|
tc.mediaElements.slice().forEach(function(video) {
|
||||||
|
if (!video || video.isConnected) return;
|
||||||
|
removeController(video);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
{ timeout: 1000 }
|
{ timeout: 1000 }
|
||||||
);
|
);
|
||||||
@@ -2608,54 +4697,49 @@ function attachMediaDetectionListeners(root) {
|
|||||||
root.vscMediaEventListenersAttached = true;
|
root.vscMediaEventListenersAttached = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachIframeListeners(doc) {
|
function scheduleNavigationRescan() {
|
||||||
Array.from(doc.getElementsByTagName("iframe")).forEach(function(frame) {
|
var nextHref = location.href;
|
||||||
if (!frame.vscLoadListenerAttached) {
|
if (window.vscLastObservedHref !== nextHref) {
|
||||||
frame.addEventListener("load", function() {
|
// Pointer and controller interaction state belongs to the previous route.
|
||||||
try {
|
// YouTube keeps hover-preview and Shorts players connected across SPA
|
||||||
if (frame.contentDocument) {
|
// navigations, so retaining either target can send shortcuts to an
|
||||||
initializeWhenReady(frame.contentDocument, true);
|
// off-screen/stale player even though the new watch controller works.
|
||||||
}
|
tc.lastPointerPosition = null;
|
||||||
} catch (e) {
|
tc.lastInteractedMedia = null;
|
||||||
}
|
}
|
||||||
});
|
window.vscLastObservedHref = nextHref;
|
||||||
frame.vscLoadListenerAttached = true;
|
clearTimeout(window.vscNavigationRescanTimer);
|
||||||
}
|
window.vscNavigationRescanTimer = setTimeout(function() {
|
||||||
|
initializeWhenReady(document, true);
|
||||||
try {
|
}, 300);
|
||||||
if (frame.contentDocument) {
|
|
||||||
initializeWhenReady(frame.contentDocument);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachNavigationListeners() {
|
function attachNavigationListeners() {
|
||||||
if (window.vscNavigationListenersAttached) return;
|
if (window.vscNavigationListenersAttached) return;
|
||||||
|
|
||||||
var scheduleRescan = function() {
|
|
||||||
clearTimeout(window.vscNavigationRescanTimer);
|
|
||||||
window.vscNavigationRescanTimer = setTimeout(function() {
|
|
||||||
initializeWhenReady(document, true);
|
|
||||||
}, 300);
|
|
||||||
};
|
|
||||||
|
|
||||||
["pushState", "replaceState"].forEach(function(method) {
|
["pushState", "replaceState"].forEach(function(method) {
|
||||||
if (typeof history[method] !== "function") return;
|
if (typeof history[method] !== "function") return;
|
||||||
var original = history[method];
|
var original = history[method];
|
||||||
history[method] = function() {
|
history[method] = function() {
|
||||||
var result = original.apply(this, arguments);
|
var result = original.apply(this, arguments);
|
||||||
scheduleRescan();
|
scheduleNavigationRescan();
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener("popstate", scheduleRescan);
|
window.addEventListener("popstate", scheduleNavigationRescan);
|
||||||
window.addEventListener("hashchange", scheduleRescan);
|
window.addEventListener("hashchange", scheduleNavigationRescan);
|
||||||
/* YouTube often navigates without a history API call the extension can see first */
|
/* YouTube often navigates without a history API call the extension can see first */
|
||||||
if (typeof document !== "undefined" && isOnYouTube()) {
|
if (typeof document !== "undefined" && isOnYouTube()) {
|
||||||
document.addEventListener("yt-navigate-finish", scheduleRescan);
|
document.addEventListener("yt-navigate-finish", scheduleNavigationRescan);
|
||||||
|
}
|
||||||
|
window.vscLastObservedHref = location.href;
|
||||||
|
if (!window.vscLocationWatchTimer) {
|
||||||
|
window.vscLocationWatchTimer = setInterval(function() {
|
||||||
|
if (window.vscLastObservedHref !== location.href) {
|
||||||
|
scheduleNavigationRescan();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
}
|
}
|
||||||
window.vscNavigationListenersAttached = true;
|
window.vscNavigationListenersAttached = true;
|
||||||
}
|
}
|
||||||
@@ -2663,43 +4747,46 @@ function attachNavigationListeners() {
|
|||||||
function initializeNow(doc, forceReinit = false) {
|
function initializeNow(doc, forceReinit = false) {
|
||||||
if ((!forceReinit && vscInitializedDocuments.has(doc)) || !doc.body) return;
|
if ((!forceReinit && vscInitializedDocuments.has(doc)) || !doc.body) return;
|
||||||
|
|
||||||
|
// Navigation must remain observable even while this URL is disabled, or a
|
||||||
|
// later whitelisted SPA route can never reactivate the extension.
|
||||||
|
attachNavigationListeners();
|
||||||
|
if (typeof tc.videoController === "undefined") defineVideoController();
|
||||||
applySiteRuleOverrides();
|
applySiteRuleOverrides();
|
||||||
if (!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.
|
||||||
|
observeRoot(doc);
|
||||||
|
if (!isActive) {
|
||||||
|
tc.mediaElements.slice().forEach(function(video) {
|
||||||
|
removeController(video);
|
||||||
|
});
|
||||||
|
vscInitializedDocuments.add(doc);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removeIneligibleMediaControllers();
|
||||||
|
|
||||||
if (!doc.body.classList.contains("vsc-initialized")) {
|
if (!doc.body.classList.contains("vsc-initialized")) {
|
||||||
doc.body.classList.add("vsc-initialized");
|
doc.body.classList.add("vsc-initialized");
|
||||||
}
|
}
|
||||||
if (typeof tc.videoController === "undefined") defineVideoController();
|
|
||||||
attachKeydownListeners(doc);
|
attachKeydownListeners(doc);
|
||||||
attachNavigationListeners();
|
attachMediaTargetTracking(doc);
|
||||||
observeRoot(doc);
|
|
||||||
|
|
||||||
// Delayed rescan to catch custom elements whose shadow roots were created
|
|
||||||
// before our attachShadow patch was installed (e.g. archive.org's <play-av>
|
|
||||||
// Lit component, or any site that lazily creates shadow DOM video players).
|
|
||||||
if (!doc.vscDelayedShadowScanDone) {
|
|
||||||
doc.vscDelayedShadowScanDone = true;
|
|
||||||
setTimeout(function() {
|
|
||||||
if (!doc.body) return;
|
|
||||||
try {
|
|
||||||
var els = doc.body.querySelectorAll("*");
|
|
||||||
for (var i = 0; i < els.length; i++) {
|
|
||||||
if (els[i].shadowRoot && !vscObservedRoots.has(els[i].shadowRoot)) {
|
|
||||||
observeRoot(els[i].shadowRoot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (forceReinit) {
|
if (forceReinit) {
|
||||||
log("Force re-initialization requested", 4);
|
log("Force re-initialization requested", 4);
|
||||||
|
// A root is normally scanned only once. A user-requested rescan must also
|
||||||
|
// revisit media that was present but source-less during the initial scan.
|
||||||
|
scanRootForMedia(doc);
|
||||||
|
rescanObservedMediaRoots(doc);
|
||||||
refreshAllControllerGeometry();
|
refreshAllControllerGeometry();
|
||||||
|
if (tc.settings.rememberSpeed || tc.settings.forceLastSavedSpeed) {
|
||||||
|
tc.mediaElements.slice().forEach(applyRememberedSpeedPolicy);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
vscInitializedDocuments.add(doc);
|
vscInitializedDocuments.add(doc);
|
||||||
|
flushPendingMediaCandidates();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSpeed(
|
function setSpeed(
|
||||||
@@ -2729,12 +4816,20 @@ function setSpeed(
|
|||||||
`setSpeed: Target ${numericSpeed.toFixed(2)}. Initial: ${isInitialCall}. UserKeyPress: ${isUserKeyPress}`,
|
`setSpeed: Target ${numericSpeed.toFixed(2)}. Initial: ${isInitialCall}. UserKeyPress: ${isUserKeyPress}`,
|
||||||
4
|
4
|
||||||
);
|
);
|
||||||
tc.settings.lastSpeed = numericSpeed;
|
|
||||||
video.vsc.speedIndicator.textContent = numericSpeed.toFixed(2);
|
video.vsc.speedIndicator.textContent = numericSpeed.toFixed(2);
|
||||||
|
|
||||||
|
if (isUserKeyPress) {
|
||||||
|
tc.settings.lastSpeed = numericSpeed;
|
||||||
|
rememberSourceSpeed(video, numericSpeed);
|
||||||
|
schedulePersistLastSpeed(numericSpeed);
|
||||||
|
}
|
||||||
|
|
||||||
// Update the target speed for nudge so it knows what to revert to
|
// Update the target speed for nudge so it knows what to revert to
|
||||||
video.vsc.targetSpeed = numericSpeed;
|
video.vsc.targetSpeed = numericSpeed;
|
||||||
video.vsc.targetSpeedSourceKey = getVideoSourceKey(video);
|
video.vsc.targetSpeedSourceKey = getVideoSourceKey(video);
|
||||||
|
if (isUserKeyPress) {
|
||||||
|
video.vsc.targetSpeedOrigin = "user";
|
||||||
|
}
|
||||||
|
|
||||||
if (isUserKeyPress && !isInitialCall && video.vsc && video.vsc.div) {
|
if (isUserKeyPress && !isInitialCall && video.vsc && video.vsc.div) {
|
||||||
runAction("blink", 1000, null, video); // Pass video to blink
|
runAction("blink", 1000, null, video); // Pass video to blink
|
||||||
@@ -2743,25 +4838,35 @@ function setSpeed(
|
|||||||
|
|
||||||
// Try YouTube's native speed API first — keeps subtitles in sync without nudge
|
// Try YouTube's native speed API first — keeps subtitles in sync without nudge
|
||||||
var usedNativeSpeed = false;
|
var usedNativeSpeed = false;
|
||||||
if (tc.settings.forceLastSavedSpeed) {
|
if (Math.abs(video.playbackRate - numericSpeed) > 0.001) {
|
||||||
video.dispatchEvent(
|
rememberPendingRateChange(video, numericSpeed, isUserKeyPress);
|
||||||
new CustomEvent("ratechange", {
|
if (!tc.settings.forceLastSavedSpeed) {
|
||||||
detail: {
|
|
||||||
origin: "videoSpeed",
|
|
||||||
speed: numericSpeed.toFixed(2),
|
|
||||||
fromUserInput: isUserKeyPress
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
if (Math.abs(video.playbackRate - numericSpeed) > 0.001) {
|
|
||||||
rememberPendingRateChange(video, numericSpeed);
|
|
||||||
usedNativeSpeed = tryYouTubeNativeSpeed(video, numericSpeed);
|
usedNativeSpeed = tryYouTubeNativeSpeed(video, numericSpeed);
|
||||||
if (!usedNativeSpeed) {
|
}
|
||||||
|
if (!usedNativeSpeed) {
|
||||||
|
try {
|
||||||
video.playbackRate = numericSpeed;
|
video.playbackRate = numericSpeed;
|
||||||
|
} catch (error) {
|
||||||
|
if (video.vsc) video.vsc.pendingRateChange = null;
|
||||||
|
log(`Unable to set playbackRate to ${numericSpeed}: ${error.message}`, 2);
|
||||||
|
if (
|
||||||
|
tc.settings.forceLastSavedSpeed ||
|
||||||
|
(tc.settings.rememberSpeed && video.vsc.targetSpeedOrigin === "policy") ||
|
||||||
|
Number(video.vsc.speedRestoreUntil) > Date.now()
|
||||||
|
) {
|
||||||
|
scheduleSpeedVerification(video, numericSpeed, true);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
tc.settings.forceLastSavedSpeed ||
|
||||||
|
(tc.settings.rememberSpeed && video.vsc.targetSpeedOrigin === "policy") ||
|
||||||
|
Number(video.vsc.speedRestoreUntil) > Date.now()
|
||||||
|
) {
|
||||||
|
scheduleSpeedVerification(video, numericSpeed, true);
|
||||||
|
}
|
||||||
if (video.vsc) {
|
if (video.vsc) {
|
||||||
if (numericSpeed === 1.0 || video.paused) {
|
if (numericSpeed === 1.0 || video.paused) {
|
||||||
video.vsc.stopSubtitleNudge();
|
video.vsc.stopSubtitleNudge();
|
||||||
@@ -2772,33 +4877,119 @@ function setSpeed(
|
|||||||
video.vsc.startSubtitleNudge();
|
video.vsc.startSubtitleNudge();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMediaFromControllerEvent(event, candidates) {
|
||||||
|
if (!event || !event.target || !event.target.getRootNode) return null;
|
||||||
|
var root = event.target.getRootNode();
|
||||||
|
var controllerHost = root && root.host;
|
||||||
|
if (!controllerHost) return null;
|
||||||
|
return candidates.find(function(video) {
|
||||||
|
return video.vsc && video.vsc.div === controllerHost;
|
||||||
|
}) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function distanceSquaredToRect(x, y, rect) {
|
||||||
|
if (!rect) return Infinity;
|
||||||
|
var dx = x < rect.left ? rect.left - x : x > rect.right ? x - rect.right : 0;
|
||||||
|
var dy = y < rect.top ? rect.top - y : y > rect.bottom ? y - rect.bottom : 0;
|
||||||
|
return dx * dx + dy * dy;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClosestMediaToPointer(candidates, pointerPosition) {
|
||||||
|
if (!pointerPosition || !Array.isArray(candidates)) return null;
|
||||||
|
var best = null;
|
||||||
|
var bestDistance = Infinity;
|
||||||
|
|
||||||
|
candidates.forEach(function(video) {
|
||||||
|
if (!video || !video.vsc || !video.isConnected) return;
|
||||||
|
var target = getControllerElement(video.vsc) || video;
|
||||||
|
var rect = null;
|
||||||
|
try {
|
||||||
|
rect = target.getBoundingClientRect();
|
||||||
|
if (!rect || rect.width <= 0 || rect.height <= 0) {
|
||||||
|
rect = video.getBoundingClientRect();
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!rect || rect.width <= 0 || rect.height <= 0) return;
|
||||||
|
var distance = distanceSquaredToRect(
|
||||||
|
pointerPosition.x,
|
||||||
|
pointerPosition.y,
|
||||||
|
rect
|
||||||
|
);
|
||||||
|
if (distance < bestDistance) {
|
||||||
|
best = video;
|
||||||
|
bestDistance = distance;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveActionMediaTargets(event, specificVideo) {
|
||||||
|
if (specificVideo && specificVideo.vsc) return [specificVideo];
|
||||||
|
|
||||||
|
var docContext =
|
||||||
|
event && event.target && event.target.ownerDocument
|
||||||
|
? event.target.ownerDocument
|
||||||
|
: document;
|
||||||
|
var candidates = tc.mediaElements.filter(function(video) {
|
||||||
|
return Boolean(
|
||||||
|
video &&
|
||||||
|
video.vsc &&
|
||||||
|
video.isConnected &&
|
||||||
|
video.ownerDocument === docContext
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (candidates.length <= 1) return candidates;
|
||||||
|
|
||||||
|
var controllerMedia = getMediaFromControllerEvent(event, candidates);
|
||||||
|
if (controllerMedia) {
|
||||||
|
tc.lastInteractedMedia = controllerMedia;
|
||||||
|
return [controllerMedia];
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
: null;
|
||||||
|
var closest = getClosestMediaToPointer(candidates, pointer);
|
||||||
|
if (closest) {
|
||||||
|
tc.lastInteractedMedia = closest;
|
||||||
|
return [closest];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
tc.lastInteractedMedia &&
|
||||||
|
candidates.indexOf(tc.lastInteractedMedia) !== -1
|
||||||
|
) {
|
||||||
|
return [tc.lastInteractedMedia];
|
||||||
|
}
|
||||||
|
|
||||||
|
var primary = getPrimaryVideoElement(candidates);
|
||||||
|
return primary ? [primary] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function runAction(action, value, e) {
|
function runAction(action, value, e) {
|
||||||
log("runAction Begin", 5);
|
log("runAction Begin", 5);
|
||||||
var mediaTagsToProcess;
|
|
||||||
const specificVideo = arguments[3] || null;
|
const specificVideo = arguments[3] || null;
|
||||||
var subtitleNudgeToggleValue = null;
|
var subtitleNudgeToggleValue = null;
|
||||||
|
var mediaTagsToProcess = resolveActionMediaTargets(e, specificVideo);
|
||||||
if (specificVideo) {
|
|
||||||
mediaTagsToProcess = [specificVideo];
|
|
||||||
} else if (e && e.target && e.target.getRootNode) {
|
|
||||||
// Event-driven action
|
|
||||||
const docContext = e.target.ownerDocument || document;
|
|
||||||
mediaTagsToProcess = tc.mediaElements.filter(
|
|
||||||
(v) => v.ownerDocument === docContext
|
|
||||||
);
|
|
||||||
const targetController = e.target.getRootNode().host;
|
|
||||||
if (targetController) {
|
|
||||||
// If it's a click on a controller, only use that one video
|
|
||||||
const videoFromController = tc.mediaElements.find(
|
|
||||||
(v) => v.vsc && v.vsc.div === targetController
|
|
||||||
);
|
|
||||||
if (videoFromController) mediaTagsToProcess = [videoFromController];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
mediaTagsToProcess = tc.mediaElements;
|
|
||||||
}
|
|
||||||
if (action === "settings") {
|
if (action === "settings") {
|
||||||
chrome.runtime.sendMessage({ action: "openOptions" });
|
chrome.runtime.sendMessage({ action: "openOptions" });
|
||||||
return;
|
return;
|
||||||
@@ -2844,7 +5035,11 @@ function runAction(action, value, e) {
|
|||||||
showController(controller, 2000, true);
|
showController(controller, 2000, true);
|
||||||
}
|
}
|
||||||
if (v.classList.contains("vsc-cancelled")) return;
|
if (v.classList.contains("vsc-cancelled")) return;
|
||||||
const numValue = parseFloat(value);
|
const numValue = keyBindingUtils.sanitizeActionValue(
|
||||||
|
action,
|
||||||
|
value,
|
||||||
|
getKeyBindings(action, "value")
|
||||||
|
);
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "rewind":
|
case "rewind":
|
||||||
isUserSeek = true;
|
isUserSeek = true;
|
||||||
@@ -2880,8 +5075,12 @@ function runAction(action, value, e) {
|
|||||||
case "fast":
|
case "fast":
|
||||||
var preferredSpeed = numValue;
|
var preferredSpeed = numValue;
|
||||||
// Apply site-specific preferred speed override if available
|
// Apply site-specific preferred speed override if available
|
||||||
if (tc.activeSiteRule && typeof tc.activeSiteRule.preferredSpeed === "number") {
|
if (tc.activeSiteRule && tc.activeSiteRule.preferredSpeed !== undefined) {
|
||||||
preferredSpeed = tc.activeSiteRule.preferredSpeed;
|
preferredSpeed = keyBindingUtils.sanitizeActionValue(
|
||||||
|
"fast",
|
||||||
|
tc.activeSiteRule.preferredSpeed,
|
||||||
|
preferredSpeed
|
||||||
|
);
|
||||||
}
|
}
|
||||||
resetSpeed(v, preferredSpeed, true);
|
resetSpeed(v, preferredSpeed, true);
|
||||||
break;
|
break;
|
||||||
@@ -3073,13 +5272,20 @@ function handleDrag(video, e) {
|
|||||||
const c = video.vsc.div;
|
const c = video.vsc.div;
|
||||||
const sC = convertControllerToManualPosition(video.vsc);
|
const sC = convertControllerToManualPosition(video.vsc);
|
||||||
if (!sC) return;
|
if (!sC) return;
|
||||||
var pE = c.parentElement;
|
var pE =
|
||||||
|
getControllerGeometryMount(video.vsc.controllerHostMount) ||
|
||||||
|
c.parentElement ||
|
||||||
|
(c.parentNode && c.parentNode.host);
|
||||||
|
if (!pE || typeof pE.addEventListener !== "function") return;
|
||||||
|
var nextParent = pE.parentElement;
|
||||||
while (
|
while (
|
||||||
pE.parentNode &&
|
nextParent &&
|
||||||
pE.parentNode.offsetHeight === pE.offsetHeight &&
|
nextParent.offsetHeight === pE.offsetHeight &&
|
||||||
pE.parentNode.offsetWidth === pE.offsetWidth
|
nextParent.offsetWidth === pE.offsetWidth
|
||||||
)
|
) {
|
||||||
pE = pE.parentNode;
|
pE = nextParent;
|
||||||
|
nextParent = pE.parentElement;
|
||||||
|
}
|
||||||
video.classList.add("vcs-dragging");
|
video.classList.add("vcs-dragging");
|
||||||
sC.classList.add("dragging");
|
sC.classList.add("dragging");
|
||||||
const iXY = [e.clientX, e.clientY],
|
const iXY = [e.clientX, e.clientY],
|
||||||
@@ -3136,11 +5342,22 @@ function showController(controller, duration = 2000, forced = false) {
|
|||||||
}, duration);
|
}, duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add global listener to handle fullscreen transitions and adjust controller positions
|
// Keep each controller inside the subtree promoted to the fullscreen top layer,
|
||||||
document.addEventListener("fullscreenchange", () => {
|
// then restore its normal player-local mount when fullscreen exits.
|
||||||
|
function handleFullscreenControllerTransition() {
|
||||||
tc.mediaElements.forEach((video) => {
|
tc.mediaElements.forEach((video) => {
|
||||||
if (video.vsc) {
|
if (video.vsc) {
|
||||||
|
syncControllerFullscreenMount(video.vsc);
|
||||||
applyControllerLocation(video.vsc, video.vsc.controllerLocation);
|
applyControllerLocation(video.vsc, video.vsc.controllerLocation);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[
|
||||||
|
"fullscreenchange",
|
||||||
|
"webkitfullscreenchange",
|
||||||
|
"mozfullscreenchange",
|
||||||
|
"MSFullscreenChange"
|
||||||
|
].forEach(function(eventName) {
|
||||||
|
document.addEventListener(eventName, handleFullscreenControllerTransition);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
(function() {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
if (window.__speederPageShadowBridgeInstalled) return;
|
||||||
|
|
||||||
|
window.__speederPageShadowBridgeInstalled = true;
|
||||||
|
|
||||||
|
function notifyLocationChanged() {
|
||||||
|
try {
|
||||||
|
document.dispatchEvent(
|
||||||
|
new Event("speeder-location-changed", {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (_error) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof Element !== "undefined" &&
|
||||||
|
typeof Element.prototype.attachShadow === "function"
|
||||||
|
) {
|
||||||
|
var originalAttachShadow = Element.prototype.attachShadow;
|
||||||
|
Element.prototype.attachShadow = function() {
|
||||||
|
var shadowRoot = originalAttachShadow.apply(this, arguments);
|
||||||
|
try {
|
||||||
|
this.dispatchEvent(
|
||||||
|
new Event("speeder-shadow-root-attached", {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (_error) {}
|
||||||
|
return shadowRoot;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
["pushState", "replaceState"].forEach(function(method) {
|
||||||
|
if (typeof history === "undefined" || typeof history[method] !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var original = history[method];
|
||||||
|
history[method] = function() {
|
||||||
|
var result = original.apply(this, arguments);
|
||||||
|
notifyLocationChanged();
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -10,6 +10,24 @@
|
|||||||
overflow: visible !important;
|
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-nosource),
|
||||||
:host(.vsc-hidden) {
|
:host(.vsc-hidden) {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
@@ -44,6 +62,7 @@
|
|||||||
after a mouse click kept #controls visible while hover-only rules (e.g. draggable
|
after a mouse click kept #controls visible while hover-only rules (e.g. draggable
|
||||||
margin) turned off when the pointer left the bar. */
|
margin) turned off when the pointer left the bar. */
|
||||||
#controller:hover #controls,
|
#controller:hover #controls,
|
||||||
|
#controller.vsc-controls-hovered #controls,
|
||||||
#controller:focus-within:has(:focus-visible) #controls,
|
#controller:focus-within:has(:focus-visible) #controls,
|
||||||
:host(:hover) #controls {
|
:host(:hover) #controls {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -85,6 +104,10 @@
|
|||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#controller.vsc-controls-hovered > .draggable {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Center presets: midpoint between left- and right-preset inset lines; center bar on that X. */
|
/* Center presets: midpoint between left- and right-preset inset lines; center bar on that X. */
|
||||||
#controller[data-location="top-center"]:not([data-position-mode="manual"]) {
|
#controller[data-location="top-center"]:not([data-position-mode="manual"]) {
|
||||||
transform: translate(-50%, 0) !important;
|
transform: translate(-50%, 0) !important;
|
||||||
@@ -311,10 +334,15 @@ button .vsc-btn-icon svg {
|
|||||||
transform: translateY(0.5px);
|
transform: translateY(0.5px);
|
||||||
}
|
}
|
||||||
|
|
||||||
button:focus {
|
button:focus:not(:focus-visible) {
|
||||||
outline: 0;
|
outline: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button:focus-visible {
|
||||||
|
outline: 2px solid #fff;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
button:hover {
|
button:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "Speeder",
|
"name": "Speeder",
|
||||||
"short_name": "Speeder",
|
"short_name": "Speeder",
|
||||||
"version": "5.3.3.0",
|
"version": "6.0.8.0",
|
||||||
"manifest_version": 2,
|
"manifest_version": 2,
|
||||||
"description": "Speed up, slow down, advance and rewind HTML5 audio/video with shortcuts (New and improved version of \"Video Speed Controller\")",
|
"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",
|
"homepage_url": "https://github.com/SoPat712/speeder",
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"permissions": [
|
"permissions": [
|
||||||
|
"activeTab",
|
||||||
"storage",
|
"storage",
|
||||||
"https://cdn.jsdelivr.net/*"
|
"https://cdn.jsdelivr.net/*"
|
||||||
],
|
],
|
||||||
@@ -50,6 +51,7 @@
|
|||||||
"file:///*"
|
"file:///*"
|
||||||
],
|
],
|
||||||
"match_about_blank": true,
|
"match_about_blank": true,
|
||||||
|
"run_at": "document_start",
|
||||||
"exclude_matches": [
|
"exclude_matches": [
|
||||||
"https://plus.google.com/hangouts/*",
|
"https://plus.google.com/hangouts/*",
|
||||||
"https://hangouts.google.com/*",
|
"https://hangouts.google.com/*",
|
||||||
@@ -61,6 +63,7 @@
|
|||||||
"js": [
|
"js": [
|
||||||
"shared/controller-utils.js",
|
"shared/controller-utils.js",
|
||||||
"shared/key-bindings.js",
|
"shared/key-bindings.js",
|
||||||
|
"shared/settings-core.js",
|
||||||
"shared/site-rules.js",
|
"shared/site-rules.js",
|
||||||
"shared/ui-icons.js",
|
"shared/ui-icons.js",
|
||||||
"content/inject.js"
|
"content/inject.js"
|
||||||
@@ -69,6 +72,7 @@
|
|||||||
],
|
],
|
||||||
"web_accessible_resources": [
|
"web_accessible_resources": [
|
||||||
"content/inject.css",
|
"content/inject.css",
|
||||||
|
"content/shadow-bridge.js",
|
||||||
"content/shadow.css"
|
"content/shadow.css"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,83 @@ function generateBackupFilename() {
|
|||||||
return importExportUtils.generateBackupFilename(new Date());
|
return importExportUtils.generateBackupFilename(new Date());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizedSettingsForExport(rawStorage) {
|
||||||
|
var raw = rawStorage && typeof rawStorage === "object" ? rawStorage : {};
|
||||||
|
var expanded = vscExpandStoredSettings(raw);
|
||||||
|
var normalized = vscBuildStoredSettingsDiff(expanded);
|
||||||
|
|
||||||
|
// Backups should remain readable outside the extension too. Storage stays
|
||||||
|
// sparse, but explicitly include built-in rule titles in the exported v2
|
||||||
|
// patches. Custom rule titles are already part of their normal patches.
|
||||||
|
var titledRulePatches = Array.isArray(normalized.siteRules)
|
||||||
|
? normalized.siteRules.slice()
|
||||||
|
: [];
|
||||||
|
var orderedRulePatches = [];
|
||||||
|
var claimedRulePatches = new Set();
|
||||||
|
vscGetSettingsDefaults().siteRules.forEach(function(defaultRule) {
|
||||||
|
var expandedRule = expanded.siteRules.find(function(rule) {
|
||||||
|
return rule && rule.pattern === defaultRule.pattern;
|
||||||
|
});
|
||||||
|
if (!expandedRule) return;
|
||||||
|
var patch = titledRulePatches.find(function(candidate) {
|
||||||
|
return (
|
||||||
|
candidate &&
|
||||||
|
candidate.pattern === defaultRule.pattern &&
|
||||||
|
!claimedRulePatches.has(candidate)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (!patch && typeof expandedRule.title === "string") {
|
||||||
|
patch = { pattern: defaultRule.pattern };
|
||||||
|
titledRulePatches.push(patch);
|
||||||
|
}
|
||||||
|
if (!patch) return;
|
||||||
|
if (typeof expandedRule.title === "string") {
|
||||||
|
patch.title = expandedRule.title;
|
||||||
|
}
|
||||||
|
claimedRulePatches.add(patch);
|
||||||
|
orderedRulePatches.push(patch);
|
||||||
|
});
|
||||||
|
titledRulePatches.forEach(function(patch) {
|
||||||
|
if (!claimedRulePatches.has(patch)) orderedRulePatches.push(patch);
|
||||||
|
});
|
||||||
|
if (orderedRulePatches.length > 0) {
|
||||||
|
normalized.siteRules = orderedRulePatches;
|
||||||
|
normalized.siteRulesFormat = vscGetSiteRulesDiffFormat();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)) {
|
||||||
|
normalized.lastSpeed = expanded.lastSpeed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
function exportSettings() {
|
function exportSettings() {
|
||||||
chrome.storage.sync.get(null, function (storage) {
|
chrome.storage.sync.get(null, function (storage) {
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
showStatus(
|
||||||
|
"Error: Failed to read settings - " + chrome.runtime.lastError.message,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
chrome.storage.local.get(null, function (localStorage) {
|
chrome.storage.local.get(null, function (localStorage) {
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
showStatus(
|
||||||
|
"Error: Failed to read local extension data - " +
|
||||||
|
chrome.runtime.lastError.message,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const backup = importExportUtils.buildBackupPayload(
|
const backup = importExportUtils.buildBackupPayload(
|
||||||
storage,
|
normalizedSettingsForExport(storage),
|
||||||
importExportUtils.filterLocalSettingsForExport(localStorage),
|
importExportUtils.filterLocalSettingsForExport(localStorage),
|
||||||
new Date()
|
new Date()
|
||||||
);
|
);
|
||||||
@@ -33,6 +105,66 @@ function exportSettings() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function persistImportedSyncSettings(currentRaw, importedRaw, callback) {
|
||||||
|
var expanded = vscExpandStoredSettings(importedRaw || {});
|
||||||
|
var mutation = vscBuildManagedStorageMutation(currentRaw || {}, expanded);
|
||||||
|
|
||||||
|
if (
|
||||||
|
importedRaw &&
|
||||||
|
Object.prototype.hasOwnProperty.call(importedRaw, "lastSpeed")
|
||||||
|
) {
|
||||||
|
if (Number.isFinite(expanded.lastSpeed)) {
|
||||||
|
mutation.set.lastSpeed = expanded.lastSpeed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeStaleSettings() {
|
||||||
|
if (!mutation.remove.length) {
|
||||||
|
callback(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chrome.storage.sync.remove(mutation.remove, function () {
|
||||||
|
callback(chrome.runtime.lastError || null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Object.keys(mutation.set).length) {
|
||||||
|
removeStaleSettings();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the replacement first. A failed write must not leave the user with
|
||||||
|
// their old managed settings already deleted.
|
||||||
|
chrome.storage.sync.set(mutation.set, function () {
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
callback(chrome.runtime.lastError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
removeStaleSettings();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function importLocalSettings(localToImport, callback) {
|
||||||
|
if (localToImport === null) {
|
||||||
|
callback(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var icons = localToImport.customButtonIcons;
|
||||||
|
if (icons && typeof icons === "object" && !Array.isArray(icons)) {
|
||||||
|
chrome.storage.local.set({ customButtonIcons: icons }, function () {
|
||||||
|
callback(chrome.runtime.lastError || null);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backups only own customButtonIcons. Keep Lucide caches, remembered speed
|
||||||
|
// history, and any future local runtime data private to the current install.
|
||||||
|
chrome.storage.local.remove("customButtonIcons", function () {
|
||||||
|
callback(chrome.runtime.lastError || null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function importSettings() {
|
function importSettings() {
|
||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
input.type = "file";
|
input.type = "file";
|
||||||
@@ -55,65 +187,54 @@ function importSettings() {
|
|||||||
var settingsToImport = parsedBackup.settings;
|
var settingsToImport = parsedBackup.settings;
|
||||||
var localToImport = parsedBackup.localSettings;
|
var localToImport = parsedBackup.localSettings;
|
||||||
|
|
||||||
function importLocalSettings(callback) {
|
chrome.storage.sync.get(null, function (currentRaw) {
|
||||||
if (parsedBackup.isWrappedBackup !== true) {
|
if (chrome.runtime.lastError) {
|
||||||
callback();
|
showStatus(
|
||||||
|
"Error: Failed to read current settings - " +
|
||||||
|
chrome.runtime.lastError.message,
|
||||||
|
true
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
chrome.storage.local.clear(function () {
|
persistImportedSyncSettings(
|
||||||
if (chrome.runtime.lastError) {
|
currentRaw || {},
|
||||||
showStatus(
|
settingsToImport,
|
||||||
"Error: Failed to clear local extension data - " +
|
function (syncError) {
|
||||||
chrome.runtime.lastError.message,
|
if (syncError) {
|
||||||
true
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (localToImport && Object.keys(localToImport).length > 0) {
|
|
||||||
chrome.storage.local.set(localToImport, function () {
|
|
||||||
if (chrome.runtime.lastError) {
|
|
||||||
showStatus(
|
|
||||||
"Error: Failed to save local extension data - " +
|
|
||||||
chrome.runtime.lastError.message,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
callback();
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
callback();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function afterLocalImport() {
|
|
||||||
chrome.storage.sync.clear(function () {
|
|
||||||
chrome.storage.sync.set(settingsToImport, function () {
|
|
||||||
if (chrome.runtime.lastError) {
|
|
||||||
showStatus(
|
showStatus(
|
||||||
"Error: Failed to save imported settings - " +
|
"Error: Failed to save imported settings - " +
|
||||||
chrome.runtime.lastError.message,
|
syncError.message,
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showStatus("Settings imported successfully. Reloading...");
|
|
||||||
setTimeout(function () {
|
|
||||||
if (typeof restore_options === "function") {
|
|
||||||
restore_options();
|
|
||||||
} else {
|
|
||||||
location.reload();
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
importLocalSettings(afterLocalImport);
|
var scopedLocalSettings =
|
||||||
|
parsedBackup.isWrappedBackup === true ? localToImport : null;
|
||||||
|
importLocalSettings(scopedLocalSettings, function (localError) {
|
||||||
|
if (localError) {
|
||||||
|
showStatus(
|
||||||
|
"Settings imported, but custom icons could not be updated - " +
|
||||||
|
localError.message +
|
||||||
|
". Reloading...",
|
||||||
|
true
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showStatus("Settings imported successfully. Reloading...");
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(function () {
|
||||||
|
if (typeof restore_options === "function") {
|
||||||
|
restore_options();
|
||||||
|
} else {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showStatus("Error: Failed to parse backup file - " + err.message, true);
|
showStatus("Error: Failed to parse backup file - " + err.message, true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
--text: #17191c;
|
--text: #17191c;
|
||||||
--muted: #626b76;
|
--muted: #626b76;
|
||||||
--accent: #111827;
|
--accent: #111827;
|
||||||
|
--focus-ring: #2563eb;
|
||||||
--switch-track-off: #c1cad6;
|
--switch-track-off: #c1cad6;
|
||||||
--switch-track-off-border: #aeb8c5;
|
--switch-track-off-border: #aeb8c5;
|
||||||
--switch-track-on: #111827;
|
--switch-track-on: #111827;
|
||||||
@@ -177,9 +178,14 @@ a:visited {
|
|||||||
text-underline-offset: 0.14em;
|
text-underline-offset: 0.14em;
|
||||||
}
|
}
|
||||||
|
|
||||||
a:hover,
|
a:hover {
|
||||||
a:focus {
|
color: var(--text);
|
||||||
color: #000;
|
text-decoration-thickness: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:focus-visible {
|
||||||
|
outline: 2px solid var(--focus-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
@@ -225,7 +231,7 @@ input[type="checkbox"]:focus-visible,
|
|||||||
input[type="text"]:focus,
|
input[type="text"]:focus,
|
||||||
select:focus,
|
select:focus,
|
||||||
textarea:focus {
|
textarea:focus {
|
||||||
outline: 2px solid rgba(17, 24, 39, 0.14);
|
outline: 2px solid var(--focus-ring);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,7 +328,7 @@ label em {
|
|||||||
|
|
||||||
.shortcut-row {
|
.shortcut-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 120px 120px;
|
grid-template-columns: minmax(0, 1fr) 120px 120px 38px;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
@@ -334,10 +340,6 @@ label em {
|
|||||||
border-top: 0;
|
border-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shortcut-row.customs {
|
|
||||||
grid-template-columns: minmax(0, 1fr) 120px 120px 38px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shortcut-label {
|
.shortcut-label {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -628,6 +630,9 @@ label em {
|
|||||||
cursor: grab;
|
cursor: grab;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
transition: box-shadow 150ms ease, opacity 150ms ease;
|
transition: box-shadow 150ms ease, opacity 150ms ease;
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cb-block:hover {
|
.cb-block:hover {
|
||||||
@@ -905,8 +910,19 @@ button.lucide-result-tile.lucide-picked {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.site-rule-identity {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(140px, 0.45fr) minmax(220px, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-title,
|
||||||
.site-pattern {
|
.site-pattern {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-pattern {
|
||||||
font-family: "SFMono-Regular", Menlo, Consolas, monospace;
|
font-family: "SFMono-Regular", Menlo, Consolas, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1050,6 +1066,20 @@ button.lucide-result-tile.lucide-picked {
|
|||||||
font-weight: 500;
|
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 {
|
#status:empty {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -1173,6 +1203,10 @@ button.lucide-result-tile.lucide-picked {
|
|||||||
grid-template-columns: 36px minmax(0, 1fr);
|
grid-template-columns: 36px minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.site-rule-identity {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.remove-site-rule {
|
.remove-site-rule {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
@@ -1198,6 +1232,10 @@ button.lucide-result-tile.lucide-picked {
|
|||||||
.site-rule-header {
|
.site-rule-header {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.site-rule-identity {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
@@ -1210,6 +1248,7 @@ button.lucide-result-tile.lucide-picked {
|
|||||||
--text: #f2f4f6;
|
--text: #f2f4f6;
|
||||||
--muted: #a0a8b2;
|
--muted: #a0a8b2;
|
||||||
--accent: #f2f4f6;
|
--accent: #f2f4f6;
|
||||||
|
--focus-ring: #93c5fd;
|
||||||
--switch-track-off: #374151;
|
--switch-track-off: #374151;
|
||||||
--switch-track-off-border: #4b5563;
|
--switch-track-off-border: #4b5563;
|
||||||
--switch-track-on: #aab7c6;
|
--switch-track-on: #aab7c6;
|
||||||
|
|||||||
+120
-37
@@ -7,6 +7,7 @@
|
|||||||
<link rel="stylesheet" href="options.css" />
|
<link rel="stylesheet" href="options.css" />
|
||||||
<script src="../shared/controller-utils.js"></script>
|
<script src="../shared/controller-utils.js"></script>
|
||||||
<script src="../shared/key-bindings.js"></script>
|
<script src="../shared/key-bindings.js"></script>
|
||||||
|
<script src="../shared/settings-core.js"></script>
|
||||||
<script src="../shared/popup-controls.js"></script>
|
<script src="../shared/popup-controls.js"></script>
|
||||||
<script src="../shared/ui-icons.js"></script>
|
<script src="../shared/ui-icons.js"></script>
|
||||||
<script src="lucide-client.js"></script>
|
<script src="lucide-client.js"></script>
|
||||||
@@ -249,7 +250,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<select id="addShortcutSelector">
|
<select id="addShortcutSelector" aria-label="Add another shortcut">
|
||||||
<option value="">Add shortcut…</option>
|
<option value="">Add shortcut…</option>
|
||||||
</select>
|
</select>
|
||||||
</section>
|
</section>
|
||||||
@@ -281,6 +282,17 @@
|
|||||||
</label>
|
</label>
|
||||||
<input id="audioBoolean" type="checkbox" />
|
<input id="audioBoolean" type="checkbox" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row row-checkbox">
|
||||||
|
<label for="showAmbientLoopControls"
|
||||||
|
>Show controls on ambient/slideshow videos<br />
|
||||||
|
<em
|
||||||
|
>Also shows Speeder on muted autoplay loops used as animated
|
||||||
|
cards or slideshow artwork. Genuine video players are always
|
||||||
|
controlled regardless of this setting.</em
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<input id="showAmbientLoopControls" type="checkbox" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="defaults-divider"></div>
|
<div class="defaults-divider"></div>
|
||||||
<h4 class="defaults-sub-heading">Playback</h4>
|
<h4 class="defaults-sub-heading">Playback</h4>
|
||||||
@@ -289,9 +301,9 @@
|
|||||||
<label for="rememberSpeed"
|
<label for="rememberSpeed"
|
||||||
>Remember playback speed<br />
|
>Remember playback speed<br />
|
||||||
<em
|
<em
|
||||||
>Stores speed per source so revisiting the same media can restore
|
>Stores speeds you set with Speeder per source so revisiting the
|
||||||
it. Separate from “Force last saved speed,” which
|
same media can restore them. Separate from “Force last
|
||||||
fights players that reset rate.</em
|
saved speed,” which fights players that reset rate.</em
|
||||||
>
|
>
|
||||||
</label>
|
</label>
|
||||||
<input id="rememberSpeed" type="checkbox" />
|
<input id="rememberSpeed" type="checkbox" />
|
||||||
@@ -306,6 +318,20 @@
|
|||||||
</label>
|
</label>
|
||||||
<input id="forceLastSavedSpeed" type="checkbox" />
|
<input id="forceLastSavedSpeed" type="checkbox" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label for="shortcutTargetMode"
|
||||||
|
>Keyboard shortcut target<br />
|
||||||
|
<em
|
||||||
|
>Closest changes the video nearest the pointer (falling back to
|
||||||
|
the most visible active player). Change all keeps the legacy
|
||||||
|
behavior on pages with multiple players.</em
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<select id="shortcutTargetMode">
|
||||||
|
<option value="closest">Closest active video</option>
|
||||||
|
<option value="all">All videos on the page</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="defaults-divider"></div>
|
<div class="defaults-divider"></div>
|
||||||
<h4 class="defaults-sub-heading">Controller</h4>
|
<h4 class="defaults-sub-heading">Controller</h4>
|
||||||
@@ -412,15 +438,15 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<label for="subtitleNudgeInterval"
|
<label for="subtitleNudgeInterval"
|
||||||
>Nudge interval (milliseconds)<br /><em
|
>Nudge interval (milliseconds)<br /><em
|
||||||
>How often to nudge: 10–1000. Smaller values are more
|
>How often to nudge: 250–1000. Smaller values are more
|
||||||
frequent. Default: 50.</em
|
frequent. Default: 250.</em
|
||||||
>
|
>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="subtitleNudgeInterval"
|
id="subtitleNudgeInterval"
|
||||||
type="text"
|
type="text"
|
||||||
value=""
|
value=""
|
||||||
placeholder="50"
|
placeholder="250"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -442,8 +468,8 @@
|
|||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<h3>Hover control bar</h3>
|
<h3>Hover control bar</h3>
|
||||||
<p class="section-intro">
|
<p class="section-intro">
|
||||||
Drag blocks to reorder. Move between Active and Available to
|
Drag blocks or use arrow keys to reorder. Press a block to
|
||||||
show or hide buttons.
|
move it between Active and Available.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="cb-editor">
|
<div class="cb-editor">
|
||||||
@@ -565,7 +591,7 @@
|
|||||||
<div
|
<div
|
||||||
id="lucideIconResults"
|
id="lucideIconResults"
|
||||||
class="lucide-icon-results"
|
class="lucide-icon-results"
|
||||||
role="listbox"
|
role="group"
|
||||||
aria-label="Matching Lucide icons"
|
aria-label="Matching Lucide icons"
|
||||||
></div>
|
></div>
|
||||||
<p id="lucideIconStatus" class="lucide-icon-status" aria-live="polite"></p>
|
<p id="lucideIconStatus" class="lucide-icon-status" aria-live="polite"></p>
|
||||||
@@ -607,7 +633,9 @@
|
|||||||
>
|
>
|
||||||
patterns like <code>/(.+)youtube\.com(\/*)$/gi</code>. Turn on a
|
patterns like <code>/(.+)youtube\.com(\/*)$/gi</code>. Turn on a
|
||||||
row only when you want that group to override the general defaults
|
row only when you want that group to override the general defaults
|
||||||
above.
|
above. Regex and plain-path rules test both the literal URL and a
|
||||||
|
safely decoded path, query, and hash, so readable text can match
|
||||||
|
<code>%xx</code>-encoded URLs too.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="siteRulesContainer"></div>
|
<div id="siteRulesContainer"></div>
|
||||||
@@ -626,11 +654,20 @@
|
|||||||
>
|
>
|
||||||
<span class="site-rule-toggle-icon" aria-hidden="true">…</span>
|
<span class="site-rule-toggle-icon" aria-hidden="true">…</span>
|
||||||
</button>
|
</button>
|
||||||
<input
|
<div class="site-rule-identity">
|
||||||
type="text"
|
<input
|
||||||
class="site-pattern"
|
type="text"
|
||||||
placeholder="e.g., youtube.com or /regex/gi"
|
class="site-title"
|
||||||
/>
|
placeholder="Rule title (optional)"
|
||||||
|
aria-label="Site rule title"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="site-pattern"
|
||||||
|
placeholder="e.g., youtube.com or /regex/gi"
|
||||||
|
aria-label="Site rule URL pattern"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<button type="button" class="remove-site-rule">Remove</button>
|
<button type="button" class="remove-site-rule">Remove</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="site-rule-body">
|
<div class="site-rule-body">
|
||||||
@@ -697,6 +734,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="site-rule-override-section">
|
||||||
|
<label class="site-override-lead">
|
||||||
|
<span
|
||||||
|
>Override shortcut target for this site<br /><em
|
||||||
|
>Choose whether keyboard and popup control-bar actions
|
||||||
|
affect the closest active video or every video.</em
|
||||||
|
></span
|
||||||
|
>
|
||||||
|
<input type="checkbox" class="override-shortcut-target" />
|
||||||
|
</label>
|
||||||
|
<div class="site-shortcut-target-container">
|
||||||
|
<div class="site-rule-option site-rule-option-field">
|
||||||
|
<label>Shortcut target:</label>
|
||||||
|
<select class="site-shortcutTargetMode">
|
||||||
|
<option value="closest">Closest active video</option>
|
||||||
|
<option value="all">All videos on the page</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="site-rule-override-section">
|
<div class="site-rule-override-section">
|
||||||
<label class="site-override-lead">
|
<label class="site-override-lead">
|
||||||
<span
|
<span
|
||||||
@@ -757,8 +814,8 @@
|
|||||||
<label class="site-override-lead">
|
<label class="site-override-lead">
|
||||||
<span
|
<span
|
||||||
>Override playback for this site<br /><em
|
>Override playback for this site<br /><em
|
||||||
>When on, remember speed / force / audio below replace
|
>When on, remember speed / force / media eligibility
|
||||||
general defaults for matching URLs.</em
|
below replace general defaults for matching URLs.</em
|
||||||
></span
|
></span
|
||||||
>
|
>
|
||||||
<input type="checkbox" class="override-playback" />
|
<input type="checkbox" class="override-playback" />
|
||||||
@@ -767,10 +824,10 @@
|
|||||||
<div class="site-rule-option site-rule-option-checkbox">
|
<div class="site-rule-option site-rule-option-checkbox">
|
||||||
<label
|
<label
|
||||||
>Remember playback speed:<br /><em
|
>Remember playback speed:<br /><em
|
||||||
>Stores speed per source so revisiting the same media
|
>Stores speeds you set with Speeder per source so
|
||||||
can restore it. Separate from “Force last saved
|
revisiting the same media can restore them. Separate
|
||||||
speed,” which fights players that reset
|
from “Force last saved speed,” which fights
|
||||||
rate.</em
|
players that reset rate.</em
|
||||||
></label
|
></label
|
||||||
>
|
>
|
||||||
<input type="checkbox" class="site-rememberSpeed" />
|
<input type="checkbox" class="site-rememberSpeed" />
|
||||||
@@ -784,6 +841,21 @@
|
|||||||
>
|
>
|
||||||
<input type="checkbox" class="site-forceLastSavedSpeed" />
|
<input type="checkbox" class="site-forceLastSavedSpeed" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="site-rule-option site-rule-option-field">
|
||||||
|
<label
|
||||||
|
>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
|
||||||
|
></label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputmode="decimal"
|
||||||
|
class="site-preferredSpeed"
|
||||||
|
placeholder="1.80"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div class="site-rule-option site-rule-option-checkbox">
|
<div class="site-rule-option site-rule-option-checkbox">
|
||||||
<label
|
<label
|
||||||
>Work on audio:<br /><em
|
>Work on audio:<br /><em
|
||||||
@@ -793,6 +865,16 @@
|
|||||||
>
|
>
|
||||||
<input type="checkbox" class="site-audioBoolean" />
|
<input type="checkbox" class="site-audioBoolean" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="site-rule-option site-rule-option-checkbox">
|
||||||
|
<label
|
||||||
|
>Show controls on ambient/slideshow videos:<br /><em
|
||||||
|
>Also includes muted autoplay loops used as animated
|
||||||
|
cards or slideshow artwork. Genuine video players are
|
||||||
|
always included.</em
|
||||||
|
></label
|
||||||
|
>
|
||||||
|
<input type="checkbox" class="site-showAmbientLoopControls" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="site-rule-override-section">
|
<div class="site-rule-override-section">
|
||||||
@@ -848,12 +930,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="site-rule-option site-rule-option-field">
|
<div class="site-rule-option site-rule-option-field">
|
||||||
<label
|
<label
|
||||||
>Nudge interval (10–1000ms):<br /><em
|
>Nudge interval (250–1000ms):<br /><em
|
||||||
>How often to nudge: 10–1000. Smaller values are
|
>How often to nudge: 250–1000. Smaller values are
|
||||||
more frequent. Default: 50.</em
|
more frequent. Default: 250.</em
|
||||||
></label
|
></label
|
||||||
>
|
>
|
||||||
<input type="text" class="site-subtitleNudgeInterval" placeholder="50" />
|
<input type="text" class="site-subtitleNudgeInterval" placeholder="250" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -861,8 +943,8 @@
|
|||||||
<label class="site-override-lead">
|
<label class="site-override-lead">
|
||||||
<span
|
<span
|
||||||
>Override in-player control bar for this site<br /><em
|
>Override in-player control bar for this site<br /><em
|
||||||
>Same idea as Hover control bar: drag blocks between
|
>Drag blocks or use the keyboard to arrange Active and
|
||||||
Active and Available for matching URLs only.</em
|
Available buttons for matching URLs only.</em
|
||||||
></span
|
></span
|
||||||
>
|
>
|
||||||
<input type="checkbox" class="override-controlbar" />
|
<input type="checkbox" class="override-controlbar" />
|
||||||
@@ -916,8 +998,8 @@
|
|||||||
<label class="site-override-lead">
|
<label class="site-override-lead">
|
||||||
<span
|
<span
|
||||||
>Override shortcuts for this site<br /><em
|
>Override shortcuts for this site<br /><em
|
||||||
>Add shortcuts from the menu; none by default. Leave off
|
>A site shortcut replaces global bindings for the same
|
||||||
to use global Shortcuts.</em
|
action; other global shortcuts remain active.</em
|
||||||
></span
|
></span
|
||||||
>
|
>
|
||||||
<input type="checkbox" class="override-shortcuts" />
|
<input type="checkbox" class="override-shortcuts" />
|
||||||
@@ -940,13 +1022,14 @@
|
|||||||
<section class="settings-card action-card">
|
<section class="settings-card action-card">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<h3>Actions</h3>
|
<h3>Actions</h3>
|
||||||
<p class="section-intro">Save, restore, export, or import settings.</p>
|
<p class="section-intro">Changes save automatically.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="action-row">
|
<div class="action-row">
|
||||||
<button id="save">Save Changes</button>
|
<button id="save">Save Now</button>
|
||||||
<button id="restore">Restore Defaults</button>
|
<button id="restore">Restore Defaults</button>
|
||||||
<button id="exportSettings">Export Settings</button>
|
<button id="exportSettings">Export Settings</button>
|
||||||
<button id="importSettings">Import Settings</button>
|
<button id="importSettings">Import Settings</button>
|
||||||
|
<button id="copyDiagnostics">Copy Diagnostics</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="status" role="status" aria-live="polite"></div>
|
<div id="status" role="status" aria-live="polite"></div>
|
||||||
@@ -955,11 +1038,11 @@
|
|||||||
<section id="faq" class="settings-card info-card">
|
<section id="faq" class="settings-card info-card">
|
||||||
<h4>Extension controls not appearing?</h4>
|
<h4>Extension controls not appearing?</h4>
|
||||||
<p>
|
<p>
|
||||||
This extension only works with HTML5 audio and video. If the
|
Speeder works with HTML5 video and, when enabled above, HTML5 audio.
|
||||||
controls never appear, you may be looking at Flash content instead.
|
Check that the current site is enabled, then use the popup’s
|
||||||
Right-click the player to check: if the menu mentions Flash, that
|
“Rescan page for videos” action after a player loads
|
||||||
is the issue. Most sites will fall back to HTML5 when Flash is not
|
dynamically. Browser-internal pages and players that do not expose
|
||||||
available, so disabling Flash in the browser can help.
|
HTML5 media to extensions cannot be controlled.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
+561
-177
@@ -162,58 +162,96 @@ function createDefaultBinding(action, code, value) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
var tcDefaults = {
|
var tcDefaults = vscGetSettingsDefaults();
|
||||||
speed: 1.0,
|
var optionsSyncSettingsLoaded = false;
|
||||||
lastSpeed: 1.0,
|
var autoSaveTimer = null;
|
||||||
rememberSpeed: false,
|
var saveStatusTimer = null;
|
||||||
audioBoolean: false,
|
|
||||||
startHidden: false,
|
function scheduleAutoSave() {
|
||||||
hideWithYouTubeControls: false,
|
if (!optionsSyncSettingsLoaded) return;
|
||||||
hideWithControls: false,
|
clearTimeout(autoSaveTimer);
|
||||||
hideWithControlsTimer: 2.0,
|
var sourceSaveButton = document.getElementById("save");
|
||||||
controllerLocation: "top-left",
|
var scheduledTimer = setTimeout(function () {
|
||||||
forceLastSavedSpeed: false,
|
if (
|
||||||
enabled: true,
|
autoSaveTimer !== scheduledTimer ||
|
||||||
controllerOpacity: 0.3,
|
!sourceSaveButton ||
|
||||||
controllerMarginTop: 0,
|
!sourceSaveButton.isConnected
|
||||||
controllerMarginRight: 0,
|
) {
|
||||||
controllerMarginBottom: 65,
|
return;
|
||||||
controllerMarginLeft: 0,
|
|
||||||
keyBindings: [
|
|
||||||
createDefaultBinding("display", "KeyV", 0),
|
|
||||||
createDefaultBinding("move", "KeyP", 0),
|
|
||||||
createDefaultBinding("slower", "KeyS", 0.1),
|
|
||||||
createDefaultBinding("faster", "KeyD", 0.1),
|
|
||||||
createDefaultBinding("rewind", "KeyZ", 10),
|
|
||||||
createDefaultBinding("advance", "KeyX", 10),
|
|
||||||
createDefaultBinding("reset", "KeyR", 1),
|
|
||||||
createDefaultBinding("fast", "KeyG", 1.8),
|
|
||||||
createDefaultBinding("toggleSubtitleNudge", "KeyN", 0)
|
|
||||||
],
|
|
||||||
siteRules: [
|
|
||||||
{
|
|
||||||
pattern: "/^https:\\/\\/(www\\.)?youtube\\.com\\/(?!shorts\\/).*/",
|
|
||||||
enabled: true,
|
|
||||||
enableSubtitleNudge: true,
|
|
||||||
subtitleNudgeInterval: 50
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pattern: "/^https:\\/\\/(www\\.)?youtube\\.com\\/shorts\\/.*/",
|
|
||||||
enabled: true,
|
|
||||||
rememberSpeed: true,
|
|
||||||
controllerMarginTop: 60,
|
|
||||||
controllerMarginBottom: 85
|
|
||||||
}
|
}
|
||||||
],
|
autoSaveTimer = null;
|
||||||
controllerButtons: ["rewind", "slower", "faster", "advance", "display"],
|
save_options(true);
|
||||||
showPopupControlBar: true,
|
}, 300);
|
||||||
popupMatchHoverControls: true,
|
autoSaveTimer = scheduledTimer;
|
||||||
popupControllerButtons: ["rewind", "slower", "faster", "advance", "display"],
|
}
|
||||||
enableSubtitleNudge: false,
|
|
||||||
subtitleNudgeEnabledByDefault: true,
|
function setOptionsSyncSettingsLoaded(loaded) {
|
||||||
subtitleNudgeInterval: 50,
|
optionsSyncSettingsLoaded = loaded === true;
|
||||||
subtitleNudgeAmount: 0.001
|
if (!optionsSyncSettingsLoaded) {
|
||||||
};
|
clearTimeout(autoSaveTimer);
|
||||||
|
autoSaveTimer = null;
|
||||||
|
}
|
||||||
|
var saveButton = document.getElementById("save");
|
||||||
|
if (saveButton) {
|
||||||
|
saveButton.disabled = !optionsSyncSettingsLoaded;
|
||||||
|
saveButton.setAttribute(
|
||||||
|
"aria-busy",
|
||||||
|
optionsSyncSettingsLoaded ? "false" : "true"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistManagedSyncSettings(settings, callback, existingStorage) {
|
||||||
|
function persistFromRaw(rawStorage) {
|
||||||
|
// Preserve canonical values that this version of the Options UI does not
|
||||||
|
// expose (for example legacy left/right placement margins). A caller can
|
||||||
|
// still reset every managed value by passing the complete defaults object.
|
||||||
|
var completeSettings = vscDeepMergeDefaults(
|
||||||
|
vscExpandStoredSettings(rawStorage || {}),
|
||||||
|
settings || {}
|
||||||
|
);
|
||||||
|
var mutation = vscBuildManagedStorageMutation(
|
||||||
|
rawStorage,
|
||||||
|
completeSettings
|
||||||
|
);
|
||||||
|
|
||||||
|
function removeStaleKeys() {
|
||||||
|
if (!mutation.remove.length) {
|
||||||
|
callback(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chrome.storage.sync.remove(mutation.remove, function () {
|
||||||
|
callback(chrome.runtime.lastError || null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(mutation.set).length === 0) {
|
||||||
|
removeStaleKeys();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
chrome.storage.sync.set(mutation.set, function () {
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
callback(chrome.runtime.lastError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
removeStaleKeys();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingStorage && typeof existingStorage === "object") {
|
||||||
|
persistFromRaw(existingStorage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
chrome.storage.sync.get(null, function (rawStorage) {
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
callback(chrome.runtime.lastError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
persistFromRaw(rawStorage || {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const actionLabels = {
|
const actionLabels = {
|
||||||
display: "Show/hide controller",
|
display: "Show/hide controller",
|
||||||
@@ -261,14 +299,11 @@ function getDefaultShortcutValue(action) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveShortcutValue(action, value) {
|
function resolveShortcutValue(action, value) {
|
||||||
if (value === undefined || value === null) {
|
return keyBindingUtils.sanitizeActionValue(
|
||||||
return getDefaultShortcutValue(action);
|
action,
|
||||||
}
|
value,
|
||||||
var numericValue = Number(value);
|
getDefaultShortcutValue(action)
|
||||||
if (Number.isFinite(numericValue)) {
|
);
|
||||||
return numericValue;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const customActionsNoValues = [
|
const customActionsNoValues = [
|
||||||
@@ -291,33 +326,15 @@ function refreshAddShortcutSelector() {
|
|||||||
selector.remove(1);
|
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) => {
|
Object.keys(actionLabels).forEach((action) => {
|
||||||
if (!usedActions.has(action)) {
|
const option = document.createElement("option");
|
||||||
const option = document.createElement("option");
|
option.value = action;
|
||||||
option.value = action;
|
option.text = actionLabels[action];
|
||||||
option.text = actionLabels[action];
|
selector.appendChild(option);
|
||||||
selector.appendChild(option);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// If no available actions, hide or disable the selector
|
selector.disabled = false;
|
||||||
if (selector.options.length === 1) {
|
selector.options[0].text = "Add shortcut\u2026";
|
||||||
selector.disabled = true;
|
|
||||||
selector.options[0].text = "All shortcuts added";
|
|
||||||
} else {
|
|
||||||
selector.disabled = false;
|
|
||||||
selector.options[0].text = "Add shortcut\u2026";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshSiteRuleAddShortcutSelector(ruleEl) {
|
function refreshSiteRuleAddShortcutSelector(ruleEl) {
|
||||||
@@ -329,32 +346,19 @@ function refreshSiteRuleAddShortcutSelector(ruleEl) {
|
|||||||
selector.remove(1);
|
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) {
|
Object.keys(actionLabels).forEach(function (action) {
|
||||||
if (!usedActions.has(action)) {
|
var option = document.createElement("option");
|
||||||
var option = document.createElement("option");
|
option.value = action;
|
||||||
option.value = action;
|
option.textContent = actionLabels[action];
|
||||||
option.textContent = actionLabels[action];
|
selector.appendChild(option);
|
||||||
selector.appendChild(option);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
var overrideShortcutsOn =
|
var overrideShortcutsOn =
|
||||||
ruleEl.querySelector(".override-shortcuts") &&
|
ruleEl.querySelector(".override-shortcuts") &&
|
||||||
ruleEl.querySelector(".override-shortcuts").checked;
|
ruleEl.querySelector(".override-shortcuts").checked;
|
||||||
|
|
||||||
if (selector.options.length === 1) {
|
selector.disabled = !overrideShortcutsOn;
|
||||||
selector.disabled = true;
|
selector.options[0].text = "Add shortcut\u2026";
|
||||||
selector.options[0].text = "All shortcuts added";
|
|
||||||
} else {
|
|
||||||
selector.disabled = !overrideShortcutsOn;
|
|
||||||
selector.options[0].text = "Add shortcut\u2026";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getGlobalBindingSnapshotForSiteShortcut(action) {
|
function getGlobalBindingSnapshotForSiteShortcut(action) {
|
||||||
@@ -406,6 +410,19 @@ function parseFiniteNumberOrFallback(value, fallback) {
|
|||||||
return Number.isFinite(numericValue) ? numericValue : fallback;
|
return Number.isFinite(numericValue) ? numericValue : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readOptionalPreferredSpeedInput(input) {
|
||||||
|
var rawValue = input && typeof input.value === "string"
|
||||||
|
? input.value.trim()
|
||||||
|
: "";
|
||||||
|
if (!rawValue) return undefined;
|
||||||
|
var parsed = Number(rawValue);
|
||||||
|
if (!Number.isFinite(parsed)) return undefined;
|
||||||
|
return Math.min(
|
||||||
|
keyBindingUtils.MAX_SPEED,
|
||||||
|
Math.max(keyBindingUtils.MIN_SPEED, parsed)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function updateSiteRuleToggleIcon(toggleButton, action) {
|
function updateSiteRuleToggleIcon(toggleButton, action) {
|
||||||
if (!toggleButton) return;
|
if (!toggleButton) return;
|
||||||
var iconEl = toggleButton.querySelector(".site-rule-toggle-icon");
|
var iconEl = toggleButton.querySelector(".site-rule-toggle-icon");
|
||||||
@@ -539,6 +556,7 @@ function normalizeStoredBinding(binding, fallbackCode) {
|
|||||||
|
|
||||||
var normalized = {
|
var normalized = {
|
||||||
code: normalizedCode,
|
code: normalizedCode,
|
||||||
|
shiftKey: binding.shiftKey === true,
|
||||||
disabled: false
|
disabled: false
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -557,7 +575,7 @@ function formatBindingCode(code) {
|
|||||||
function getBindingLabel(binding) {
|
function getBindingLabel(binding) {
|
||||||
if (!binding) return "";
|
if (!binding) return "";
|
||||||
if (binding.disabled) return "";
|
if (binding.disabled) return "";
|
||||||
return formatBindingCode(binding.code);
|
return (binding.shiftKey ? "Shift+" : "") + formatBindingCode(binding.code);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setShortcutInputBinding(input, binding) {
|
function setShortcutInputBinding(input, binding) {
|
||||||
@@ -570,6 +588,7 @@ function captureBindingFromEvent(event) {
|
|||||||
if (typeof event.code !== "string" || event.code.length === 0) return null;
|
if (typeof event.code !== "string" || event.code.length === 0) return null;
|
||||||
return {
|
return {
|
||||||
code: event.code,
|
code: event.code,
|
||||||
|
shiftKey: event.shiftKey === true,
|
||||||
disabled: false
|
disabled: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -579,6 +598,7 @@ function recordKeyPress(event) {
|
|||||||
|
|
||||||
if (event.key === "Backspace") {
|
if (event.key === "Backspace") {
|
||||||
setShortcutInputBinding(event.target, null);
|
setShortcutInputBinding(event.target, null);
|
||||||
|
scheduleAutoSave();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
return;
|
return;
|
||||||
@@ -586,6 +606,7 @@ function recordKeyPress(event) {
|
|||||||
|
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
setShortcutInputBinding(event.target, createDisabledBinding());
|
setShortcutInputBinding(event.target, createDisabledBinding());
|
||||||
|
scheduleAutoSave();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
return;
|
return;
|
||||||
@@ -595,6 +616,7 @@ function recordKeyPress(event) {
|
|||||||
if (!binding) return;
|
if (!binding) return;
|
||||||
|
|
||||||
setShortcutInputBinding(event.target, binding);
|
setShortcutInputBinding(event.target, binding);
|
||||||
|
scheduleAutoSave();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
}
|
}
|
||||||
@@ -650,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) {
|
function add_shortcut(action, value) {
|
||||||
if (!action) return;
|
if (!action) return;
|
||||||
|
|
||||||
@@ -689,6 +725,7 @@ function add_shortcut(action, value) {
|
|||||||
div.appendChild(keyInput);
|
div.appendChild(keyInput);
|
||||||
div.appendChild(valueInput);
|
div.appendChild(valueInput);
|
||||||
div.appendChild(removeButton);
|
div.appendChild(removeButton);
|
||||||
|
labelShortcutRow(div);
|
||||||
|
|
||||||
var customsElement = document.querySelector(".shortcuts-grid");
|
var customsElement = document.querySelector(".shortcuts-grid");
|
||||||
customsElement.appendChild(div);
|
customsElement.appendChild(div);
|
||||||
@@ -726,13 +763,32 @@ function createKeyBindings(item) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var bindingValue = 0;
|
||||||
|
if (!customActionsNoValues.includes(action)) {
|
||||||
|
var valueError = keyBindingUtils.getActionValueError(
|
||||||
|
action,
|
||||||
|
valueInput.value
|
||||||
|
);
|
||||||
|
if (valueError) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
message:
|
||||||
|
"Error: Value for " +
|
||||||
|
(actionLabels[action] || action) +
|
||||||
|
" " +
|
||||||
|
valueError +
|
||||||
|
". Unable to save"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
bindingValue = Number(valueInput.value);
|
||||||
|
}
|
||||||
|
|
||||||
keyBindings.push({
|
keyBindings.push({
|
||||||
action: action,
|
action: action,
|
||||||
code: binding.code,
|
code: binding.code,
|
||||||
|
shiftKey: binding.shiftKey === true,
|
||||||
disabled: binding.disabled === true,
|
disabled: binding.disabled === true,
|
||||||
value: customActionsNoValues.includes(action)
|
value: bindingValue,
|
||||||
? 0
|
|
||||||
: Number(valueInput.value),
|
|
||||||
force: false,
|
force: false,
|
||||||
predefined: predefined
|
predefined: predefined
|
||||||
});
|
});
|
||||||
@@ -752,9 +808,8 @@ function validate() {
|
|||||||
if (pattern.startsWith("/")) {
|
if (pattern.startsWith("/")) {
|
||||||
try {
|
try {
|
||||||
var lastSlash = pattern.lastIndexOf("/");
|
var lastSlash = pattern.lastIndexOf("/");
|
||||||
if (lastSlash > 0) {
|
if (lastSlash === 0) throw new Error("Missing closing slash");
|
||||||
new RegExp(pattern.substring(1, lastSlash), pattern.substring(lastSlash + 1));
|
new RegExp(pattern.substring(1, lastSlash), pattern.substring(lastSlash + 1));
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
status.textContent =
|
status.textContent =
|
||||||
"Error: Invalid site rule regex: " + pattern + ". Unable to save";
|
"Error: Invalid site rule regex: " + pattern + ". Unable to save";
|
||||||
@@ -767,11 +822,19 @@ function validate() {
|
|||||||
return valid;
|
return valid;
|
||||||
}
|
}
|
||||||
|
|
||||||
function save_options() {
|
function save_options(isAutoSave) {
|
||||||
|
clearTimeout(autoSaveTimer);
|
||||||
|
clearTimeout(saveStatusTimer);
|
||||||
|
autoSaveTimer = null;
|
||||||
|
var status = document.getElementById("status");
|
||||||
|
if (!optionsSyncSettingsLoaded) {
|
||||||
|
status.textContent =
|
||||||
|
"Error: Settings have not loaded. Retry before saving.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (validate() === false) return;
|
if (validate() === false) return;
|
||||||
|
|
||||||
keyBindings = [];
|
keyBindings = [];
|
||||||
var status = document.getElementById("status");
|
|
||||||
var saveError = null;
|
var saveError = null;
|
||||||
|
|
||||||
// Collect shortcuts from the main shortcuts section (both default and custom)
|
// Collect shortcuts from the main shortcuts section (both default and custom)
|
||||||
@@ -791,8 +854,14 @@ function save_options() {
|
|||||||
settings.forceLastSavedSpeed =
|
settings.forceLastSavedSpeed =
|
||||||
document.getElementById("forceLastSavedSpeed").checked;
|
document.getElementById("forceLastSavedSpeed").checked;
|
||||||
settings.audioBoolean = document.getElementById("audioBoolean").checked;
|
settings.audioBoolean = document.getElementById("audioBoolean").checked;
|
||||||
|
settings.showAmbientLoopControls =
|
||||||
|
document.getElementById("showAmbientLoopControls").checked;
|
||||||
settings.enabled = document.getElementById("enabled").checked;
|
settings.enabled = document.getElementById("enabled").checked;
|
||||||
settings.startHidden = document.getElementById("startHidden").checked;
|
settings.startHidden = document.getElementById("startHidden").checked;
|
||||||
|
settings.shortcutTargetMode =
|
||||||
|
document.getElementById("shortcutTargetMode").value === "all"
|
||||||
|
? "all"
|
||||||
|
: "closest";
|
||||||
settings.hideWithControls = document.getElementById("hideWithControls").checked;
|
settings.hideWithControls = document.getElementById("hideWithControls").checked;
|
||||||
settings.hideWithControlsTimer =
|
settings.hideWithControlsTimer =
|
||||||
Math.min(15, Math.max(0.1, parseFloat(document.getElementById("hideWithControlsTimer").value) || tcDefaults.hideWithControlsTimer));
|
Math.min(15, Math.max(0.1, parseFloat(document.getElementById("hideWithControlsTimer").value) || tcDefaults.hideWithControlsTimer));
|
||||||
@@ -829,10 +898,9 @@ function save_options() {
|
|||||||
settings.subtitleNudgeInterval =
|
settings.subtitleNudgeInterval =
|
||||||
parseInt(document.getElementById("subtitleNudgeInterval").value, 10) ||
|
parseInt(document.getElementById("subtitleNudgeInterval").value, 10) ||
|
||||||
tcDefaults.subtitleNudgeInterval;
|
tcDefaults.subtitleNudgeInterval;
|
||||||
settings.subtitleNudgeAmount = tcDefaults.subtitleNudgeAmount;
|
|
||||||
|
|
||||||
if (settings.subtitleNudgeInterval < 10) {
|
if (settings.subtitleNudgeInterval < 250) {
|
||||||
settings.subtitleNudgeInterval = 10;
|
settings.subtitleNudgeInterval = 250;
|
||||||
}
|
}
|
||||||
if (settings.subtitleNudgeInterval > 1000) {
|
if (settings.subtitleNudgeInterval > 1000) {
|
||||||
settings.subtitleNudgeInterval = 1000;
|
settings.subtitleNudgeInterval = 1000;
|
||||||
@@ -851,11 +919,25 @@ function save_options() {
|
|||||||
var pattern = ruleEl.querySelector(".site-pattern").value.trim();
|
var pattern = ruleEl.querySelector(".site-pattern").value.trim();
|
||||||
if (pattern.length === 0) return;
|
if (pattern.length === 0) return;
|
||||||
|
|
||||||
var rule = { pattern: pattern };
|
var rule = vscClonePlainData(ruleEl.vscOriginalRule) || {};
|
||||||
|
rule.pattern = pattern;
|
||||||
|
var title = ruleEl.querySelector(".site-title").value.trim();
|
||||||
|
if (title) rule.title = title;
|
||||||
|
else delete rule.title;
|
||||||
|
delete rule.disableExtension;
|
||||||
|
|
||||||
// Handle Enable toggle
|
// Handle Enable toggle
|
||||||
rule.enabled = ruleEl.querySelector(".site-enabled").checked;
|
rule.enabled = ruleEl.querySelector(".site-enabled").checked;
|
||||||
|
|
||||||
|
if (ruleEl.querySelector(".override-shortcut-target").checked) {
|
||||||
|
rule.shortcutTargetMode =
|
||||||
|
ruleEl.querySelector(".site-shortcutTargetMode").value === "all"
|
||||||
|
? "all"
|
||||||
|
: "closest";
|
||||||
|
} else {
|
||||||
|
delete rule.shortcutTargetMode;
|
||||||
|
}
|
||||||
|
|
||||||
if (ruleEl.querySelector(".override-placement").checked) {
|
if (ruleEl.querySelector(".override-placement").checked) {
|
||||||
rule.controllerLocation = normalizeControllerLocation(
|
rule.controllerLocation = normalizeControllerLocation(
|
||||||
ruleEl.querySelector(".site-controllerLocation").value
|
ruleEl.querySelector(".site-controllerLocation").value
|
||||||
@@ -874,10 +956,16 @@ function save_options() {
|
|||||||
tcDefaults.controllerMarginBottom
|
tcDefaults.controllerMarginBottom
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
delete rule.controllerLocation;
|
||||||
|
delete rule.controllerMarginTop;
|
||||||
|
delete rule.controllerMarginBottom;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ruleEl.querySelector(".override-visibility").checked) {
|
if (ruleEl.querySelector(".override-visibility").checked) {
|
||||||
rule.startHidden = ruleEl.querySelector(".site-startHidden").checked;
|
rule.startHidden = ruleEl.querySelector(".site-startHidden").checked;
|
||||||
|
} else {
|
||||||
|
delete rule.startHidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ruleEl.querySelector(".override-autohide").checked) {
|
if (ruleEl.querySelector(".override-autohide").checked) {
|
||||||
@@ -889,13 +977,33 @@ function save_options() {
|
|||||||
15,
|
15,
|
||||||
Math.max(0.1, Number.isFinite(st) ? st : settings.hideWithControlsTimer)
|
Math.max(0.1, Number.isFinite(st) ? st : settings.hideWithControlsTimer)
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
delete rule.hideWithControls;
|
||||||
|
delete rule.hideWithControlsTimer;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ruleEl.querySelector(".override-playback").checked) {
|
if (ruleEl.querySelector(".override-playback").checked) {
|
||||||
rule.rememberSpeed = ruleEl.querySelector(".site-rememberSpeed").checked;
|
rule.rememberSpeed = ruleEl.querySelector(".site-rememberSpeed").checked;
|
||||||
rule.forceLastSavedSpeed =
|
rule.forceLastSavedSpeed =
|
||||||
ruleEl.querySelector(".site-forceLastSavedSpeed").checked;
|
ruleEl.querySelector(".site-forceLastSavedSpeed").checked;
|
||||||
|
var preferredSpeed = readOptionalPreferredSpeedInput(
|
||||||
|
ruleEl.querySelector(".site-preferredSpeed")
|
||||||
|
);
|
||||||
|
if (preferredSpeed === undefined) {
|
||||||
|
delete rule.preferredSpeed;
|
||||||
|
} else {
|
||||||
|
rule.preferredSpeed = preferredSpeed;
|
||||||
|
}
|
||||||
rule.audioBoolean = ruleEl.querySelector(".site-audioBoolean").checked;
|
rule.audioBoolean = ruleEl.querySelector(".site-audioBoolean").checked;
|
||||||
|
rule.showAmbientLoopControls = ruleEl.querySelector(
|
||||||
|
".site-showAmbientLoopControls"
|
||||||
|
).checked;
|
||||||
|
} else {
|
||||||
|
delete rule.rememberSpeed;
|
||||||
|
delete rule.forceLastSavedSpeed;
|
||||||
|
delete rule.preferredSpeed;
|
||||||
|
delete rule.audioBoolean;
|
||||||
|
delete rule.showAmbientLoopControls;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ruleEl.querySelector(".override-opacity").checked) {
|
if (ruleEl.querySelector(".override-opacity").checked) {
|
||||||
@@ -904,6 +1012,8 @@ function save_options() {
|
|||||||
ruleEl.querySelector(".site-controllerOpacity").value,
|
ruleEl.querySelector(".site-controllerOpacity").value,
|
||||||
settings.controllerOpacity
|
settings.controllerOpacity
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
delete rule.controllerOpacity;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ruleEl.querySelector(".override-subtitleNudge").checked) {
|
if (ruleEl.querySelector(".override-subtitleNudge").checked) {
|
||||||
@@ -918,10 +1028,14 @@ function save_options() {
|
|||||||
rule.subtitleNudgeInterval = Math.min(
|
rule.subtitleNudgeInterval = Math.min(
|
||||||
1000,
|
1000,
|
||||||
Math.max(
|
Math.max(
|
||||||
10,
|
250,
|
||||||
Number.isFinite(nudgeIv) ? nudgeIv : settings.subtitleNudgeInterval
|
Number.isFinite(nudgeIv) ? nudgeIv : settings.subtitleNudgeInterval
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
delete rule.enableSubtitleNudge;
|
||||||
|
delete rule.subtitleNudgeEnabledByDefault;
|
||||||
|
delete rule.subtitleNudgeInterval;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ruleEl.querySelector(".override-controlbar").checked) {
|
if (ruleEl.querySelector(".override-controlbar").checked) {
|
||||||
@@ -929,6 +1043,8 @@ function save_options() {
|
|||||||
if (activeZone) {
|
if (activeZone) {
|
||||||
rule.controllerButtons = readControlBarOrder(activeZone);
|
rule.controllerButtons = readControlBarOrder(activeZone);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
delete rule.controllerButtons;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ruleEl.querySelector(".override-popup-controlbar").checked) {
|
if (ruleEl.querySelector(".override-popup-controlbar").checked) {
|
||||||
@@ -940,6 +1056,9 @@ function save_options() {
|
|||||||
readControlBarOrder(popupActiveZone)
|
readControlBarOrder(popupActiveZone)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
delete rule.showPopupControlBar;
|
||||||
|
delete rule.popupControllerButtons;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ruleEl.querySelector(".override-shortcuts").checked) {
|
if (ruleEl.querySelector(".override-shortcuts").checked) {
|
||||||
@@ -971,37 +1090,57 @@ function save_options() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var shortcutValue = 0;
|
||||||
|
if (!customActionsNoValues.includes(action)) {
|
||||||
|
var shortcutValueError = keyBindingUtils.getActionValueError(
|
||||||
|
action,
|
||||||
|
valueInput.value
|
||||||
|
);
|
||||||
|
if (shortcutValueError) {
|
||||||
|
saveError =
|
||||||
|
"Error: Site rule value for " +
|
||||||
|
(actionLabels[action] || action) +
|
||||||
|
" " +
|
||||||
|
shortcutValueError +
|
||||||
|
". Unable to save";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
shortcutValue = Number(valueInput.value);
|
||||||
|
}
|
||||||
|
|
||||||
shortcuts.push({
|
shortcuts.push({
|
||||||
action: action,
|
action: action,
|
||||||
code: binding.code,
|
code: binding.code,
|
||||||
|
shiftKey: binding.shiftKey === true,
|
||||||
disabled: binding.disabled === true,
|
disabled: binding.disabled === true,
|
||||||
value: customActionsNoValues.includes(action)
|
value: shortcutValue,
|
||||||
? 0
|
|
||||||
: Number(valueInput.value),
|
|
||||||
force: forceCheckbox ? forceCheckbox.checked : false
|
force: forceCheckbox ? forceCheckbox.checked : false
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
if (saveError) return;
|
if (saveError) return;
|
||||||
if (shortcuts.length > 0) rule.shortcuts = shortcuts;
|
if (shortcuts.length > 0) rule.shortcuts = shortcuts;
|
||||||
|
else delete rule.shortcuts;
|
||||||
|
} else {
|
||||||
|
delete rule.shortcuts;
|
||||||
}
|
}
|
||||||
|
|
||||||
settings.siteRules.push(rule);
|
settings.siteRules.push(rule);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Legacy keys to remove
|
if (saveError) {
|
||||||
const legacyKeys = [
|
status.textContent = saveError;
|
||||||
"resetSpeed", "speedStep", "fastSpeed", "rewindTime", "advanceTime",
|
return;
|
||||||
"resetKeyCode", "slowerKeyCode", "fasterKeyCode", "rewindKeyCode",
|
}
|
||||||
"advanceKeyCode", "fastKeyCode", "blacklist"
|
|
||||||
];
|
|
||||||
|
|
||||||
chrome.storage.sync.remove(legacyKeys, function () {
|
persistManagedSyncSettings(settings, function (error) {
|
||||||
chrome.storage.sync.set(settings, function () {
|
if (error) {
|
||||||
status.textContent = "Options saved";
|
status.textContent = "Error: Unable to save options - " + error.message;
|
||||||
setTimeout(function () {
|
return;
|
||||||
status.textContent = "";
|
}
|
||||||
}, 1000);
|
status.textContent = isAutoSave === true ? "Auto-saved" : "Options saved";
|
||||||
});
|
saveStatusTimer = setTimeout(function () {
|
||||||
|
status.textContent = "";
|
||||||
|
}, 1000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1020,15 +1159,7 @@ function addSiteRuleShortcut(rowsEl, action, binding, value, force) {
|
|||||||
|
|
||||||
var actionLabel = document.createElement("div");
|
var actionLabel = document.createElement("div");
|
||||||
actionLabel.className = "shortcut-label";
|
actionLabel.className = "shortcut-label";
|
||||||
var actionLabelText = actionLabels[action] || action;
|
actionLabel.textContent = 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;
|
|
||||||
|
|
||||||
var keyInput = document.createElement("input");
|
var keyInput = document.createElement("input");
|
||||||
keyInput.className = "customKey";
|
keyInput.className = "customKey";
|
||||||
@@ -1076,17 +1207,50 @@ function addSiteRuleShortcut(rowsEl, action, binding, value, force) {
|
|||||||
div.appendChild(valueInput);
|
div.appendChild(valueInput);
|
||||||
div.appendChild(forceLabel);
|
div.appendChild(forceLabel);
|
||||||
div.appendChild(removeButton);
|
div.appendChild(removeButton);
|
||||||
|
labelShortcutRow(div);
|
||||||
|
|
||||||
rowsEl.appendChild(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) {
|
function createSiteRule(rule) {
|
||||||
var template = document.getElementById("siteRuleTemplate");
|
var template = document.getElementById("siteRuleTemplate");
|
||||||
var clone = template.content.cloneNode(true);
|
var clone = template.content.cloneNode(true);
|
||||||
var ruleEl = clone.querySelector(".site-rule");
|
var ruleEl = clone.querySelector(".site-rule");
|
||||||
|
ruleEl.vscOriginalRule = vscClonePlainData(rule) || {};
|
||||||
|
|
||||||
var pattern = rule && rule.pattern ? rule.pattern : "";
|
var pattern = rule && rule.pattern ? rule.pattern : "";
|
||||||
ruleEl.querySelector(".site-pattern").value = pattern;
|
ruleEl.querySelector(".site-pattern").value = pattern;
|
||||||
|
ruleEl.querySelector(".site-title").value =
|
||||||
|
rule && typeof rule.title === "string" ? rule.title : "";
|
||||||
|
|
||||||
// Make the rule body collapsed by default
|
// Make the rule body collapsed by default
|
||||||
setSiteRuleExpandedState(ruleEl, false);
|
setSiteRuleExpandedState(ruleEl, false);
|
||||||
@@ -1117,6 +1281,17 @@ function createSiteRule(rule) {
|
|||||||
}
|
}
|
||||||
updateDisabledState();
|
updateDisabledState();
|
||||||
|
|
||||||
|
ruleEl.querySelector(".override-shortcut-target").checked = Boolean(
|
||||||
|
rule && rule.shortcutTargetMode !== undefined
|
||||||
|
);
|
||||||
|
ruleEl.querySelector(".site-shortcutTargetMode").value =
|
||||||
|
rule && rule.shortcutTargetMode === "all" ? "all" : "closest";
|
||||||
|
applySiteRuleOverrideState(
|
||||||
|
ruleEl,
|
||||||
|
"override-shortcut-target",
|
||||||
|
"site-shortcut-target-container"
|
||||||
|
);
|
||||||
|
|
||||||
var placementKeys = [
|
var placementKeys = [
|
||||||
"controllerLocation",
|
"controllerLocation",
|
||||||
"controllerMarginTop",
|
"controllerMarginTop",
|
||||||
@@ -1150,12 +1325,19 @@ function createSiteRule(rule) {
|
|||||||
rule &&
|
rule &&
|
||||||
(rule.rememberSpeed !== undefined ||
|
(rule.rememberSpeed !== undefined ||
|
||||||
rule.forceLastSavedSpeed !== undefined ||
|
rule.forceLastSavedSpeed !== undefined ||
|
||||||
rule.audioBoolean !== undefined)
|
rule.preferredSpeed !== undefined ||
|
||||||
|
rule.audioBoolean !== undefined ||
|
||||||
|
rule.showAmbientLoopControls !== undefined)
|
||||||
);
|
);
|
||||||
ruleEl.querySelector(".override-playback").checked = hasPlaybackOverride;
|
ruleEl.querySelector(".override-playback").checked = hasPlaybackOverride;
|
||||||
syncSiteRuleField(ruleEl, rule, "rememberSpeed", true);
|
syncSiteRuleField(ruleEl, rule, "rememberSpeed", true);
|
||||||
syncSiteRuleField(ruleEl, rule, "forceLastSavedSpeed", true);
|
syncSiteRuleField(ruleEl, rule, "forceLastSavedSpeed", true);
|
||||||
|
ruleEl.querySelector(".site-preferredSpeed").value =
|
||||||
|
rule && Number.isFinite(Number(rule.preferredSpeed))
|
||||||
|
? String(rule.preferredSpeed)
|
||||||
|
: "";
|
||||||
syncSiteRuleField(ruleEl, rule, "audioBoolean", true);
|
syncSiteRuleField(ruleEl, rule, "audioBoolean", true);
|
||||||
|
syncSiteRuleField(ruleEl, rule, "showAmbientLoopControls", true);
|
||||||
applySiteRuleOverrideState(ruleEl, "override-playback", "site-playback-container");
|
applySiteRuleOverrideState(ruleEl, "override-playback", "site-playback-container");
|
||||||
|
|
||||||
ruleEl.querySelector(".override-opacity").checked = Boolean(
|
ruleEl.querySelector(".override-opacity").checked = Boolean(
|
||||||
@@ -1231,6 +1413,7 @@ function createSiteRule(rule) {
|
|||||||
}
|
}
|
||||||
applySiteRuleOverrideState(ruleEl, "override-shortcuts", "site-shortcuts-container");
|
applySiteRuleOverrideState(ruleEl, "override-shortcuts", "site-shortcuts-container");
|
||||||
refreshSiteRuleAddShortcutSelector(ruleEl);
|
refreshSiteRuleAddShortcutSelector(ruleEl);
|
||||||
|
associateSiteRuleLabels(ruleEl);
|
||||||
|
|
||||||
document.getElementById("siteRulesContainer").appendChild(ruleEl);
|
document.getElementById("siteRulesContainer").appendChild(ruleEl);
|
||||||
}
|
}
|
||||||
@@ -1239,7 +1422,8 @@ function createControlBarBlock(buttonId) {
|
|||||||
var def = controllerButtonDefs[buttonId];
|
var def = controllerButtonDefs[buttonId];
|
||||||
if (!def) return null;
|
if (!def) return null;
|
||||||
|
|
||||||
var block = document.createElement("div");
|
var block = document.createElement("button");
|
||||||
|
block.type = "button";
|
||||||
block.className = "cb-block";
|
block.className = "cb-block";
|
||||||
block.dataset.buttonId = buttonId;
|
block.dataset.buttonId = buttonId;
|
||||||
block.draggable = true;
|
block.draggable = true;
|
||||||
@@ -1262,6 +1446,25 @@ function createControlBarBlock(buttonId) {
|
|||||||
return block;
|
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) {
|
function populateControlBarZones(activeZone, availableZone, activeIds, allowButtonId) {
|
||||||
vscClearElement(activeZone);
|
vscClearElement(activeZone);
|
||||||
vscClearElement(availableZone);
|
vscClearElement(availableZone);
|
||||||
@@ -1284,6 +1487,8 @@ function populateControlBarZones(activeZone, availableZone, activeIds, allowButt
|
|||||||
if (block) availableZone.appendChild(block);
|
if (block) availableZone.appendChild(block);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
updateControlBarBlockLabels(activeZone.closest(".cb-editor"));
|
||||||
}
|
}
|
||||||
|
|
||||||
function readControlBarOrder(activeZone) {
|
function readControlBarOrder(activeZone) {
|
||||||
@@ -1327,11 +1532,11 @@ function updatePopupEditorDisabledState() {
|
|||||||
var checkbox = document.getElementById("popupMatchHoverControls");
|
var checkbox = document.getElementById("popupMatchHoverControls");
|
||||||
var wrap = document.getElementById("popupCbEditorWrap");
|
var wrap = document.getElementById("popupCbEditorWrap");
|
||||||
if (!checkbox || !wrap) return;
|
if (!checkbox || !wrap) return;
|
||||||
if (checkbox.checked) {
|
wrap.classList.toggle("cb-editor-disabled", checkbox.checked);
|
||||||
wrap.classList.add("cb-editor-disabled");
|
wrap.querySelectorAll(".cb-block").forEach(function(block) {
|
||||||
} else {
|
block.disabled = checkbox.checked;
|
||||||
wrap.classList.remove("cb-editor-disabled");
|
block.draggable = !checkbox.checked;
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDragAfterElement(container, x, y) {
|
function getDragAfterElement(container, x, y) {
|
||||||
@@ -1354,6 +1559,8 @@ function getDragAfterElement(container, x, y) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function initControlBarEditor() {
|
function initControlBarEditor() {
|
||||||
|
if (document.vscControlBarEditorInitialized) return;
|
||||||
|
document.vscControlBarEditorInitialized = true;
|
||||||
var draggedBlock = null;
|
var draggedBlock = null;
|
||||||
|
|
||||||
function clearControlBarDropTargets(activeZone) {
|
function clearControlBarDropTargets(activeZone) {
|
||||||
@@ -1366,7 +1573,7 @@ function initControlBarEditor() {
|
|||||||
|
|
||||||
document.addEventListener("dragstart", function (e) {
|
document.addEventListener("dragstart", function (e) {
|
||||||
var block = e.target.closest(".cb-block");
|
var block = e.target.closest(".cb-block");
|
||||||
if (!block) return;
|
if (!block || block.disabled) return;
|
||||||
draggedBlock = block;
|
draggedBlock = block;
|
||||||
e.dataTransfer.effectAllowed = "move";
|
e.dataTransfer.effectAllowed = "move";
|
||||||
e.dataTransfer.setData("text/plain", block.dataset.buttonId);
|
e.dataTransfer.setData("text/plain", block.dataset.buttonId);
|
||||||
@@ -1411,10 +1618,49 @@ function initControlBarEditor() {
|
|||||||
var zone = e.target.closest(".cb-dropzone");
|
var zone = e.target.closest(".cb-dropzone");
|
||||||
if (zone) {
|
if (zone) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
updateControlBarBlockLabels(zone.closest(".cb-editor"));
|
||||||
|
scheduleAutoSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
clearControlBarDropTargets(null);
|
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;
|
var lucidePickerSelectedSlug = null;
|
||||||
@@ -1486,6 +1732,10 @@ function initLucideButtonIconsUI() {
|
|||||||
b.dataset.slug = slug;
|
b.dataset.slug = slug;
|
||||||
b.title = slug;
|
b.title = slug;
|
||||||
b.setAttribute("aria-label", slug);
|
b.setAttribute("aria-label", slug);
|
||||||
|
b.setAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
slug === lucidePickerSelectedSlug ? "true" : "false"
|
||||||
|
);
|
||||||
if (slug === lucidePickerSelectedSlug) {
|
if (slug === lucidePickerSelectedSlug) {
|
||||||
b.classList.add("lucide-picked");
|
b.classList.add("lucide-picked");
|
||||||
}
|
}
|
||||||
@@ -1507,6 +1757,10 @@ function initLucideButtonIconsUI() {
|
|||||||
resultsEl.querySelectorAll("button"),
|
resultsEl.querySelectorAll("button"),
|
||||||
function (x) {
|
function (x) {
|
||||||
x.classList.toggle("lucide-picked", x.dataset.slug === slug);
|
x.classList.toggle("lucide-picked", x.dataset.slug === slug);
|
||||||
|
x.setAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
x.dataset.slug === slug ? "true" : "false"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
fetchLucideSvg(slug)
|
fetchLucideSvg(slug)
|
||||||
@@ -1572,7 +1826,7 @@ function initLucideButtonIconsUI() {
|
|||||||
slug +
|
slug +
|
||||||
" for " +
|
" for " +
|
||||||
action +
|
action +
|
||||||
". Reload pages for the hover bar."
|
". Open pages update automatically."
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
@@ -1621,11 +1875,26 @@ function initLucideButtonIconsUI() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function restore_options() {
|
function restore_options(callback) {
|
||||||
chrome.storage.sync.get(tcDefaults, function (storage) {
|
var done = typeof callback === "function" ? callback : function () {};
|
||||||
|
var status = document.getElementById("status");
|
||||||
|
setOptionsSyncSettingsLoaded(false);
|
||||||
|
chrome.storage.sync.get(null, function (rawStorage) {
|
||||||
|
var syncError = chrome.runtime.lastError;
|
||||||
|
if (syncError) {
|
||||||
|
status.textContent =
|
||||||
|
"Error: Unable to load options - " + syncError.message;
|
||||||
|
done(syncError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var storage = vscExpandStoredSettings(rawStorage || {});
|
||||||
chrome.storage.local.get(["customButtonIcons"], function (loc) {
|
chrome.storage.local.get(["customButtonIcons"], function (loc) {
|
||||||
|
var localError = chrome.runtime.lastError;
|
||||||
customButtonIconsLive =
|
customButtonIconsLive =
|
||||||
loc && loc.customButtonIcons && typeof loc.customButtonIcons === "object"
|
!localError &&
|
||||||
|
loc &&
|
||||||
|
loc.customButtonIcons &&
|
||||||
|
typeof loc.customButtonIcons === "object"
|
||||||
? loc.customButtonIcons
|
? loc.customButtonIcons
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
@@ -1633,15 +1902,15 @@ function restore_options() {
|
|||||||
document.getElementById("forceLastSavedSpeed").checked =
|
document.getElementById("forceLastSavedSpeed").checked =
|
||||||
storage.forceLastSavedSpeed;
|
storage.forceLastSavedSpeed;
|
||||||
document.getElementById("audioBoolean").checked = storage.audioBoolean;
|
document.getElementById("audioBoolean").checked = storage.audioBoolean;
|
||||||
|
document.getElementById("showAmbientLoopControls").checked =
|
||||||
|
storage.showAmbientLoopControls;
|
||||||
document.getElementById("enabled").checked = storage.enabled;
|
document.getElementById("enabled").checked = storage.enabled;
|
||||||
document.getElementById("startHidden").checked = storage.startHidden;
|
document.getElementById("startHidden").checked = storage.startHidden;
|
||||||
|
document.getElementById("shortcutTargetMode").value =
|
||||||
|
storage.shortcutTargetMode === "all" ? "all" : "closest";
|
||||||
|
|
||||||
// Migration/Normalization for hideWithControls
|
document.getElementById("hideWithControls").checked =
|
||||||
const hideWithControls = typeof storage.hideWithControls !== "undefined"
|
storage.hideWithControls;
|
||||||
? storage.hideWithControls
|
|
||||||
: storage.hideWithYouTubeControls;
|
|
||||||
|
|
||||||
document.getElementById("hideWithControls").checked = hideWithControls;
|
|
||||||
document.getElementById("hideWithControlsTimer").value =
|
document.getElementById("hideWithControlsTimer").value =
|
||||||
storage.hideWithControlsTimer || tcDefaults.hideWithControlsTimer;
|
storage.hideWithControlsTimer || tcDefaults.hideWithControlsTimer;
|
||||||
|
|
||||||
@@ -1670,13 +1939,16 @@ function restore_options() {
|
|||||||
|
|
||||||
document.querySelectorAll(".customs:not([id])").forEach((row) => row.remove());
|
document.querySelectorAll(".customs:not([id])").forEach((row) => row.remove());
|
||||||
|
|
||||||
|
var usedDefaultShortcutRows = new Set();
|
||||||
storage.keyBindings.forEach((item) => {
|
storage.keyBindings.forEach((item) => {
|
||||||
var row = document.getElementById(item.action);
|
var row = document.getElementById(item.action);
|
||||||
var normalizedBinding = normalizeStoredBinding(item);
|
var normalizedBinding = normalizeStoredBinding(item);
|
||||||
|
|
||||||
if (!row) {
|
if (!row || usedDefaultShortcutRows.has(item.action)) {
|
||||||
add_shortcut(item.action, item.value);
|
add_shortcut(item.action, item.value);
|
||||||
row = document.querySelector(".shortcut-row.customs:last-of-type");
|
row = document.querySelector(".shortcut-row.customs:last-of-type");
|
||||||
|
} else {
|
||||||
|
usedDefaultShortcutRows.add(item.action);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
@@ -1699,11 +1971,7 @@ function restore_options() {
|
|||||||
|
|
||||||
refreshAddShortcutSelector();
|
refreshAddShortcutSelector();
|
||||||
|
|
||||||
// Load site rules (use defaults if none in storage or empty array)
|
var siteRules = Array.isArray(storage.siteRules) ? storage.siteRules : [];
|
||||||
var siteRules =
|
|
||||||
Array.isArray(storage.siteRules) && storage.siteRules.length > 0
|
|
||||||
? storage.siteRules
|
|
||||||
: tcDefaults.siteRules || [];
|
|
||||||
|
|
||||||
vscClearElement(document.getElementById("siteRulesContainer"));
|
vscClearElement(document.getElementById("siteRulesContainer"));
|
||||||
if (siteRules.length > 0) {
|
if (siteRules.length > 0) {
|
||||||
@@ -1729,25 +1997,94 @@ function restore_options() {
|
|||||||
updatePopupEditorDisabledState();
|
updatePopupEditorDisabledState();
|
||||||
|
|
||||||
initLucideButtonIconsUI();
|
initLucideButtonIconsUI();
|
||||||
|
setOptionsSyncSettingsLoaded(true);
|
||||||
|
if (localError) {
|
||||||
|
status.textContent =
|
||||||
|
"Warning: Options loaded, but custom icons could not be read - " +
|
||||||
|
localError.message;
|
||||||
|
}
|
||||||
|
done(localError || null);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function restore_defaults() {
|
function restore_defaults() {
|
||||||
document.querySelectorAll(".customs:not([id])").forEach((el) => el.remove());
|
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);
|
||||||
|
if (restoreButton) restoreButton.disabled = true;
|
||||||
|
|
||||||
chrome.storage.local.remove(
|
function finishRestore(error) {
|
||||||
["customButtonIcons", "lucideTagsCacheV1", "lucideTagsCacheV1At"],
|
// Leave the storage callback before rereading. Besides avoiding nested API
|
||||||
function () {}
|
// calls, this prevents a callback-scoped runtime.lastError from being
|
||||||
);
|
// mistaken for an error from the fresh read.
|
||||||
|
Promise.resolve().then(function () {
|
||||||
|
restore_options(function (reloadError) {
|
||||||
|
if (restoreButton) restoreButton.disabled = false;
|
||||||
|
if (error) {
|
||||||
|
status.textContent =
|
||||||
|
"Error: Unable to restore all defaults - " + error.message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (reloadError) {
|
||||||
|
status.textContent =
|
||||||
|
"Defaults restored, but the options page could not reload - " +
|
||||||
|
reloadError.message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
status.textContent = "Default options restored";
|
||||||
|
setTimeout(function () {
|
||||||
|
status.textContent = "";
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
chrome.storage.sync.set(tcDefaults, function () {
|
persistManagedSyncSettings(tcDefaults, function (error) {
|
||||||
restore_options();
|
if (error) {
|
||||||
var status = document.getElementById("status");
|
finishRestore(error);
|
||||||
status.textContent = "Default options restored";
|
return;
|
||||||
setTimeout(function () {
|
}
|
||||||
status.textContent = "";
|
|
||||||
}, 1000);
|
chrome.storage.sync.set({ lastSpeed: tcDefaults.lastSpeed }, function () {
|
||||||
|
var lastSpeedError = chrome.runtime.lastError;
|
||||||
|
if (lastSpeedError) {
|
||||||
|
finishRestore(lastSpeedError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
chrome.storage.local.set(
|
||||||
|
{
|
||||||
|
rememberedSpeeds: {},
|
||||||
|
rememberedSpeedsResetAt: Date.now()
|
||||||
|
},
|
||||||
|
function () {
|
||||||
|
var speedResetError = chrome.runtime.lastError;
|
||||||
|
if (speedResetError) {
|
||||||
|
finishRestore(speedResetError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chrome.storage.local.remove(
|
||||||
|
[
|
||||||
|
"customButtonIcons",
|
||||||
|
"lucideTagsCacheV1",
|
||||||
|
"lucideTagsCacheV1At",
|
||||||
|
"rememberedSpeeds"
|
||||||
|
],
|
||||||
|
function () {
|
||||||
|
finishRestore(chrome.runtime.lastError || null);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1758,6 +2095,8 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
versionElement.textContent = manifest.version;
|
versionElement.textContent = manifest.version;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll("#customs .shortcut-row").forEach(labelShortcutRow);
|
||||||
|
|
||||||
restore_options();
|
restore_options();
|
||||||
initControlBarEditor();
|
initControlBarEditor();
|
||||||
|
|
||||||
@@ -1765,6 +2104,41 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
.addEventListener("change", updatePopupEditorDisabledState);
|
.addEventListener("change", updatePopupEditorDisabledState);
|
||||||
|
|
||||||
document.getElementById("save").addEventListener("click", save_options);
|
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");
|
const addSelector = document.getElementById("addShortcutSelector");
|
||||||
if (addSelector) {
|
if (addSelector) {
|
||||||
@@ -1817,11 +2191,13 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
if (siteRuleForShortcut) {
|
if (siteRuleForShortcut) {
|
||||||
refreshSiteRuleAddShortcutSelector(siteRuleForShortcut);
|
refreshSiteRuleAddShortcutSelector(siteRuleForShortcut);
|
||||||
}
|
}
|
||||||
|
scheduleAutoSave();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var removeSiteRuleButton = targetEl.closest(".remove-site-rule");
|
var removeSiteRuleButton = targetEl.closest(".remove-site-rule");
|
||||||
if (removeSiteRuleButton) {
|
if (removeSiteRuleButton) {
|
||||||
removeSiteRuleButton.closest(".site-rule").remove();
|
removeSiteRuleButton.closest(".site-rule").remove();
|
||||||
|
scheduleAutoSave();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var toggleButton = targetEl.closest(".toggle-site-rule");
|
var toggleButton = targetEl.closest(".toggle-site-rule");
|
||||||
@@ -1891,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: #e2e5e9;
|
||||||
--border-strong: #d4d9e0;
|
--border-strong: #d4d9e0;
|
||||||
--text: #17191c;
|
--text: #17191c;
|
||||||
--muted: #626b76;
|
--muted: #626b76;
|
||||||
--accent: #111827;
|
--accent: #111827;
|
||||||
|
--focus-ring: #2563eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -83,10 +84,15 @@ button:active {
|
|||||||
background: #f1f3f5;
|
background: #f1f3f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:focus-visible {
|
button:focus-visible {
|
||||||
outline: 2px solid rgba(17, 24, 39, 0.14);
|
outline: 2px solid var(--focus-ring);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
#refresh {
|
#refresh {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
@@ -112,6 +118,12 @@ button:focus-visible {
|
|||||||
border-color: #9fbd98;
|
border-color: #9fbd98;
|
||||||
color: #285d21;
|
color: #285d21;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#pauseTab[aria-pressed="true"] {
|
||||||
|
background: #fff3d6;
|
||||||
|
border-color: #d8b66b;
|
||||||
|
color: #704f0d;
|
||||||
|
}
|
||||||
|
|
||||||
.popup-divider {
|
.popup-divider {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
@@ -250,8 +262,8 @@ button:focus-visible {
|
|||||||
background: #f1f3f5;
|
background: #f1f3f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.donate-icon-btn:focus-visible {
|
.donate-icon-btn:focus-visible {
|
||||||
outline: 2px solid rgba(17, 24, 39, 0.14);
|
outline: 2px solid var(--focus-ring);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
@@ -295,8 +307,9 @@ button:focus-visible {
|
|||||||
--border: #2b3138;
|
--border: #2b3138;
|
||||||
--border-strong: #3a414a;
|
--border-strong: #3a414a;
|
||||||
--text: #f2f4f6;
|
--text: #f2f4f6;
|
||||||
--muted: #a0a8b2;
|
--muted: #a0a8b2;
|
||||||
--accent: #f2f4f6;
|
--accent: #f2f4f6;
|
||||||
|
--focus-ring: #93c5fd;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>Speeder</title>
|
<title>Speeder</title>
|
||||||
<link rel="stylesheet" href="popup.css" />
|
<link rel="stylesheet" href="popup.css" />
|
||||||
|
<script src="../shared/settings-core.js"></script>
|
||||||
<script src="../shared/site-rules.js"></script>
|
<script src="../shared/site-rules.js"></script>
|
||||||
<script src="../shared/popup-controls.js"></script>
|
<script src="../shared/popup-controls.js"></script>
|
||||||
<script src="../shared/ui-icons.js"></script>
|
<script src="../shared/ui-icons.js"></script>
|
||||||
@@ -23,6 +24,12 @@
|
|||||||
type="button"
|
type="button"
|
||||||
aria-pressed="false"
|
aria-pressed="false"
|
||||||
>Force last saved speed</button>
|
>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 class="popup-divider"></div>
|
||||||
<div id="popupControlBar" class="popup-control-bar">
|
<div id="popupControlBar" class="popup-control-bar">
|
||||||
<span id="popupSpeed" class="popup-speed">1.00</span>
|
<span id="popupSpeed" class="popup-speed">1.00</span>
|
||||||
@@ -31,10 +38,17 @@
|
|||||||
<button id="enable" class="hide">Enable</button>
|
<button id="enable" class="hide">Enable</button>
|
||||||
<button id="disable">Disable</button>
|
<button id="disable">Disable</button>
|
||||||
</div>
|
</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">
|
<div class="popup-links">
|
||||||
<button id="config">Settings</button>
|
<button id="config">Settings</button>
|
||||||
<div class="popup-secondary">
|
<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="feedback" class="secondary">Feedback</button>
|
||||||
<button id="about" class="secondary">About</button>
|
<button id="about" class="secondary">About</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+376
-73
@@ -1,4 +1,6 @@
|
|||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
if (window.vscPopupInitialized) return;
|
||||||
|
window.vscPopupInitialized = true;
|
||||||
var speederShared =
|
var speederShared =
|
||||||
typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {};
|
typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {};
|
||||||
var siteRuleUtils = speederShared.siteRules || {};
|
var siteRuleUtils = speederShared.siteRules || {};
|
||||||
@@ -25,17 +27,104 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
|
|
||||||
var defaultButtons = ["rewind", "slower", "faster", "advance", "display"];
|
var defaultButtons = ["rewind", "slower", "faster", "advance", "display"];
|
||||||
var popupExcludedButtonIds = new Set(["settings"]);
|
var popupExcludedButtonIds = new Set(["settings"]);
|
||||||
var storageDefaults = {
|
|
||||||
enabled: true,
|
|
||||||
lastSpeed: 1.0,
|
|
||||||
forceLastSavedSpeed: false,
|
|
||||||
showPopupControlBar: true,
|
|
||||||
controllerButtons: defaultButtons,
|
|
||||||
popupMatchHoverControls: true,
|
|
||||||
popupControllerButtons: defaultButtons,
|
|
||||||
siteRules: []
|
|
||||||
};
|
|
||||||
var renderToken = 0;
|
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);
|
||||||
|
|
||||||
|
function removeStaleKeys() {
|
||||||
|
if (!mutation.remove.length) {
|
||||||
|
callback(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chrome.storage.sync.remove(mutation.remove, function () {
|
||||||
|
callback(chrome.runtime.lastError || null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Object.keys(mutation.set).length) {
|
||||||
|
removeStaleKeys();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
chrome.storage.sync.set(mutation.set, function () {
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
callback(chrome.runtime.lastError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
removeStaleKeys();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStoredSettings(update, callback) {
|
||||||
|
chrome.storage.sync.get(null, function (rawStorage) {
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
callback(chrome.runtime.lastError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var settings = vscExpandStoredSettings(rawStorage || {});
|
||||||
|
update(settings);
|
||||||
|
persistExpandedSettings(rawStorage || {}, settings, callback);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function updateForceButton(enabled) {
|
function updateForceButton(enabled) {
|
||||||
var button = document.getElementById("forceLastSavedSpeed");
|
var button = document.getElementById("forceLastSavedSpeed");
|
||||||
@@ -46,6 +135,13 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
: "Keep this page at the last speed saved by Speeder";
|
: "Keep this page at the last speed saved by Speeder";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setForceButtonLoading(loading) {
|
||||||
|
var button = document.getElementById("forceLastSavedSpeed");
|
||||||
|
if (!button) return;
|
||||||
|
button.disabled = loading === true;
|
||||||
|
button.setAttribute("aria-busy", loading ? "true" : "false");
|
||||||
|
}
|
||||||
|
|
||||||
function matchSiteRule(url, siteRules) {
|
function matchSiteRule(url, siteRules) {
|
||||||
return siteRuleUtils.matchSiteRule(url, siteRules);
|
return siteRuleUtils.matchSiteRule(url, siteRules);
|
||||||
}
|
}
|
||||||
@@ -86,16 +182,23 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getActiveTabContext(callback) {
|
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) {
|
chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
|
||||||
var activeTab = tabs && tabs[0] ? tabs[0] : null;
|
var activeTab = tabs && tabs[0] ? tabs[0] : null;
|
||||||
if (!activeTab || !activeTab.id) {
|
if (!activeTab || !activeTab.id) {
|
||||||
if (callback) callback({ tab: null, url: "" });
|
finish({ tab: null, url: "" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var tabUrl = typeof activeTab.url === "string" ? activeTab.url : "";
|
var tabUrl = typeof activeTab.url === "string" ? activeTab.url : "";
|
||||||
if (tabUrl.length > 0) {
|
if (tabUrl.length > 0) {
|
||||||
if (callback) callback({ tab: activeTab, url: tabUrl });
|
finish({ tab: activeTab, url: tabUrl });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,13 +207,13 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
{ action: "get_page_context" },
|
{ action: "get_page_context" },
|
||||||
function (response) {
|
function (response) {
|
||||||
if (chrome.runtime.lastError) {
|
if (chrome.runtime.lastError) {
|
||||||
if (callback) callback({ tab: activeTab, url: "" });
|
finish({ tab: activeTab, url: "" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var pageUrl =
|
var pageUrl =
|
||||||
response && typeof response.url === "string" ? response.url : "";
|
response && typeof response.url === "string" ? response.url : "";
|
||||||
if (callback) callback({ tab: activeTab, url: pageUrl });
|
finish({ tab: activeTab, url: pageUrl });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -125,15 +228,20 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
if (response && response.speed != null) {
|
if (response && response.speed != null) {
|
||||||
updateSpeedDisplay(response.speed);
|
updateSpeedDisplay(response.speed);
|
||||||
}
|
}
|
||||||
|
selectedFrameToken =
|
||||||
|
response && typeof response.frameToken === "string"
|
||||||
|
? response.frameToken
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickBestFrameSpeedResult(results) {
|
function pickBestFrameSpeedResult(results) {
|
||||||
return popupControlUtils.pickBestFrameSpeedResult(results);
|
return popupControlUtils.pickBestFrameSpeedResult(results);
|
||||||
}
|
}
|
||||||
|
|
||||||
function querySpeed() {
|
function querySpeed(callback) {
|
||||||
chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
|
chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
|
||||||
if (!tabs[0] || tabs[0].id == null) {
|
if (!tabs[0] || tabs[0].id == null) {
|
||||||
|
if (callback) callback(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var tabId = tabs[0].id;
|
var tabId = tabs[0].id;
|
||||||
@@ -144,15 +252,18 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
if (chrome.runtime.lastError) {
|
if (chrome.runtime.lastError) {
|
||||||
sendToActiveTab({ action: "get_speed" }, function (response) {
|
sendToActiveTab({ action: "get_speed" }, function (response) {
|
||||||
applySpeedAndResetFromResponse(response || { speed: 1 });
|
applySpeedAndResetFromResponse(response || { speed: 1 });
|
||||||
|
if (callback) callback(response || null);
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var best = pickBestFrameSpeedResult(results);
|
var best = pickBestFrameSpeedResult(results);
|
||||||
if (best) {
|
if (best) {
|
||||||
applySpeedAndResetFromResponse(best);
|
applySpeedAndResetFromResponse(best);
|
||||||
|
if (callback) callback(best);
|
||||||
} else {
|
} else {
|
||||||
sendToActiveTab({ action: "get_speed" }, function (response) {
|
sendToActiveTab({ action: "get_speed" }, function (response) {
|
||||||
applySpeedAndResetFromResponse(response || { speed: 1 });
|
applySpeedAndResetFromResponse(response || { speed: 1 });
|
||||||
|
if (callback) callback(response || null);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,14 +311,19 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
}
|
}
|
||||||
if (def.className) btn.className = def.className;
|
if (def.className) btn.className = def.className;
|
||||||
btn.title = btnId.charAt(0).toUpperCase() + btnId.slice(1);
|
btn.title = btnId.charAt(0).toUpperCase() + btnId.slice(1);
|
||||||
|
btn.setAttribute("aria-label", btn.title);
|
||||||
|
|
||||||
btn.addEventListener("click", function () {
|
btn.addEventListener("click", function () {
|
||||||
if (btnId === "settings") {
|
if (btnId === "settings") {
|
||||||
window.open(chrome.runtime.getURL("options/options.html"));
|
window.open(chrome.runtime.getURL("options/options.html"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
var message = { action: "run_action", actionName: btnId };
|
||||||
|
if (shortcutTargetMode !== "all" && selectedFrameToken) {
|
||||||
|
message.targetFrameToken = selectedFrameToken;
|
||||||
|
}
|
||||||
sendToActiveTab(
|
sendToActiveTab(
|
||||||
{ action: "run_action", actionName: btnId },
|
message,
|
||||||
function () {
|
function () {
|
||||||
querySpeed();
|
querySpeed();
|
||||||
}
|
}
|
||||||
@@ -236,6 +352,104 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
window.open("https://github.com/SoPat712/Speeder/issues");
|
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 () {
|
document.querySelector("#donate").addEventListener("click", function () {
|
||||||
this.classList.add("hide");
|
this.classList.add("hide");
|
||||||
document.querySelector("#donateOptions").classList.remove("hide");
|
document.querySelector("#donateOptions").classList.remove("hide");
|
||||||
@@ -269,39 +483,70 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
if (!forceLastSavedSpeedButton.dataset.listenerAttached) {
|
if (!forceLastSavedSpeedButton.dataset.listenerAttached) {
|
||||||
forceLastSavedSpeedButton.dataset.listenerAttached = "true";
|
forceLastSavedSpeedButton.dataset.listenerAttached = "true";
|
||||||
forceLastSavedSpeedButton.addEventListener("click", function () {
|
forceLastSavedSpeedButton.addEventListener("click", function () {
|
||||||
|
if (forceLastSavedSpeedControlledBySiteRule === null) {
|
||||||
|
setStatusMessage("Loading page settings...");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (forceLastSavedSpeedControlledBySiteRule) {
|
||||||
|
setStatusMessage("Force setting is controlled by this site rule.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var button = this;
|
var button = this;
|
||||||
var enabled = button.getAttribute("aria-pressed") !== "true";
|
var enabled = button.getAttribute("aria-pressed") !== "true";
|
||||||
chrome.storage.sync.get({ lastSpeed: 1.0 }, function (storage) {
|
updateStoredSettings(
|
||||||
chrome.storage.sync.set({ forceLastSavedSpeed: enabled }, function () {
|
function (settings) {
|
||||||
updateForceButton(enabled);
|
settings.forceLastSavedSpeed = enabled;
|
||||||
|
},
|
||||||
|
function (error) {
|
||||||
|
if (error) {
|
||||||
|
setStatusMessage("Could not update speed forcing: " + error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
sendToActiveTab(
|
sendToActiveTab(
|
||||||
{
|
{
|
||||||
action: "set_force_last_saved_speed",
|
action: "set_force_last_saved_speed",
|
||||||
enabled: enabled,
|
enabled: enabled
|
||||||
speed: Number(storage.lastSpeed) || 1.0
|
|
||||||
},
|
},
|
||||||
function (response) {
|
function (response) {
|
||||||
|
var effectiveEnabled =
|
||||||
|
response && typeof response.enabled === "boolean"
|
||||||
|
? response.enabled
|
||||||
|
: enabled;
|
||||||
|
updateForceButton(effectiveEnabled);
|
||||||
|
if (effectiveEnabled !== enabled) {
|
||||||
|
setStatusMessage("Force setting is controlled by this site rule.");
|
||||||
|
if (response && response.speed != null) {
|
||||||
|
updateSpeedDisplay(response.speed);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (response && response.speed != null) {
|
if (response && response.speed != null) {
|
||||||
updateSpeedDisplay(response.speed);
|
updateSpeedDisplay(response.speed);
|
||||||
setStatusMessage(
|
setStatusMessage(
|
||||||
enabled ? "Saved speed is now forced." : "Speed forcing is off."
|
effectiveEnabled
|
||||||
|
? "Saved speed is now forced."
|
||||||
|
: "Speed forcing is off."
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
setStatusMessage(
|
setStatusMessage(
|
||||||
enabled
|
effectiveEnabled
|
||||||
? "Force enabled. No video found on this page."
|
? "Force enabled. No video found on this page."
|
||||||
: "Speed forcing is off."
|
: "Speed forcing is off."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
});
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderForActiveTab() {
|
function renderForActiveTab() {
|
||||||
var currentRenderToken = ++renderToken;
|
var currentRenderToken = ++renderToken;
|
||||||
|
forceLastSavedSpeedControlledBySiteRule = null;
|
||||||
|
selectedFrameToken = null;
|
||||||
|
setForceButtonLoading(true);
|
||||||
|
|
||||||
chrome.storage.local.get(["customButtonIcons"], function (loc) {
|
chrome.storage.local.get(["customButtonIcons"], function (loc) {
|
||||||
if (currentRenderToken !== renderToken) return;
|
if (currentRenderToken !== renderToken) return;
|
||||||
@@ -310,46 +555,102 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
? loc.customButtonIcons
|
? loc.customButtonIcons
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
chrome.storage.sync.get(storageDefaults, function (storage) {
|
chrome.storage.sync.get(null, function (rawStorage) {
|
||||||
if (currentRenderToken !== renderToken) return;
|
if (currentRenderToken !== renderToken) return;
|
||||||
|
var storage = vscExpandStoredSettings(rawStorage || {});
|
||||||
|
|
||||||
getActiveTabContext(function (context) {
|
getActiveTabContext(function (context) {
|
||||||
if (currentRenderToken !== renderToken) return;
|
if (currentRenderToken !== renderToken) return;
|
||||||
|
|
||||||
var url = context && context.url ? context.url : "";
|
var tabPaused = context && context.tabPaused === true;
|
||||||
var siteRule = matchSiteRule(url, storage.siteRules);
|
updateTabPauseButton(tabPaused);
|
||||||
var siteDisabled = isSiteRuleDisabled(siteRule);
|
|
||||||
var siteAvailable = siteRuleUtils.isSpeederActiveForSite(
|
|
||||||
storage.enabled,
|
|
||||||
siteRule
|
|
||||||
);
|
|
||||||
var showBar = storage.showPopupControlBar !== false;
|
|
||||||
|
|
||||||
if (siteRule && siteRule.showPopupControlBar !== undefined) {
|
var url = context && context.url ? context.url : "";
|
||||||
showBar = siteRule.showPopupControlBar;
|
var siteRule = matchSiteRule(url, storage.siteRules);
|
||||||
}
|
var siteDisabled = isSiteRuleDisabled(siteRule);
|
||||||
|
var siteAvailable = siteRuleUtils.isSpeederActiveForSite(
|
||||||
|
storage.enabled,
|
||||||
|
siteRule
|
||||||
|
);
|
||||||
|
shortcutTargetMode =
|
||||||
|
siteRule && siteRule.shortcutTargetMode !== undefined
|
||||||
|
? siteRule.shortcutTargetMode
|
||||||
|
: storage.shortcutTargetMode;
|
||||||
|
var showBar = storage.showPopupControlBar !== false;
|
||||||
|
forceLastSavedSpeedControlledBySiteRule = Boolean(
|
||||||
|
siteRule && siteRule.forceLastSavedSpeed !== undefined
|
||||||
|
);
|
||||||
|
var effectiveForceLastSavedSpeed =
|
||||||
|
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);
|
||||||
|
|
||||||
toggleEnabledUI(storage.enabled !== false);
|
if (siteRule && siteRule.showPopupControlBar !== undefined) {
|
||||||
updateForceButton(storage.forceLastSavedSpeed === true);
|
showBar = siteRule.showPopupControlBar;
|
||||||
buildControlBar(
|
diagnosticContext.showBar = showBar;
|
||||||
resolvePopupButtons(storage, siteRule),
|
}
|
||||||
customIconsMap
|
|
||||||
);
|
|
||||||
setControlBarVisible(siteAvailable && showBar);
|
|
||||||
|
|
||||||
if (siteDisabled) {
|
toggleEnabledUI(storage.enabled !== false);
|
||||||
setStatusMessage("Speeder is disabled for this site.");
|
updateForceButton(effectiveForceLastSavedSpeed);
|
||||||
updateSpeedDisplay(1);
|
buildControlBar(
|
||||||
return;
|
resolvePopupButtons(storage, siteRule),
|
||||||
}
|
customIconsMap
|
||||||
|
);
|
||||||
|
setControlBarVisible(!tabPaused && siteAvailable && showBar);
|
||||||
|
|
||||||
clearStatusMessage();
|
if (tabPaused) {
|
||||||
if (siteAvailable) {
|
setForceButtonLoading(true);
|
||||||
querySpeed();
|
setStatusMessage("Speeder is paused for this tab.");
|
||||||
} else {
|
return;
|
||||||
updateSpeedDisplay(1);
|
}
|
||||||
}
|
|
||||||
});
|
if (siteDisabled) {
|
||||||
|
setForceButtonLoading(false);
|
||||||
|
setStatusMessage("Speeder is disabled for this site.");
|
||||||
|
updateSpeedDisplay(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearStatusMessage();
|
||||||
|
if (siteAvailable) {
|
||||||
|
querySpeed(function(frameContext) {
|
||||||
|
if (currentRenderToken !== renderToken) return;
|
||||||
|
diagnosticContext.frame = frameContext || null;
|
||||||
|
if (
|
||||||
|
frameContext &&
|
||||||
|
typeof frameContext.forceLastSavedSpeed === "boolean"
|
||||||
|
) {
|
||||||
|
effectiveForceLastSavedSpeed =
|
||||||
|
frameContext.forceLastSavedSpeed;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
frameContext &&
|
||||||
|
typeof frameContext.forceLastSavedSpeedControlledBySiteRule ===
|
||||||
|
"boolean"
|
||||||
|
) {
|
||||||
|
forceLastSavedSpeedControlledBySiteRule =
|
||||||
|
frameContext.forceLastSavedSpeedControlledBySiteRule;
|
||||||
|
}
|
||||||
|
updateForceButton(effectiveForceLastSavedSpeed);
|
||||||
|
setForceButtonLoading(false);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
updateSpeedDisplay(1);
|
||||||
|
setForceButtonLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -378,7 +679,9 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
changes.controllerButtons ||
|
changes.controllerButtons ||
|
||||||
changes.popupMatchHoverControls ||
|
changes.popupMatchHoverControls ||
|
||||||
changes.popupControllerButtons ||
|
changes.popupControllerButtons ||
|
||||||
changes.siteRules
|
changes.siteRules ||
|
||||||
|
changes.siteRulesMeta ||
|
||||||
|
changes.siteRulesFormat
|
||||||
) {
|
) {
|
||||||
renderForActiveTab();
|
renderForActiveTab();
|
||||||
}
|
}
|
||||||
@@ -387,29 +690,29 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
renderForActiveTab();
|
renderForActiveTab();
|
||||||
|
|
||||||
function toggleEnabled(enabled, callback) {
|
function toggleEnabled(enabled, callback) {
|
||||||
chrome.storage.sync.set({ enabled: enabled }, function () {
|
updateStoredSettings(
|
||||||
toggleEnabledUI(enabled);
|
function (settings) {
|
||||||
if (callback) callback(enabled);
|
settings.enabled = enabled;
|
||||||
});
|
},
|
||||||
|
function (error) {
|
||||||
|
if (error) {
|
||||||
|
setStatusMessage("Could not update Speeder: " + error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toggleEnabledUI(enabled);
|
||||||
|
if (callback) callback(enabled);
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleEnabledUI(enabled) {
|
function toggleEnabledUI(enabled) {
|
||||||
document.querySelector("#enable").classList.toggle("hide", enabled);
|
document.querySelector("#enable").classList.toggle("hide", enabled);
|
||||||
document.querySelector("#disable").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) {
|
function settingsSavedReloadMessage(enabled) {
|
||||||
setStatusMessage(
|
setStatusMessage(
|
||||||
`${enabled ? "Enabled" : "Disabled"}. Reload page to see changes`
|
`${enabled ? "Enabled" : "Disabled"}. Open pages update automatically.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,11 +30,26 @@
|
|||||||
"rememberSpeed",
|
"rememberSpeed",
|
||||||
"showPopupControlBar",
|
"showPopupControlBar",
|
||||||
"siteRules",
|
"siteRules",
|
||||||
|
"siteRulesFormat",
|
||||||
|
"siteRulesMeta",
|
||||||
"speed",
|
"speed",
|
||||||
"startHidden",
|
"startHidden",
|
||||||
"subtitleNudgeAmount",
|
"subtitleNudgeAmount",
|
||||||
"subtitleNudgeEnabledByDefault",
|
"subtitleNudgeEnabledByDefault",
|
||||||
"subtitleNudgeInterval"
|
"subtitleNudgeInterval",
|
||||||
|
"resetSpeed",
|
||||||
|
"speedStep",
|
||||||
|
"fastSpeed",
|
||||||
|
"rewindTime",
|
||||||
|
"advanceTime",
|
||||||
|
"resetKeyCode",
|
||||||
|
"slowerKeyCode",
|
||||||
|
"fasterKeyCode",
|
||||||
|
"rewindKeyCode",
|
||||||
|
"advanceKeyCode",
|
||||||
|
"fastKeyCode",
|
||||||
|
"displayKeyCode",
|
||||||
|
"blacklist"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function isRecognizedRawSettingsObject(backup) {
|
function isRecognizedRawSettingsObject(backup) {
|
||||||
@@ -48,24 +63,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Local-only keys excluded from backup JSON. These are disposable caches
|
* Only user-authored local settings belong in backups. Disposable caches,
|
||||||
* (e.g. Lucide tags.json) that bloat exports and are refetched when needed.
|
* per-source speed history, and future runtime data stay installation-local.
|
||||||
* Keep in sync with options/lucide-client.js (LUCIDE_TAGS_CACHE_KEY + "At").
|
|
||||||
*/
|
*/
|
||||||
var localSettingsKeysOmittedFromExport = [
|
|
||||||
"lucideTagsCacheV1",
|
|
||||||
"lucideTagsCacheV1At"
|
|
||||||
];
|
|
||||||
|
|
||||||
function filterLocalSettingsForExport(local) {
|
function filterLocalSettingsForExport(local) {
|
||||||
if (!local || typeof local !== "object" || Array.isArray(local)) {
|
if (!local || typeof local !== "object" || Array.isArray(local)) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
var out = {};
|
var out = {};
|
||||||
for (var key in local) {
|
if (
|
||||||
if (!Object.prototype.hasOwnProperty.call(local, key)) continue;
|
local.customButtonIcons &&
|
||||||
if (localSettingsKeysOmittedFromExport.indexOf(key) !== -1) continue;
|
typeof local.customButtonIcons === "object" &&
|
||||||
out[key] = local[key];
|
!Array.isArray(local.customButtonIcons)
|
||||||
|
) {
|
||||||
|
out.customButtonIcons = local.customButtonIcons;
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -108,27 +119,64 @@
|
|||||||
function extractImportSettings(backup) {
|
function extractImportSettings(backup) {
|
||||||
var settingsToImport = null;
|
var settingsToImport = null;
|
||||||
var isWrappedBackup = false;
|
var isWrappedBackup = false;
|
||||||
|
var backupIsObject =
|
||||||
|
backup && typeof backup === "object" && !Array.isArray(backup);
|
||||||
|
var hasWrappedSettings =
|
||||||
|
backupIsObject &&
|
||||||
|
Object.prototype.hasOwnProperty.call(backup, "settings");
|
||||||
|
|
||||||
if (backup && backup.settings && typeof backup.settings === "object") {
|
if (hasWrappedSettings) {
|
||||||
|
if (
|
||||||
|
!backup.settings ||
|
||||||
|
typeof backup.settings !== "object" ||
|
||||||
|
Array.isArray(backup.settings)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
settingsToImport = backup.settings;
|
settingsToImport = backup.settings;
|
||||||
isWrappedBackup = true;
|
isWrappedBackup = true;
|
||||||
} else if (
|
} else if (backupIsObject && isRecognizedRawSettingsObject(backup)) {
|
||||||
backup &&
|
|
||||||
typeof backup === "object" &&
|
|
||||||
isRecognizedRawSettingsObject(backup)
|
|
||||||
) {
|
|
||||||
settingsToImport = backup;
|
settingsToImport = backup;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!settingsToImport) return null;
|
if (!settingsToImport) return null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
isWrappedBackup &&
|
||||||
|
Object.prototype.hasOwnProperty.call(backup, "localSettings") &&
|
||||||
|
(
|
||||||
|
!backup.localSettings ||
|
||||||
|
typeof backup.localSettings !== "object" ||
|
||||||
|
Array.isArray(backup.localSettings)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isWrappedBackup &&
|
||||||
|
backup.localSettings &&
|
||||||
|
Object.prototype.hasOwnProperty.call(
|
||||||
|
backup.localSettings,
|
||||||
|
"customButtonIcons"
|
||||||
|
) &&
|
||||||
|
(
|
||||||
|
!backup.localSettings.customButtonIcons ||
|
||||||
|
typeof backup.localSettings.customButtonIcons !== "object" ||
|
||||||
|
Array.isArray(backup.localSettings.customButtonIcons)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isWrappedBackup: isWrappedBackup,
|
isWrappedBackup: isWrappedBackup,
|
||||||
settings: settingsToImport,
|
settings: settingsToImport,
|
||||||
localSettings:
|
localSettings:
|
||||||
backup &&
|
backup &&
|
||||||
backup.localSettings &&
|
backup.localSettings &&
|
||||||
typeof backup.localSettings === "object"
|
typeof backup.localSettings === "object" &&
|
||||||
|
!Array.isArray(backup.localSettings)
|
||||||
? backup.localSettings
|
? backup.localSettings
|
||||||
: null
|
: null
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
root.SpeederShared = root.SpeederShared || {};
|
root.SpeederShared = root.SpeederShared || {};
|
||||||
root.SpeederShared.keyBindings = exports;
|
root.SpeederShared.keyBindings = exports;
|
||||||
})(typeof globalThis !== "undefined" ? globalThis : this, function() {
|
})(typeof globalThis !== "undefined" ? globalThis : this, function() {
|
||||||
|
var MIN_SPEED = 0.1;
|
||||||
|
var MAX_SPEED = 16;
|
||||||
|
|
||||||
function normalizeBindingKey(key) {
|
function normalizeBindingKey(key) {
|
||||||
if (typeof key !== "string" || key.length === 0) return null;
|
if (typeof key !== "string" || key.length === 0) return null;
|
||||||
if (key === "Spacebar") return " ";
|
if (key === "Spacebar") return " ";
|
||||||
@@ -112,11 +115,64 @@
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var defaultActionValues = {
|
||||||
|
slower: 0.1,
|
||||||
|
faster: 0.1,
|
||||||
|
rewind: 10,
|
||||||
|
advance: 10,
|
||||||
|
fast: 1.8,
|
||||||
|
louder: 0.1,
|
||||||
|
softer: 0.1
|
||||||
|
};
|
||||||
|
|
||||||
|
function getActionValueError(action, value) {
|
||||||
|
var numericValue = Number(value);
|
||||||
|
if (!Number.isFinite(numericValue)) return "must be a finite number";
|
||||||
|
if (
|
||||||
|
(action === "slower" || action === "faster") &&
|
||||||
|
(numericValue <= 0 || numericValue > MAX_SPEED)
|
||||||
|
) {
|
||||||
|
return "must be greater than 0 and no more than " + MAX_SPEED;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
action === "fast" &&
|
||||||
|
(numericValue < MIN_SPEED || numericValue > MAX_SPEED)
|
||||||
|
) {
|
||||||
|
return "must be between " + MIN_SPEED + " and " + MAX_SPEED;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(action === "rewind" || action === "advance") &&
|
||||||
|
numericValue < 0
|
||||||
|
) {
|
||||||
|
return "must be zero or greater";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(action === "louder" || action === "softer") &&
|
||||||
|
(numericValue <= 0 || numericValue > 1)
|
||||||
|
) {
|
||||||
|
return "must be greater than 0 and no more than 1";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeActionValue(action, value, fallback) {
|
||||||
|
if (!getActionValueError(action, value)) return Number(value);
|
||||||
|
if (!getActionValueError(action, fallback)) return Number(fallback);
|
||||||
|
if (Object.prototype.hasOwnProperty.call(defaultActionValues, action)) {
|
||||||
|
return defaultActionValues[action];
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
MAX_SPEED: MAX_SPEED,
|
||||||
|
MIN_SPEED: MIN_SPEED,
|
||||||
|
getActionValueError: getActionValueError,
|
||||||
getLegacyKeyCode: getLegacyKeyCode,
|
getLegacyKeyCode: getLegacyKeyCode,
|
||||||
inferBindingCode: inferBindingCode,
|
inferBindingCode: inferBindingCode,
|
||||||
legacyBindingKeyToCode: legacyBindingKeyToCode,
|
legacyBindingKeyToCode: legacyBindingKeyToCode,
|
||||||
legacyKeyCodeToCode: legacyKeyCodeToCode,
|
legacyKeyCodeToCode: legacyKeyCodeToCode,
|
||||||
normalizeBindingKey: normalizeBindingKey
|
normalizeBindingKey: normalizeBindingKey,
|
||||||
|
sanitizeActionValue: sanitizeActionValue
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -66,18 +66,92 @@
|
|||||||
function pickBestFrameSpeedResult(results) {
|
function pickBestFrameSpeedResult(results) {
|
||||||
if (!results || !results.length) return null;
|
if (!results || !results.length) return null;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof result.forceLastSavedSpeedControlledBySiteRule === "boolean"
|
||||||
|
) {
|
||||||
|
normalized.forceLastSavedSpeedControlledBySiteRule =
|
||||||
|
result.forceLastSavedSpeedControlledBySiteRule;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
var fallback = null;
|
var fallback = null;
|
||||||
for (var i = 0; i < results.length; i++) {
|
for (var i = 0; i < results.length; i++) {
|
||||||
var result = results[i];
|
var result = results[i];
|
||||||
if (!result || typeof result.speed !== "number") continue;
|
if (!result || typeof result.speed !== "number") continue;
|
||||||
if (result.preferred) return { speed: result.speed };
|
if (result.preferred) return normalizeResult(result);
|
||||||
if (!fallback) fallback = { speed: result.speed };
|
if (!fallback) fallback = normalizeResult(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
return fallback;
|
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 {
|
return {
|
||||||
|
buildDiagnosticReport: buildDiagnosticReport,
|
||||||
pickBestFrameSpeedResult: pickBestFrameSpeedResult,
|
pickBestFrameSpeedResult: pickBestFrameSpeedResult,
|
||||||
resolvePopupButtons: resolvePopupButtons,
|
resolvePopupButtons: resolvePopupButtons,
|
||||||
sanitizeButtonOrder: sanitizeButtonOrder
|
sanitizeButtonOrder: sanitizeButtonOrder
|
||||||
|
|||||||
+574
-132
@@ -1,32 +1,32 @@
|
|||||||
(function (global) {
|
(function (global) {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
var SITE_RULES_DIFF_FORMAT = "defaults-diff-v1";
|
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 DEFAULT_BUTTONS = ["rewind", "slower", "faster", "advance", "display"];
|
||||||
var SITE_RULE_OVERRIDE_KEYS = [
|
var LEGACY_SYNC_KEYS = [
|
||||||
"controllerLocation",
|
"resetSpeed",
|
||||||
"controllerMarginTop",
|
"speedStep",
|
||||||
"controllerMarginBottom",
|
"fastSpeed",
|
||||||
"startHidden",
|
"rewindTime",
|
||||||
"hideWithControls",
|
"advanceTime",
|
||||||
"hideWithControlsTimer",
|
"resetKeyCode",
|
||||||
"rememberSpeed",
|
"slowerKeyCode",
|
||||||
"forceLastSavedSpeed",
|
"fasterKeyCode",
|
||||||
"audioBoolean",
|
"rewindKeyCode",
|
||||||
"controllerOpacity",
|
"advanceKeyCode",
|
||||||
"enableSubtitleNudge",
|
"fastKeyCode",
|
||||||
"subtitleNudgeEnabledByDefault",
|
"displayKeyCode",
|
||||||
"subtitleNudgeInterval",
|
"blacklist"
|
||||||
"controllerButtons",
|
|
||||||
"showPopupControlBar",
|
|
||||||
"popupControllerButtons",
|
|
||||||
"shortcuts",
|
|
||||||
"preferredSpeed"
|
|
||||||
];
|
];
|
||||||
var DIFFABLE_OPTION_KEYS = [
|
var DIFFABLE_OPTION_KEYS = [
|
||||||
"rememberSpeed",
|
"rememberSpeed",
|
||||||
"forceLastSavedSpeed",
|
"forceLastSavedSpeed",
|
||||||
"audioBoolean",
|
"audioBoolean",
|
||||||
|
"showAmbientLoopControls",
|
||||||
"enabled",
|
"enabled",
|
||||||
"startHidden",
|
"startHidden",
|
||||||
"hideWithControls",
|
"hideWithControls",
|
||||||
@@ -34,7 +34,10 @@
|
|||||||
"controllerLocation",
|
"controllerLocation",
|
||||||
"controllerOpacity",
|
"controllerOpacity",
|
||||||
"controllerMarginTop",
|
"controllerMarginTop",
|
||||||
|
"controllerMarginRight",
|
||||||
"controllerMarginBottom",
|
"controllerMarginBottom",
|
||||||
|
"controllerMarginLeft",
|
||||||
|
"shortcutTargetMode",
|
||||||
"keyBindings",
|
"keyBindings",
|
||||||
"siteRules",
|
"siteRules",
|
||||||
"siteRulesMeta",
|
"siteRulesMeta",
|
||||||
@@ -48,16 +51,17 @@
|
|||||||
"subtitleNudgeInterval",
|
"subtitleNudgeInterval",
|
||||||
"subtitleNudgeAmount"
|
"subtitleNudgeAmount"
|
||||||
];
|
];
|
||||||
var MANAGED_SYNC_KEYS = DIFFABLE_OPTION_KEYS.concat([
|
var MANAGED_SYNC_KEYS = DIFFABLE_OPTION_KEYS.concat(
|
||||||
"hideWithYouTubeControls"
|
["hideWithYouTubeControls"],
|
||||||
]);
|
LEGACY_SYNC_KEYS
|
||||||
|
);
|
||||||
|
|
||||||
var DEFAULT_SETTINGS = {
|
var DEFAULT_SETTINGS = {
|
||||||
speed: 1.0,
|
speed: 1.0,
|
||||||
lastSpeed: 1.0,
|
lastSpeed: 1.0,
|
||||||
displayKeyCode: 86,
|
|
||||||
rememberSpeed: false,
|
rememberSpeed: false,
|
||||||
audioBoolean: false,
|
audioBoolean: false,
|
||||||
|
showAmbientLoopControls: false,
|
||||||
startHidden: false,
|
startHidden: false,
|
||||||
hideWithYouTubeControls: false,
|
hideWithYouTubeControls: false,
|
||||||
hideWithControls: false,
|
hideWithControls: false,
|
||||||
@@ -70,12 +74,11 @@
|
|||||||
controllerMarginRight: 0,
|
controllerMarginRight: 0,
|
||||||
controllerMarginBottom: 65,
|
controllerMarginBottom: 65,
|
||||||
controllerMarginLeft: 0,
|
controllerMarginLeft: 0,
|
||||||
|
shortcutTargetMode: "closest",
|
||||||
keyBindings: [
|
keyBindings: [
|
||||||
{
|
{
|
||||||
action: "display",
|
action: "display",
|
||||||
key: "V",
|
code: "KeyV",
|
||||||
keyCode: 86,
|
|
||||||
code: null,
|
|
||||||
disabled: false,
|
disabled: false,
|
||||||
value: 0,
|
value: 0,
|
||||||
force: false,
|
force: false,
|
||||||
@@ -83,9 +86,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
action: "move",
|
action: "move",
|
||||||
key: "P",
|
code: "KeyP",
|
||||||
keyCode: 80,
|
|
||||||
code: null,
|
|
||||||
disabled: false,
|
disabled: false,
|
||||||
value: 0,
|
value: 0,
|
||||||
force: false,
|
force: false,
|
||||||
@@ -93,9 +94,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
action: "slower",
|
action: "slower",
|
||||||
key: "S",
|
code: "KeyS",
|
||||||
keyCode: 83,
|
|
||||||
code: null,
|
|
||||||
disabled: false,
|
disabled: false,
|
||||||
value: 0.1,
|
value: 0.1,
|
||||||
force: false,
|
force: false,
|
||||||
@@ -103,9 +102,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
action: "faster",
|
action: "faster",
|
||||||
key: "D",
|
code: "KeyD",
|
||||||
keyCode: 68,
|
|
||||||
code: null,
|
|
||||||
disabled: false,
|
disabled: false,
|
||||||
value: 0.1,
|
value: 0.1,
|
||||||
force: false,
|
force: false,
|
||||||
@@ -113,9 +110,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
action: "rewind",
|
action: "rewind",
|
||||||
key: "Z",
|
code: "KeyZ",
|
||||||
keyCode: 90,
|
|
||||||
code: null,
|
|
||||||
disabled: false,
|
disabled: false,
|
||||||
value: 10,
|
value: 10,
|
||||||
force: false,
|
force: false,
|
||||||
@@ -123,9 +118,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
action: "advance",
|
action: "advance",
|
||||||
key: "X",
|
code: "KeyX",
|
||||||
keyCode: 88,
|
|
||||||
code: null,
|
|
||||||
disabled: false,
|
disabled: false,
|
||||||
value: 10,
|
value: 10,
|
||||||
force: false,
|
force: false,
|
||||||
@@ -133,9 +126,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
action: "reset",
|
action: "reset",
|
||||||
key: "R",
|
code: "KeyR",
|
||||||
keyCode: 82,
|
|
||||||
code: null,
|
|
||||||
disabled: false,
|
disabled: false,
|
||||||
value: 0,
|
value: 0,
|
||||||
force: false,
|
force: false,
|
||||||
@@ -143,9 +134,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
action: "fast",
|
action: "fast",
|
||||||
key: "G",
|
code: "KeyG",
|
||||||
keyCode: 71,
|
|
||||||
code: null,
|
|
||||||
disabled: false,
|
disabled: false,
|
||||||
value: 1.8,
|
value: 1.8,
|
||||||
force: false,
|
force: false,
|
||||||
@@ -153,9 +142,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
action: "toggleSubtitleNudge",
|
action: "toggleSubtitleNudge",
|
||||||
key: "N",
|
code: "KeyN",
|
||||||
keyCode: 78,
|
|
||||||
code: null,
|
|
||||||
disabled: false,
|
disabled: false,
|
||||||
value: 0,
|
value: 0,
|
||||||
force: false,
|
force: false,
|
||||||
@@ -164,17 +151,25 @@
|
|||||||
],
|
],
|
||||||
siteRules: [
|
siteRules: [
|
||||||
{
|
{
|
||||||
|
title: "YouTube videos",
|
||||||
pattern: "/^https:\\/\\/(www\\.)?youtube\\.com\\/(?!shorts\\/).*/",
|
pattern: "/^https:\\/\\/(www\\.)?youtube\\.com\\/(?!shorts\\/).*/",
|
||||||
enabled: true,
|
enabled: true
|
||||||
enableSubtitleNudge: true,
|
|
||||||
subtitleNudgeInterval: 50
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: "YouTube Shorts",
|
||||||
pattern: "/^https:\\/\\/(www\\.)?youtube\\.com\\/shorts\\/.*/",
|
pattern: "/^https:\\/\\/(www\\.)?youtube\\.com\\/shorts\\/.*/",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
rememberSpeed: true,
|
rememberSpeed: true,
|
||||||
controllerMarginTop: 60,
|
controllerMarginTop: 60,
|
||||||
controllerMarginBottom: 85
|
controllerMarginBottom: 85
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "YouTube Shorts (mobile)",
|
||||||
|
pattern: "/^https:\\/\\/m\\.youtube\\.com\\/shorts\\/.*/",
|
||||||
|
enabled: true,
|
||||||
|
rememberSpeed: true,
|
||||||
|
controllerMarginTop: 60,
|
||||||
|
controllerMarginBottom: 85
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
controllerButtons: DEFAULT_BUTTONS.slice(),
|
controllerButtons: DEFAULT_BUTTONS.slice(),
|
||||||
@@ -183,7 +178,7 @@
|
|||||||
popupControllerButtons: DEFAULT_BUTTONS.slice(),
|
popupControllerButtons: DEFAULT_BUTTONS.slice(),
|
||||||
enableSubtitleNudge: false,
|
enableSubtitleNudge: false,
|
||||||
subtitleNudgeEnabledByDefault: true,
|
subtitleNudgeEnabledByDefault: true,
|
||||||
subtitleNudgeInterval: 50,
|
subtitleNudgeInterval: 250,
|
||||||
subtitleNudgeAmount: 0.001
|
subtitleNudgeAmount: 0.001
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -305,7 +300,84 @@
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSiteRuleForDiff(rule, baseSettings) {
|
function coerceBoolean(value, fallback) {
|
||||||
|
if (value === true || value === false) return value;
|
||||||
|
if (value === "true" || value === 1 || value === "1") return true;
|
||||||
|
if (value === "false" || value === 0 || value === "0") return false;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampFiniteNumber(value, minimum, maximum, fallback) {
|
||||||
|
if (
|
||||||
|
value === null ||
|
||||||
|
typeof value === "boolean" ||
|
||||||
|
(typeof value === "string" && value.trim() === "") ||
|
||||||
|
(typeof value !== "number" && typeof value !== "string")
|
||||||
|
) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
var numericValue = Number(value);
|
||||||
|
if (!Number.isFinite(numericValue)) return fallback;
|
||||||
|
return Math.min(maximum, Math.max(minimum, numericValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
var SITE_RULE_BOOLEAN_KEYS = new Set([
|
||||||
|
"enabled",
|
||||||
|
"startHidden",
|
||||||
|
"hideWithControls",
|
||||||
|
"rememberSpeed",
|
||||||
|
"forceLastSavedSpeed",
|
||||||
|
"audioBoolean",
|
||||||
|
"showAmbientLoopControls",
|
||||||
|
"enableSubtitleNudge",
|
||||||
|
"subtitleNudgeEnabledByDefault",
|
||||||
|
"showPopupControlBar",
|
||||||
|
"popupMatchHoverControls"
|
||||||
|
]);
|
||||||
|
|
||||||
|
function normalizeSiteRuleValue(key, value) {
|
||||||
|
if (key === "title") {
|
||||||
|
if (typeof value !== "string") return undefined;
|
||||||
|
var title = value.trim();
|
||||||
|
return title || undefined;
|
||||||
|
}
|
||||||
|
if (SITE_RULE_BOOLEAN_KEYS.has(key)) {
|
||||||
|
return coerceBoolean(value, undefined);
|
||||||
|
}
|
||||||
|
if (key === "shortcutTargetMode") {
|
||||||
|
return value === "all" || value === "closest" ? value : undefined;
|
||||||
|
}
|
||||||
|
if (key === "hideWithControlsTimer") {
|
||||||
|
return clampFiniteNumber(value, 0.1, 15, undefined);
|
||||||
|
}
|
||||||
|
if (key === "controllerOpacity") {
|
||||||
|
return clampFiniteNumber(value, 0, 1, undefined);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
key === "controllerMarginTop" ||
|
||||||
|
key === "controllerMarginRight" ||
|
||||||
|
key === "controllerMarginBottom" ||
|
||||||
|
key === "controllerMarginLeft"
|
||||||
|
) {
|
||||||
|
return clampFiniteNumber(value, 0, 200, undefined);
|
||||||
|
}
|
||||||
|
if (key === "subtitleNudgeInterval") {
|
||||||
|
return clampFiniteNumber(value, 250, 1000, undefined);
|
||||||
|
}
|
||||||
|
if (key === "preferredSpeed") {
|
||||||
|
return clampFiniteNumber(value, MIN_SPEED, MAX_SPEED, undefined);
|
||||||
|
}
|
||||||
|
if (key === "shortcuts" && Array.isArray(value)) {
|
||||||
|
return sanitizeStoredBindingValues(value).map(function(shortcut) {
|
||||||
|
shortcut.disabled = coerceBoolean(shortcut.disabled, false);
|
||||||
|
shortcut.force = coerceBoolean(shortcut.force, false);
|
||||||
|
return shortcut;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return clonePlainData(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSiteRule(rule) {
|
||||||
if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
|
if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -316,89 +388,83 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
var normalized = { pattern: pattern };
|
var normalized = { pattern: pattern };
|
||||||
var baseEnabled = hasOwn(baseSettings, "enabled")
|
|
||||||
? Boolean(baseSettings.enabled)
|
|
||||||
: true;
|
|
||||||
var ruleEnabled = hasOwn(rule, "enabled")
|
|
||||||
? Boolean(rule.enabled)
|
|
||||||
: hasOwn(rule, "disableExtension")
|
|
||||||
? !Boolean(rule.disableExtension)
|
|
||||||
: baseEnabled;
|
|
||||||
|
|
||||||
if (!areComparableValuesEqual(ruleEnabled, baseEnabled)) {
|
|
||||||
normalized.enabled = ruleEnabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
SITE_RULE_OVERRIDE_KEYS.forEach(function (key) {
|
|
||||||
var baseValue = clonePlainData(baseSettings[key]);
|
|
||||||
var effectiveValue = hasOwn(rule, key)
|
|
||||||
? clonePlainData(rule[key])
|
|
||||||
: baseValue;
|
|
||||||
|
|
||||||
if (!areComparableValuesEqual(effectiveValue, baseValue)) {
|
|
||||||
normalized[key] = effectiveValue;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.keys(rule).forEach(function (key) {
|
Object.keys(rule).forEach(function (key) {
|
||||||
if (
|
if (
|
||||||
key === "pattern" ||
|
key === "pattern" ||
|
||||||
key === "enabled" ||
|
|
||||||
key === "disableExtension" ||
|
key === "disableExtension" ||
|
||||||
SITE_RULE_OVERRIDE_KEYS.indexOf(key) !== -1 ||
|
|
||||||
rule[key] === undefined
|
rule[key] === undefined
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
normalized[key] = clonePlainData(rule[key]);
|
var normalizedValue = normalizeSiteRuleValue(key, rule[key]);
|
||||||
|
if (normalizedValue !== undefined) {
|
||||||
|
normalized[key] = normalizedValue;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!hasOwn(normalized, "enabled") && hasOwn(rule, "disableExtension")) {
|
||||||
|
normalized.enabled = !coerceBoolean(rule.disableExtension, false);
|
||||||
|
}
|
||||||
|
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
function compressSiteRules(siteRules, baseSettings) {
|
function compressSiteRules(siteRules) {
|
||||||
if (!Array.isArray(siteRules)) {
|
if (!Array.isArray(siteRules)) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
var defaultRules = getDefaultSiteRules();
|
var defaultRules = getDefaultSiteRules();
|
||||||
var defaultRulesByPattern = getDefaultSiteRulesByPattern();
|
var defaultRulesByPattern = getDefaultSiteRulesByPattern();
|
||||||
var currentPatterns = new Set();
|
var claimedDefaultPatterns = new Set();
|
||||||
var exportRules = [];
|
var exportRules = [];
|
||||||
|
var removedDefaultRuleKeys = [];
|
||||||
|
|
||||||
siteRules.forEach(function (rule) {
|
siteRules.forEach(function (rule) {
|
||||||
if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
|
var normalizedRule = normalizeSiteRule(rule);
|
||||||
return;
|
if (!normalizedRule) return;
|
||||||
}
|
|
||||||
|
|
||||||
var pattern = typeof rule.pattern === "string" ? rule.pattern.trim() : "";
|
var pattern = normalizedRule.pattern;
|
||||||
if (pattern) {
|
var defaultRule = defaultRulesByPattern[pattern];
|
||||||
currentPatterns.add(pattern);
|
if (defaultRule && !claimedDefaultPatterns.has(pattern)) {
|
||||||
}
|
claimedDefaultPatterns.add(pattern);
|
||||||
|
var normalizedDefaultRule = normalizeSiteRule(defaultRule);
|
||||||
|
var patch = { pattern: pattern };
|
||||||
|
var removedKeys = [];
|
||||||
|
|
||||||
var normalizedRule = normalizeSiteRuleForDiff(rule, baseSettings);
|
Object.keys(normalizedRule).forEach(function (key) {
|
||||||
if (!normalizedRule || Object.keys(normalizedRule).length === 1) {
|
if (key === "pattern") return;
|
||||||
return;
|
if (
|
||||||
}
|
!hasOwn(normalizedDefaultRule, key) ||
|
||||||
|
!areComparableValuesEqual(
|
||||||
|
normalizedRule[key],
|
||||||
|
normalizedDefaultRule[key]
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
patch[key] = clonePlainData(normalizedRule[key]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
var defaultRule = pattern ? defaultRulesByPattern[pattern] : null;
|
Object.keys(normalizedDefaultRule).forEach(function (key) {
|
||||||
var normalizedDefaultRule = defaultRule
|
if (key === "pattern" || hasOwn(normalizedRule, key)) return;
|
||||||
? normalizeSiteRuleForDiff(defaultRule, baseSettings)
|
removedKeys.push(key);
|
||||||
: null;
|
});
|
||||||
if (normalizedDefaultRule) {
|
|
||||||
if (areComparableValuesEqual(normalizedRule, normalizedDefaultRule)) {
|
if (Object.keys(patch).length > 1) {
|
||||||
return;
|
exportRules.push(patch);
|
||||||
}
|
}
|
||||||
|
if (removedKeys.length > 0) {
|
||||||
var defaultRuleDiff = deepDiff(normalizedRule, normalizedDefaultRule);
|
removedDefaultRuleKeys.push({
|
||||||
if (defaultRuleDiff && Object.keys(defaultRuleDiff).length > 0) {
|
pattern: pattern,
|
||||||
defaultRuleDiff.pattern = pattern;
|
keys: removedKeys.sort()
|
||||||
exportRules.push(defaultRuleDiff);
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A custom pattern-only rule is meaningful: it can whitelist a site while
|
||||||
|
// global Speeder is disabled. Never discard it as an "empty" rule.
|
||||||
exportRules.push(normalizedRule);
|
exportRules.push(normalizedRule);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -407,7 +473,7 @@
|
|||||||
return rule && typeof rule.pattern === "string" ? rule.pattern : "";
|
return rule && typeof rule.pattern === "string" ? rule.pattern : "";
|
||||||
})
|
})
|
||||||
.filter(function (pattern) {
|
.filter(function (pattern) {
|
||||||
return pattern && !currentPatterns.has(pattern);
|
return pattern && !claimedDefaultPatterns.has(pattern);
|
||||||
});
|
});
|
||||||
|
|
||||||
var result = {};
|
var result = {};
|
||||||
@@ -415,17 +481,48 @@
|
|||||||
result.siteRules = exportRules;
|
result.siteRules = exportRules;
|
||||||
result.siteRulesFormat = SITE_RULES_DIFF_FORMAT;
|
result.siteRulesFormat = SITE_RULES_DIFF_FORMAT;
|
||||||
}
|
}
|
||||||
if (removedDefaultPatterns.length > 0) {
|
if (
|
||||||
result.siteRulesMeta = {
|
removedDefaultPatterns.length > 0 ||
|
||||||
removedDefaultPatterns: removedDefaultPatterns
|
removedDefaultRuleKeys.length > 0
|
||||||
};
|
) {
|
||||||
|
result.siteRulesMeta = {};
|
||||||
|
if (removedDefaultPatterns.length > 0) {
|
||||||
|
result.siteRulesMeta.removedDefaultPatterns = removedDefaultPatterns;
|
||||||
|
}
|
||||||
|
if (removedDefaultRuleKeys.length > 0) {
|
||||||
|
result.siteRulesMeta[REMOVED_DEFAULT_RULE_KEYS_META] =
|
||||||
|
removedDefaultRuleKeys;
|
||||||
|
}
|
||||||
result.siteRulesFormat = SITE_RULES_DIFF_FORMAT;
|
result.siteRulesFormat = SITE_RULES_DIFF_FORMAT;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function expandSiteRules(siteRules, siteRulesMeta) {
|
function getRemovedDefaultRuleKeysByPattern(siteRulesMeta) {
|
||||||
|
var byPattern = Object.create(null);
|
||||||
|
var entries =
|
||||||
|
siteRulesMeta &&
|
||||||
|
Array.isArray(siteRulesMeta[REMOVED_DEFAULT_RULE_KEYS_META])
|
||||||
|
? siteRulesMeta[REMOVED_DEFAULT_RULE_KEYS_META]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
entries.forEach(function (entry) {
|
||||||
|
if (
|
||||||
|
!entry ||
|
||||||
|
typeof entry.pattern !== "string" ||
|
||||||
|
!Array.isArray(entry.keys)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
byPattern[entry.pattern] = entry.keys.filter(function (key) {
|
||||||
|
return typeof key === "string" && key !== "pattern";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return byPattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandSiteRules(siteRules, siteRulesMeta, supportsRemovedKeys) {
|
||||||
var defaultRules = getDefaultSiteRules();
|
var defaultRules = getDefaultSiteRules();
|
||||||
var defaultRulesByPattern = getDefaultSiteRulesByPattern();
|
var defaultRulesByPattern = getDefaultSiteRulesByPattern();
|
||||||
if (defaultRules.length === 0) {
|
if (defaultRules.length === 0) {
|
||||||
@@ -437,7 +534,11 @@
|
|||||||
? siteRulesMeta.removedDefaultPatterns
|
? siteRulesMeta.removedDefaultPatterns
|
||||||
: []
|
: []
|
||||||
);
|
);
|
||||||
|
var removedKeysByPattern = supportsRemovedKeys
|
||||||
|
? getRemovedDefaultRuleKeysByPattern(siteRulesMeta)
|
||||||
|
: Object.create(null);
|
||||||
var modifiedDefaultRules = Object.create(null);
|
var modifiedDefaultRules = Object.create(null);
|
||||||
|
var claimedDefaultPatterns = new Set();
|
||||||
var customRules = [];
|
var customRules = [];
|
||||||
|
|
||||||
if (Array.isArray(siteRules)) {
|
if (Array.isArray(siteRules)) {
|
||||||
@@ -449,13 +550,16 @@
|
|||||||
var pattern = typeof rule.pattern === "string" ? rule.pattern.trim() : "";
|
var pattern = typeof rule.pattern === "string" ? rule.pattern.trim() : "";
|
||||||
if (
|
if (
|
||||||
pattern &&
|
pattern &&
|
||||||
Object.prototype.hasOwnProperty.call(defaultRulesByPattern, pattern)
|
Object.prototype.hasOwnProperty.call(defaultRulesByPattern, pattern) &&
|
||||||
|
!claimedDefaultPatterns.has(pattern)
|
||||||
) {
|
) {
|
||||||
modifiedDefaultRules[pattern] = clonePlainData(rule);
|
claimedDefaultPatterns.add(pattern);
|
||||||
|
modifiedDefaultRules[pattern] = normalizeSiteRule(rule);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
customRules.push(clonePlainData(rule));
|
var customRule = normalizeSiteRule(rule);
|
||||||
|
if (customRule) customRules.push(customRule);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,17 +572,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (modifiedDefaultRules[pattern]) {
|
if (modifiedDefaultRules[pattern]) {
|
||||||
mergedRules.push(
|
var mergedRule = Object.assign(
|
||||||
Object.assign(
|
{},
|
||||||
{},
|
clonePlainData(rule),
|
||||||
clonePlainData(rule),
|
clonePlainData(modifiedDefaultRules[pattern])
|
||||||
clonePlainData(modifiedDefaultRules[pattern])
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
(removedKeysByPattern[pattern] || []).forEach(function (key) {
|
||||||
|
delete mergedRule[key];
|
||||||
|
});
|
||||||
|
mergedRules.push(mergedRule);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
mergedRules.push(clonePlainData(rule));
|
var unmodifiedRule = clonePlainData(rule);
|
||||||
|
(removedKeysByPattern[pattern] || []).forEach(function (key) {
|
||||||
|
delete unmodifiedRule[key];
|
||||||
|
});
|
||||||
|
mergedRules.push(unmodifiedRule);
|
||||||
});
|
});
|
||||||
|
|
||||||
customRules.forEach(function (rule) {
|
customRules.forEach(function (rule) {
|
||||||
@@ -488,6 +598,49 @@
|
|||||||
return mergedRules;
|
return mergedRules;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function migrateLegacyFullSiteRules(siteRules) {
|
||||||
|
var rules = clonePlainData(siteRules) || [];
|
||||||
|
var defaults = getDefaultSiteRules();
|
||||||
|
var mobileShortsDefault = defaults[2];
|
||||||
|
if (!mobileShortsDefault || !mobileShortsDefault.pattern) return rules;
|
||||||
|
|
||||||
|
// Full-rule backups from versions before rule titles should receive the
|
||||||
|
// labels now attached to the built-ins. New saves use the sparse format,
|
||||||
|
// where an intentionally removed title is represented by a tombstone.
|
||||||
|
defaults.forEach(function(defaultRule) {
|
||||||
|
var matchingRule = rules.find(function(rule) {
|
||||||
|
return rule && rule.pattern === defaultRule.pattern;
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
matchingRule &&
|
||||||
|
!hasOwn(matchingRule, "title") &&
|
||||||
|
typeof defaultRule.title === "string"
|
||||||
|
) {
|
||||||
|
matchingRule.title = defaultRule.title;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var alreadyHasMobileRule = rules.some(function(rule) {
|
||||||
|
return rule && rule.pattern === mobileShortsDefault.pattern;
|
||||||
|
});
|
||||||
|
if (alreadyHasMobileRule) return rules;
|
||||||
|
|
||||||
|
// Older Options versions persisted the entire then-default list without a
|
||||||
|
// format marker and sometimes filled in extra false/default-valued fields.
|
||||||
|
// Presence of both known old default patterns is therefore the reliable
|
||||||
|
// signal; the mobile rule did not exist yet, so it could not have been
|
||||||
|
// intentionally removed from one of those legacy lists.
|
||||||
|
var hasOldDefaultPatterns = defaults.slice(0, 2).every(function(defaultRule) {
|
||||||
|
return rules.some(function(rule) {
|
||||||
|
return rule && rule.pattern === defaultRule.pattern;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (hasOldDefaultPatterns) {
|
||||||
|
rules.push(clonePlainData(mobileShortsDefault));
|
||||||
|
}
|
||||||
|
return rules;
|
||||||
|
}
|
||||||
|
|
||||||
function buildStoredSettingsDiff(currentSettings) {
|
function buildStoredSettingsDiff(currentSettings) {
|
||||||
var defaults = clonePlainData(DEFAULT_SETTINGS);
|
var defaults = clonePlainData(DEFAULT_SETTINGS);
|
||||||
var normalized = deepMergeDefaults(defaults, currentSettings || {});
|
var normalized = deepMergeDefaults(defaults, currentSettings || {});
|
||||||
@@ -526,25 +679,292 @@
|
|||||||
return diff;
|
return diff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function legacyKeyCodeToCode(keyCode) {
|
||||||
|
var numericCode = Number(keyCode);
|
||||||
|
if (!Number.isInteger(numericCode)) return null;
|
||||||
|
if (numericCode >= 48 && numericCode <= 57) {
|
||||||
|
return "Digit" + String.fromCharCode(numericCode);
|
||||||
|
}
|
||||||
|
if (numericCode >= 65 && numericCode <= 90) {
|
||||||
|
return "Key" + String.fromCharCode(numericCode);
|
||||||
|
}
|
||||||
|
if (numericCode >= 96 && numericCode <= 105) {
|
||||||
|
return "Numpad" + (numericCode - 96);
|
||||||
|
}
|
||||||
|
if (numericCode >= 112 && numericCode <= 123) {
|
||||||
|
return "F" + (numericCode - 111);
|
||||||
|
}
|
||||||
|
|
||||||
|
var keyCodeMap = {
|
||||||
|
32: "Space",
|
||||||
|
37: "ArrowLeft",
|
||||||
|
38: "ArrowUp",
|
||||||
|
39: "ArrowRight",
|
||||||
|
40: "ArrowDown",
|
||||||
|
59: "Semicolon",
|
||||||
|
61: "Equal",
|
||||||
|
106: "NumpadMultiply",
|
||||||
|
107: "NumpadAdd",
|
||||||
|
109: "NumpadSubtract",
|
||||||
|
110: "NumpadDecimal",
|
||||||
|
111: "NumpadDivide",
|
||||||
|
173: "Minus",
|
||||||
|
186: "Semicolon",
|
||||||
|
187: "Equal",
|
||||||
|
188: "Comma",
|
||||||
|
189: "Minus",
|
||||||
|
190: "Period",
|
||||||
|
191: "Slash",
|
||||||
|
192: "Backquote",
|
||||||
|
219: "BracketLeft",
|
||||||
|
220: "Backslash",
|
||||||
|
221: "BracketRight",
|
||||||
|
222: "Quote"
|
||||||
|
};
|
||||||
|
return keyCodeMap[numericCode] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finiteNumberOr(value, fallback) {
|
||||||
|
var numericValue = Number(value);
|
||||||
|
return Number.isFinite(numericValue) ? numericValue : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeStoredBindingValues(bindings) {
|
||||||
|
var defaultValues = Object.create(null);
|
||||||
|
DEFAULT_SETTINGS.keyBindings.forEach(function(binding) {
|
||||||
|
defaultValues[binding.action] = Number(binding.value) || 0;
|
||||||
|
});
|
||||||
|
defaultValues.louder = 0.1;
|
||||||
|
defaultValues.softer = 0.1;
|
||||||
|
|
||||||
|
return (Array.isArray(bindings) ? bindings : [])
|
||||||
|
.filter(function(binding) {
|
||||||
|
return (
|
||||||
|
isPlainObject(binding) &&
|
||||||
|
typeof binding.action === "string" &&
|
||||||
|
binding.action.length > 0
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map(function(binding) {
|
||||||
|
var normalized = clonePlainData(binding) || {};
|
||||||
|
var action = normalized.action;
|
||||||
|
var value = Number(normalized.value);
|
||||||
|
var invalid = !Number.isFinite(value);
|
||||||
|
if (action === "slower" || action === "faster") {
|
||||||
|
invalid = invalid || value <= 0 || value > MAX_SPEED;
|
||||||
|
} else if (action === "fast") {
|
||||||
|
invalid = invalid || value < MIN_SPEED || value > MAX_SPEED;
|
||||||
|
} else if (action === "rewind" || action === "advance") {
|
||||||
|
invalid = invalid || value < 0;
|
||||||
|
} else if (action === "louder" || action === "softer") {
|
||||||
|
invalid = invalid || value <= 0 || value > 1;
|
||||||
|
}
|
||||||
|
normalized.value = invalid
|
||||||
|
? (hasOwn(defaultValues, action) ? defaultValues[action] : 0)
|
||||||
|
: value;
|
||||||
|
normalized.disabled = coerceBoolean(normalized.disabled, false);
|
||||||
|
normalized.force = coerceBoolean(normalized.force, false);
|
||||||
|
return normalized;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasLegacyShortcutSettings(raw) {
|
||||||
|
return [
|
||||||
|
"speedStep",
|
||||||
|
"fastSpeed",
|
||||||
|
"rewindTime",
|
||||||
|
"advanceTime",
|
||||||
|
"resetKeyCode",
|
||||||
|
"slowerKeyCode",
|
||||||
|
"fasterKeyCode",
|
||||||
|
"rewindKeyCode",
|
||||||
|
"advanceKeyCode",
|
||||||
|
"fastKeyCode",
|
||||||
|
"displayKeyCode"
|
||||||
|
].some(function (key) {
|
||||||
|
return hasOwn(raw, key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateLegacyKeyBindings(raw) {
|
||||||
|
var bindings = clonePlainData(DEFAULT_SETTINGS.keyBindings);
|
||||||
|
var byAction = Object.create(null);
|
||||||
|
bindings.forEach(function (binding) {
|
||||||
|
byAction[binding.action] = binding;
|
||||||
|
});
|
||||||
|
|
||||||
|
var speedStep = finiteNumberOr(raw.speedStep, byAction.faster.value);
|
||||||
|
if (speedStep > 0) {
|
||||||
|
byAction.slower.value = speedStep;
|
||||||
|
byAction.faster.value = speedStep;
|
||||||
|
}
|
||||||
|
var rewindTime = finiteNumberOr(raw.rewindTime, byAction.rewind.value);
|
||||||
|
if (rewindTime >= 0) byAction.rewind.value = rewindTime;
|
||||||
|
var advanceTime = finiteNumberOr(raw.advanceTime, byAction.advance.value);
|
||||||
|
if (advanceTime >= 0) byAction.advance.value = advanceTime;
|
||||||
|
var fastSpeed = finiteNumberOr(raw.fastSpeed, byAction.fast.value);
|
||||||
|
if (fastSpeed > 0) byAction.fast.value = fastSpeed;
|
||||||
|
|
||||||
|
[
|
||||||
|
["display", "displayKeyCode"],
|
||||||
|
["reset", "resetKeyCode"],
|
||||||
|
["slower", "slowerKeyCode"],
|
||||||
|
["faster", "fasterKeyCode"],
|
||||||
|
["rewind", "rewindKeyCode"],
|
||||||
|
["advance", "advanceKeyCode"],
|
||||||
|
["fast", "fastKeyCode"]
|
||||||
|
].forEach(function (mapping) {
|
||||||
|
if (!hasOwn(raw, mapping[1])) return;
|
||||||
|
var code = legacyKeyCodeToCode(raw[mapping[1]]);
|
||||||
|
if (code) byAction[mapping[0]].code = code;
|
||||||
|
});
|
||||||
|
|
||||||
|
return bindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateLegacyBlacklist(raw) {
|
||||||
|
if (typeof raw.blacklist !== "string") return [];
|
||||||
|
return raw.blacklist
|
||||||
|
.split("\n")
|
||||||
|
.map(function (line) {
|
||||||
|
return line.trim();
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(function (pattern) {
|
||||||
|
return { pattern: pattern, enabled: false };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildManagedStorageMutation(currentStorage, currentSettings) {
|
||||||
|
var raw = isPlainObject(currentStorage) ? currentStorage : {};
|
||||||
|
var nextSettings = buildStoredSettingsDiff(currentSettings);
|
||||||
|
var removeKeys = [];
|
||||||
|
|
||||||
|
MANAGED_SYNC_KEYS.forEach(function (key) {
|
||||||
|
if (!hasOwn(nextSettings, key) && hasOwn(raw, key)) {
|
||||||
|
removeKeys.push(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
set: nextSettings,
|
||||||
|
remove: Array.from(new Set(removeKeys))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function expandStoredSettings(storage) {
|
function expandStoredSettings(storage) {
|
||||||
var raw = clonePlainData(storage) || {};
|
var raw = isPlainObject(storage) ? clonePlainData(storage) : {};
|
||||||
var expanded = deepMergeDefaults(DEFAULT_SETTINGS, raw);
|
var expanded = deepMergeDefaults(DEFAULT_SETTINGS, raw);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!hasOwn(raw, "hideWithControls") &&
|
!hasOwn(raw, "hideWithControls") &&
|
||||||
hasOwn(raw, "hideWithYouTubeControls")
|
hasOwn(raw, "hideWithYouTubeControls")
|
||||||
) {
|
) {
|
||||||
expanded.hideWithControls = Boolean(raw.hideWithYouTubeControls);
|
expanded.hideWithControls = coerceBoolean(
|
||||||
|
raw.hideWithYouTubeControls,
|
||||||
|
DEFAULT_SETTINGS.hideWithControls
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[
|
||||||
|
"rememberSpeed",
|
||||||
|
"forceLastSavedSpeed",
|
||||||
|
"audioBoolean",
|
||||||
|
"showAmbientLoopControls",
|
||||||
|
"enabled",
|
||||||
|
"startHidden",
|
||||||
|
"hideWithControls",
|
||||||
|
"showPopupControlBar",
|
||||||
|
"popupMatchHoverControls",
|
||||||
|
"enableSubtitleNudge",
|
||||||
|
"subtitleNudgeEnabledByDefault"
|
||||||
|
].forEach(function(key) {
|
||||||
|
expanded[key] = coerceBoolean(expanded[key], DEFAULT_SETTINGS[key]);
|
||||||
|
});
|
||||||
expanded.hideWithYouTubeControls = expanded.hideWithControls;
|
expanded.hideWithYouTubeControls = expanded.hideWithControls;
|
||||||
|
expanded.shortcutTargetMode =
|
||||||
|
expanded.shortcutTargetMode === "all" ? "all" : "closest";
|
||||||
|
|
||||||
|
expanded.lastSpeed = clampFiniteNumber(
|
||||||
|
expanded.lastSpeed,
|
||||||
|
MIN_SPEED,
|
||||||
|
MAX_SPEED,
|
||||||
|
DEFAULT_SETTINGS.lastSpeed
|
||||||
|
);
|
||||||
|
expanded.hideWithControlsTimer = clampFiniteNumber(
|
||||||
|
expanded.hideWithControlsTimer,
|
||||||
|
0.1,
|
||||||
|
15,
|
||||||
|
DEFAULT_SETTINGS.hideWithControlsTimer
|
||||||
|
);
|
||||||
|
expanded.controllerOpacity = clampFiniteNumber(
|
||||||
|
expanded.controllerOpacity,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
DEFAULT_SETTINGS.controllerOpacity
|
||||||
|
);
|
||||||
|
[
|
||||||
|
"controllerMarginTop",
|
||||||
|
"controllerMarginRight",
|
||||||
|
"controllerMarginBottom",
|
||||||
|
"controllerMarginLeft"
|
||||||
|
].forEach(function(key) {
|
||||||
|
expanded[key] = clampFiniteNumber(
|
||||||
|
expanded[key],
|
||||||
|
0,
|
||||||
|
200,
|
||||||
|
DEFAULT_SETTINGS[key]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expanded.subtitleNudgeInterval = clampFiniteNumber(
|
||||||
|
expanded.subtitleNudgeInterval,
|
||||||
|
250,
|
||||||
|
1000,
|
||||||
|
DEFAULT_SETTINGS.subtitleNudgeInterval
|
||||||
|
);
|
||||||
|
expanded.subtitleNudgeAmount = clampFiniteNumber(
|
||||||
|
expanded.subtitleNudgeAmount,
|
||||||
|
0.000001,
|
||||||
|
0.1,
|
||||||
|
DEFAULT_SETTINGS.subtitleNudgeAmount
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Array.isArray(raw.keyBindings) && raw.keyBindings.length > 0) {
|
||||||
|
expanded.keyBindings = clonePlainData(raw.keyBindings);
|
||||||
|
} else if (hasLegacyShortcutSettings(raw)) {
|
||||||
|
expanded.keyBindings = migrateLegacyKeyBindings(raw);
|
||||||
|
} else {
|
||||||
|
expanded.keyBindings = clonePlainData(DEFAULT_SETTINGS.keyBindings);
|
||||||
|
}
|
||||||
|
expanded.keyBindings = sanitizeStoredBindingValues(expanded.keyBindings);
|
||||||
|
if (expanded.keyBindings.length === 0) {
|
||||||
|
expanded.keyBindings = clonePlainData(DEFAULT_SETTINGS.keyBindings);
|
||||||
|
}
|
||||||
|
|
||||||
if (raw.siteRulesFormat === SITE_RULES_DIFF_FORMAT) {
|
if (raw.siteRulesFormat === SITE_RULES_DIFF_FORMAT) {
|
||||||
expanded.siteRules = expandSiteRules(raw.siteRules, raw.siteRulesMeta);
|
expanded.siteRules = expandSiteRules(
|
||||||
} else if (Array.isArray(raw.siteRules)) {
|
raw.siteRules,
|
||||||
expanded.siteRules = clonePlainData(raw.siteRules);
|
raw.siteRulesMeta,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
} else if (raw.siteRulesFormat === LEGACY_SITE_RULES_DIFF_FORMAT) {
|
||||||
|
expanded.siteRules = expandSiteRules(
|
||||||
|
raw.siteRules,
|
||||||
|
raw.siteRulesMeta,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
} else if (hasOwn(raw, "siteRules") && Array.isArray(raw.siteRules)) {
|
||||||
|
expanded.siteRules = migrateLegacyFullSiteRules(raw.siteRules);
|
||||||
|
} else if (hasOwn(raw, "blacklist")) {
|
||||||
|
expanded.siteRules = getDefaultSiteRules().concat(
|
||||||
|
migrateLegacyBlacklist(raw)
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
expanded.siteRules = getDefaultSiteRules();
|
expanded.siteRules = getDefaultSiteRules();
|
||||||
}
|
}
|
||||||
|
expanded.siteRules = expanded.siteRules
|
||||||
|
.map(normalizeSiteRule)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
return expanded;
|
return expanded;
|
||||||
}
|
}
|
||||||
@@ -565,6 +985,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
var regex;
|
var regex;
|
||||||
|
if (pattern.startsWith("/") && pattern.lastIndexOf("/") === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (pattern.startsWith("/") && pattern.lastIndexOf("/") > 0) {
|
if (pattern.startsWith("/") && pattern.lastIndexOf("/") > 0) {
|
||||||
try {
|
try {
|
||||||
var lastSlash = pattern.lastIndexOf("/");
|
var lastSlash = pattern.lastIndexOf("/");
|
||||||
@@ -631,6 +1054,7 @@
|
|||||||
global.vscAreComparableValuesEqual = areComparableValuesEqual;
|
global.vscAreComparableValuesEqual = areComparableValuesEqual;
|
||||||
global.vscDeepMergeDefaults = deepMergeDefaults;
|
global.vscDeepMergeDefaults = deepMergeDefaults;
|
||||||
global.vscBuildStoredSettingsDiff = buildStoredSettingsDiff;
|
global.vscBuildStoredSettingsDiff = buildStoredSettingsDiff;
|
||||||
|
global.vscBuildManagedStorageMutation = buildManagedStorageMutation;
|
||||||
global.vscExpandStoredSettings = expandStoredSettings;
|
global.vscExpandStoredSettings = expandStoredSettings;
|
||||||
global.vscGetSettingsDefaults = function () {
|
global.vscGetSettingsDefaults = function () {
|
||||||
return clonePlainData(DEFAULT_SETTINGS);
|
return clonePlainData(DEFAULT_SETTINGS);
|
||||||
@@ -641,7 +1065,25 @@
|
|||||||
global.vscGetSiteRulesDiffFormat = function () {
|
global.vscGetSiteRulesDiffFormat = function () {
|
||||||
return SITE_RULES_DIFF_FORMAT;
|
return SITE_RULES_DIFF_FORMAT;
|
||||||
};
|
};
|
||||||
global.vscMatchSiteRule = mergeMatchingSiteRules;
|
global.vscGetLegacySiteRulesDiffFormat = function () {
|
||||||
global.vscSiteRuleMatchesUrl = siteRuleMatchesUrl;
|
return LEGACY_SITE_RULES_DIFF_FORMAT;
|
||||||
global.vscIsSiteRuleDisabled = isSiteRuleDisabled;
|
};
|
||||||
|
global.vscMatchSiteRule = function (url, rules) {
|
||||||
|
var shared = global.SpeederShared && global.SpeederShared.siteRules;
|
||||||
|
return shared && typeof shared.matchSiteRule === "function"
|
||||||
|
? shared.matchSiteRule(url, rules)
|
||||||
|
: mergeMatchingSiteRules(url, rules);
|
||||||
|
};
|
||||||
|
global.vscSiteRuleMatchesUrl = function (rule, url) {
|
||||||
|
var shared = global.SpeederShared && global.SpeederShared.siteRules;
|
||||||
|
return shared && typeof shared.siteRuleMatchesUrl === "function"
|
||||||
|
? shared.siteRuleMatchesUrl(rule, url)
|
||||||
|
: siteRuleMatchesUrl(rule, url);
|
||||||
|
};
|
||||||
|
global.vscIsSiteRuleDisabled = function (rule) {
|
||||||
|
var shared = global.SpeederShared && global.SpeederShared.siteRules;
|
||||||
|
return shared && typeof shared.isSiteRuleDisabled === "function"
|
||||||
|
? shared.isSiteRuleDisabled(rule)
|
||||||
|
: isSiteRuleDisabled(rule);
|
||||||
|
};
|
||||||
})(typeof globalThis !== "undefined" ? globalThis : this);
|
})(typeof globalThis !== "undefined" ? globalThis : this);
|
||||||
|
|||||||
+248
-14
@@ -14,49 +14,280 @@
|
|||||||
return String(str).replace(/[|\\{}()[\]^$+*?.]/g, "\\$&");
|
return String(str).replace(/[|\\{}()[\]^$+*?.]/g, "\\$&");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decode valid percent-encoded runs without letting one malformed escape make
|
||||||
|
// the entire URL unusable. This also preserves incomplete UTF-8 bytes instead
|
||||||
|
// of replacing them with lossy placeholder characters.
|
||||||
|
function safelyDecodeUrlText(value) {
|
||||||
|
return String(value).replace(/(?:%[\da-f]{2})+/gi, function(encodedRun) {
|
||||||
|
var decoded = "";
|
||||||
|
var tripletCount = encodedRun.length / 3;
|
||||||
|
var tripletIndex = 0;
|
||||||
|
|
||||||
|
while (tripletIndex < tripletCount) {
|
||||||
|
var tripletOffset = tripletIndex * 3;
|
||||||
|
var firstByte = parseInt(
|
||||||
|
encodedRun.substring(tripletOffset + 1, tripletOffset + 3),
|
||||||
|
16
|
||||||
|
);
|
||||||
|
var sequenceLength = firstByte <= 0x7f
|
||||||
|
? 1
|
||||||
|
: firstByte >= 0xc2 && firstByte <= 0xdf
|
||||||
|
? 2
|
||||||
|
: firstByte >= 0xe0 && firstByte <= 0xef
|
||||||
|
? 3
|
||||||
|
: firstByte >= 0xf0 && firstByte <= 0xf4
|
||||||
|
? 4
|
||||||
|
: 0;
|
||||||
|
var encodedSequence = sequenceLength
|
||||||
|
? encodedRun.substring(
|
||||||
|
tripletOffset,
|
||||||
|
tripletOffset + sequenceLength * 3
|
||||||
|
)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
if (
|
||||||
|
sequenceLength &&
|
||||||
|
tripletIndex + sequenceLength <= tripletCount
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
decoded += decodeURIComponent(encodedSequence);
|
||||||
|
tripletIndex += sequenceLength;
|
||||||
|
continue;
|
||||||
|
} catch (_error) {
|
||||||
|
// Preserve this byte below, then retry at the next triplet so a
|
||||||
|
// later valid character in the same run can still be decoded.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded += encodedRun.substring(tripletOffset, tripletOffset + 3);
|
||||||
|
tripletIndex += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoded;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodedUrlMatchCandidate(url) {
|
||||||
|
var parsedUrl;
|
||||||
|
try {
|
||||||
|
parsedUrl = new URL(String(url));
|
||||||
|
} catch (_error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var authority = "";
|
||||||
|
if (parsedUrl.host || parsedUrl.protocol === "file:") {
|
||||||
|
authority = "//";
|
||||||
|
if (parsedUrl.username) {
|
||||||
|
authority += parsedUrl.username;
|
||||||
|
if (parsedUrl.password) authority += ":" + parsedUrl.password;
|
||||||
|
authority += "@";
|
||||||
|
}
|
||||||
|
authority += parsedUrl.host;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
parsedUrl.protocol +
|
||||||
|
authority +
|
||||||
|
safelyDecodeUrlText(parsedUrl.pathname || "") +
|
||||||
|
(parsedUrl.search
|
||||||
|
? "?" + safelyDecodeUrlText(parsedUrl.search.substring(1))
|
||||||
|
: "") +
|
||||||
|
(parsedUrl.hash
|
||||||
|
? "#" + safelyDecodeUrlText(parsedUrl.hash.substring(1))
|
||||||
|
: "")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePlainSitePattern(pattern) {
|
||||||
|
var normalized = String(pattern).replace(regStrip, "");
|
||||||
|
if (!normalized) return null;
|
||||||
|
|
||||||
|
var hasScheme = /^[a-z][a-z\d+.-]*:\/\//i.test(normalized);
|
||||||
|
var wildcardSubdomains = false;
|
||||||
|
var parsed;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (hasScheme) {
|
||||||
|
parsed = new URL(normalized);
|
||||||
|
} else {
|
||||||
|
var slashIndex = normalized.indexOf("/");
|
||||||
|
var authority = slashIndex === -1
|
||||||
|
? normalized
|
||||||
|
: normalized.substring(0, slashIndex);
|
||||||
|
var path = slashIndex === -1 ? "" : normalized.substring(slashIndex);
|
||||||
|
if (authority.indexOf("@") !== -1 || /[?#]/.test(authority)) return null;
|
||||||
|
if (authority.indexOf("*.") === 0) {
|
||||||
|
wildcardSubdomains = true;
|
||||||
|
authority = authority.substring(2);
|
||||||
|
}
|
||||||
|
parsed = new URL("http://" + authority + path);
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hostname = String(parsed.hostname || "").toLowerCase().replace(/\.$/, "");
|
||||||
|
if (!hostname && parsed.protocol !== "file:") return null;
|
||||||
|
|
||||||
|
var pathPrefix = parsed.pathname || "";
|
||||||
|
if (!hasScheme && pathPrefix === "/") pathPrefix = "";
|
||||||
|
if (parsed.search) pathPrefix += parsed.search;
|
||||||
|
if (parsed.hash) pathPrefix += parsed.hash;
|
||||||
|
|
||||||
|
return {
|
||||||
|
protocol: hasScheme ? parsed.protocol : null,
|
||||||
|
hostname: hostname,
|
||||||
|
port: parsed.port || "",
|
||||||
|
pathPrefix: pathPrefix,
|
||||||
|
wildcardSubdomains: wildcardSubdomains
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function plainSitePatternMatchesUrl(pattern, url) {
|
||||||
|
var parsedPattern = parsePlainSitePattern(pattern);
|
||||||
|
if (!parsedPattern) return false;
|
||||||
|
|
||||||
|
var parsedUrl;
|
||||||
|
try {
|
||||||
|
parsedUrl = new URL(String(url));
|
||||||
|
} catch (_error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedPattern.protocol && parsedPattern.protocol !== parsedUrl.protocol) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (parsedPattern.port && parsedPattern.port !== parsedUrl.port) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentHost = String(parsedUrl.hostname || "").toLowerCase().replace(/\.$/, "");
|
||||||
|
var hostMatches =
|
||||||
|
currentHost === parsedPattern.hostname ||
|
||||||
|
currentHost.endsWith("." + parsedPattern.hostname);
|
||||||
|
if (!hostMatches) return false;
|
||||||
|
if (
|
||||||
|
parsedPattern.wildcardSubdomains &&
|
||||||
|
currentHost === parsedPattern.hostname
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedPattern.pathPrefix) {
|
||||||
|
var currentPath =
|
||||||
|
(parsedUrl.pathname || "/") +
|
||||||
|
(parsedUrl.search || "") +
|
||||||
|
(parsedUrl.hash || "");
|
||||||
|
var decodedPatternPath = safelyDecodeUrlText(parsedPattern.pathPrefix);
|
||||||
|
if (
|
||||||
|
!currentPath.startsWith(parsedPattern.pathPrefix) &&
|
||||||
|
!safelyDecodeUrlText(currentPath).startsWith(decodedPatternPath)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function compileSiteRulePattern(pattern) {
|
function compileSiteRulePattern(pattern) {
|
||||||
if (typeof pattern !== "string") return null;
|
if (typeof pattern !== "string") return null;
|
||||||
|
|
||||||
var normalizedPattern = pattern.replace(regStrip, "");
|
var normalizedPattern = pattern.replace(regStrip, "");
|
||||||
if (normalizedPattern.length === 0) return null;
|
if (normalizedPattern.length === 0) return null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalizedPattern.startsWith("/") &&
|
||||||
|
normalizedPattern.lastIndexOf("/") === 0
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
normalizedPattern.startsWith("/") &&
|
normalizedPattern.startsWith("/") &&
|
||||||
normalizedPattern.lastIndexOf("/") > 0
|
normalizedPattern.lastIndexOf("/") > 0
|
||||||
) {
|
) {
|
||||||
var lastSlash = normalizedPattern.lastIndexOf("/");
|
var lastSlash = normalizedPattern.lastIndexOf("/");
|
||||||
return new RegExp(
|
var regularExpression = new RegExp(
|
||||||
normalizedPattern.substring(1, lastSlash),
|
normalizedPattern.substring(1, lastSlash),
|
||||||
normalizedPattern.substring(lastSlash + 1)
|
normalizedPattern.substring(lastSlash + 1)
|
||||||
);
|
);
|
||||||
|
return {
|
||||||
|
test: function(url) {
|
||||||
|
var rawUrl = String(url);
|
||||||
|
var decodedUrl = decodedUrlMatchCandidate(rawUrl);
|
||||||
|
var candidates = decodedUrl && decodedUrl !== rawUrl
|
||||||
|
? [rawUrl, decodedUrl]
|
||||||
|
: [rawUrl];
|
||||||
|
|
||||||
|
for (var i = 0; i < candidates.length; i++) {
|
||||||
|
// Global and sticky expressions retain state between test() calls.
|
||||||
|
// Site matching must be deterministic across navigation checks.
|
||||||
|
regularExpression.lastIndex = 0;
|
||||||
|
if (regularExpression.test(candidates[i])) {
|
||||||
|
regularExpression.lastIndex = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
regularExpression.lastIndex = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return new RegExp(escapeStringRegExp(normalizedPattern));
|
return {
|
||||||
|
test: function(url) {
|
||||||
|
return plainSitePatternMatchesUrl(normalizedPattern, url);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function siteRuleMatchesUrl(rule, url) {
|
||||||
|
if (!rule || !rule.pattern || !url) return false;
|
||||||
|
try {
|
||||||
|
var matcher = compileSiteRulePattern(rule.pattern);
|
||||||
|
return Boolean(matcher && matcher.test(url));
|
||||||
|
} catch (_error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneRuleValue(value) {
|
||||||
|
if (!value || typeof value !== "object") return value;
|
||||||
|
return JSON.parse(JSON.stringify(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchSiteRule(url, siteRules) {
|
function matchSiteRule(url, siteRules) {
|
||||||
if (!url || !Array.isArray(siteRules)) return null;
|
if (!url || !Array.isArray(siteRules)) return null;
|
||||||
|
|
||||||
|
var matches = [];
|
||||||
for (var i = 0; i < siteRules.length; i++) {
|
for (var i = 0; i < siteRules.length; i++) {
|
||||||
var rule = siteRules[i];
|
var rule = siteRules[i];
|
||||||
if (!rule || !rule.pattern) continue;
|
if (siteRuleMatchesUrl(rule, url)) matches.push(rule);
|
||||||
|
|
||||||
try {
|
|
||||||
var re = compileSiteRulePattern(rule.pattern);
|
|
||||||
if (re && re.test(url)) {
|
|
||||||
return rule;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
if (matches.length === 0) return null;
|
||||||
|
if (matches.length === 1) return matches[0];
|
||||||
|
|
||||||
|
var mergedRule = {};
|
||||||
|
matches.forEach(function(rule) {
|
||||||
|
Object.keys(rule).forEach(function(key) {
|
||||||
|
if (rule[key] !== undefined) {
|
||||||
|
mergedRule[key] = cloneRuleValue(rule[key]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return mergedRule;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSiteRuleDisabled(rule) {
|
function isSiteRuleDisabled(rule) {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
rule &&
|
rule &&
|
||||||
(rule.enabled === false || rule.disableExtension === true)
|
(
|
||||||
|
rule.enabled === false ||
|
||||||
|
(typeof rule.enabled === "undefined" && rule.disableExtension === true)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +313,9 @@
|
|||||||
escapeStringRegExp: escapeStringRegExp,
|
escapeStringRegExp: escapeStringRegExp,
|
||||||
isSiteRuleDisabled: isSiteRuleDisabled,
|
isSiteRuleDisabled: isSiteRuleDisabled,
|
||||||
isSpeederActiveForSite: isSpeederActiveForSite,
|
isSpeederActiveForSite: isSpeederActiveForSite,
|
||||||
matchSiteRule: matchSiteRule
|
matchSiteRule: matchSiteRule,
|
||||||
|
plainSitePatternMatchesUrl: plainSitePatternMatchesUrl,
|
||||||
|
safelyDecodeUrlText: safelyDecodeUrlText,
|
||||||
|
siteRuleMatchesUrl: siteRuleMatchesUrl
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,9 +17,9 @@ function readRepoFile(relPath) {
|
|||||||
* Parse HTML into a fresh JSDOM document so tests can reload scripts without
|
* Parse HTML into a fresh JSDOM document so tests can reload scripts without
|
||||||
* top-level `const` redeclaration errors (avoids document.write).
|
* top-level `const` redeclaration errors (avoids document.write).
|
||||||
*/
|
*/
|
||||||
export function loadHtmlString(html) {
|
export function loadHtmlString(html, options = {}) {
|
||||||
const dom = new JSDOM(html, {
|
const dom = new JSDOM(html, {
|
||||||
url: "https://example.org/",
|
url: options.url || "https://example.org/",
|
||||||
pretendToBeVisual: true,
|
pretendToBeVisual: true,
|
||||||
runScripts: "dangerously"
|
runScripts: "dangerously"
|
||||||
});
|
});
|
||||||
@@ -173,7 +173,13 @@ export function createChromeMock(options = {}) {
|
|||||||
getManifest: vi.fn(() => ({
|
getManifest: vi.fn(() => ({
|
||||||
version: options.manifestVersion || "9.9.9"
|
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: {
|
storage: {
|
||||||
sync: syncArea,
|
sync: syncArea,
|
||||||
|
|||||||
@@ -42,11 +42,13 @@ function applyJSDOMWindow(win) {
|
|||||||
win.Date = globalThis.Date;
|
win.Date = globalThis.Date;
|
||||||
win.open = vi.fn();
|
win.open = vi.fn();
|
||||||
win.close = vi.fn();
|
win.close = vi.fn();
|
||||||
|
win.confirm = vi.fn(() => true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadHtmlString(html) {
|
function loadHtmlString(html, options) {
|
||||||
|
const config = options || {};
|
||||||
const dom = new JSDOM(html, {
|
const dom = new JSDOM(html, {
|
||||||
url: "https://example.org/",
|
url: config.url || "https://example.org/",
|
||||||
pretendToBeVisual: true,
|
pretendToBeVisual: true,
|
||||||
runScripts: "dangerously"
|
runScripts: "dangerously"
|
||||||
});
|
});
|
||||||
@@ -243,6 +245,7 @@ function createChromeMock(options) {
|
|||||||
const storageOnChanged = createChromeEvent();
|
const storageOnChanged = createChromeEvent();
|
||||||
const tabsOnActivated = createChromeEvent();
|
const tabsOnActivated = createChromeEvent();
|
||||||
const tabsOnUpdated = createChromeEvent();
|
const tabsOnUpdated = createChromeEvent();
|
||||||
|
const tabsOnRemoved = createChromeEvent();
|
||||||
const runtimeOnMessage = createChromeEvent();
|
const runtimeOnMessage = createChromeEvent();
|
||||||
|
|
||||||
const chrome = {
|
const chrome = {
|
||||||
@@ -250,6 +253,12 @@ function createChromeMock(options) {
|
|||||||
lastError: null,
|
lastError: null,
|
||||||
getManifest: vi.fn(() => clone(config.manifest) || { version: "0.0.0-test" }),
|
getManifest: vi.fn(() => clone(config.manifest) || { version: "0.0.0-test" }),
|
||||||
getURL: vi.fn((relPath) => `moz-extension://${relPath}`),
|
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
|
onMessage: runtimeOnMessage
|
||||||
},
|
},
|
||||||
browserAction: {
|
browserAction: {
|
||||||
@@ -270,7 +279,8 @@ function createChromeMock(options) {
|
|||||||
}),
|
}),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
onActivated: tabsOnActivated,
|
onActivated: tabsOnActivated,
|
||||||
onUpdated: tabsOnUpdated
|
onUpdated: tabsOnUpdated,
|
||||||
|
onRemoved: tabsOnRemoved
|
||||||
},
|
},
|
||||||
storage: {
|
storage: {
|
||||||
onChanged: storageOnChanged,
|
onChanged: storageOnChanged,
|
||||||
|
|||||||
@@ -27,4 +27,5 @@ export function applyJSDOMWindow(win) {
|
|||||||
|
|
||||||
win.open = vi.fn();
|
win.open = vi.fn();
|
||||||
win.close = vi.fn();
|
win.close = vi.fn();
|
||||||
|
win.confirm = vi.fn(() => true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ async function setupImportExport(overrides = {}) {
|
|||||||
const restoreSpy = vi.fn();
|
const restoreSpy = vi.fn();
|
||||||
globalThis.restore_options = restoreSpy;
|
globalThis.restore_options = restoreSpy;
|
||||||
window.restore_options = restoreSpy;
|
window.restore_options = restoreSpy;
|
||||||
|
loadScript("extension/shared/settings-core.js");
|
||||||
loadScript("extension/shared/import-export.js");
|
loadScript("extension/shared/import-export.js");
|
||||||
loadScript("extension/options/import-export.js");
|
loadScript("extension/options/import-export.js");
|
||||||
await flushAsyncWork();
|
await flushAsyncWork();
|
||||||
@@ -19,11 +20,34 @@ async function setupImportExport(overrides = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("import/export flows", () => {
|
describe("import/export flows", () => {
|
||||||
|
it("keeps editable rule titles in normalized exports", async () => {
|
||||||
|
await setupImportExport();
|
||||||
|
const settings = globalThis.vscGetSettingsDefaults();
|
||||||
|
settings.siteRules[1].title = "My Shorts rule";
|
||||||
|
settings.siteRules[1].shortcutTargetMode = "all";
|
||||||
|
settings.siteRules[1].showAmbientLoopControls = true;
|
||||||
|
|
||||||
|
const exported = globalThis.normalizedSettingsForExport(
|
||||||
|
globalThis.vscBuildStoredSettingsDiff(settings)
|
||||||
|
);
|
||||||
|
const restored = globalThis.vscExpandStoredSettings(exported);
|
||||||
|
|
||||||
|
expect(restored.siteRules[1].title).toBe("My Shorts rule");
|
||||||
|
expect(restored.siteRules[1].shortcutTargetMode).toBe("all");
|
||||||
|
expect(restored.siteRules[1].showAmbientLoopControls).toBe(true);
|
||||||
|
expect(exported.siteRules.map((rule) => rule.title)).toEqual([
|
||||||
|
"YouTube videos",
|
||||||
|
"My Shorts rule",
|
||||||
|
"YouTube Shorts (mobile)"
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("exports sync and local settings as a JSON download", async () => {
|
it("exports sync and local settings as a JSON download", async () => {
|
||||||
vi.useFakeTimers();
|
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({
|
const chrome = await setupImportExport({
|
||||||
sync: { rememberSpeed: true },
|
sync: { rememberSpeed: true, shortcutTargetMode: "all" },
|
||||||
local: { customButtonIcons: { faster: { slug: "rocket" } } }
|
local: { customButtonIcons: { faster: { slug: "rocket" } } }
|
||||||
});
|
});
|
||||||
const OriginalBlob = window.Blob;
|
const OriginalBlob = window.Blob;
|
||||||
@@ -66,8 +90,17 @@ describe("import/export flows", () => {
|
|||||||
const blobText = await capturedBlob.text();
|
const blobText = await capturedBlob.text();
|
||||||
expect(JSON.parse(blobText)).toEqual({
|
expect(JSON.parse(blobText)).toEqual({
|
||||||
version: "1.1",
|
version: "1.1",
|
||||||
exportDate: "2026-04-04T12:09:10.000Z",
|
exportDate: exportTime.toISOString(),
|
||||||
settings: { rememberSpeed: true },
|
settings: {
|
||||||
|
rememberSpeed: true,
|
||||||
|
shortcutTargetMode: "all",
|
||||||
|
siteRulesFormat: "defaults-diff-v2",
|
||||||
|
siteRules: [
|
||||||
|
expect.objectContaining({ title: "YouTube videos" }),
|
||||||
|
expect.objectContaining({ title: "YouTube Shorts" }),
|
||||||
|
expect.objectContaining({ title: "YouTube Shorts (mobile)" })
|
||||||
|
]
|
||||||
|
},
|
||||||
localSettings: { customButtonIcons: { faster: { slug: "rocket" } } }
|
localSettings: { customButtonIcons: { faster: { slug: "rocket" } } }
|
||||||
});
|
});
|
||||||
expect(document.getElementById("status").textContent).toBe(
|
expect(document.getElementById("status").textContent).toBe(
|
||||||
@@ -172,7 +205,7 @@ describe("import/export flows", () => {
|
|||||||
{ customButtonIcons: { faster: { slug: "rocket" } } },
|
{ customButtonIcons: { faster: { slug: "rocket" } } },
|
||||||
expect.any(Function)
|
expect.any(Function)
|
||||||
);
|
);
|
||||||
expect(chrome.storage.sync.clear).toHaveBeenCalled();
|
expect(chrome.storage.sync.clear).not.toHaveBeenCalled();
|
||||||
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
|
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
|
||||||
{ rememberSpeed: true },
|
{ rememberSpeed: true },
|
||||||
expect.any(Function)
|
expect.any(Function)
|
||||||
@@ -221,7 +254,13 @@ describe("import/export flows", () => {
|
|||||||
{
|
{
|
||||||
__text: JSON.stringify({
|
__text: JSON.stringify({
|
||||||
enabled: false,
|
enabled: false,
|
||||||
siteRules: [{ pattern: "example.com", enabled: false }]
|
siteRules: [
|
||||||
|
{
|
||||||
|
title: "Example videos",
|
||||||
|
pattern: "example.com",
|
||||||
|
enabled: false
|
||||||
|
}
|
||||||
|
]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -230,13 +269,17 @@ describe("import/export flows", () => {
|
|||||||
|
|
||||||
expect(chrome.storage.local.clear).not.toHaveBeenCalled();
|
expect(chrome.storage.local.clear).not.toHaveBeenCalled();
|
||||||
expect(chrome.storage.local.set).not.toHaveBeenCalled();
|
expect(chrome.storage.local.set).not.toHaveBeenCalled();
|
||||||
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
|
const imported = globalThis.vscExpandStoredSettings(
|
||||||
{
|
chrome.storage.sync.__state
|
||||||
enabled: false,
|
|
||||||
siteRules: [{ pattern: "example.com", enabled: false }]
|
|
||||||
},
|
|
||||||
expect.any(Function)
|
|
||||||
);
|
);
|
||||||
|
expect(imported.enabled).toBe(false);
|
||||||
|
expect(imported.siteRules).toEqual([
|
||||||
|
{
|
||||||
|
title: "Example videos",
|
||||||
|
pattern: "example.com",
|
||||||
|
enabled: false
|
||||||
|
}
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clears stale local data when a wrapped backup has empty local settings", async () => {
|
it("clears stale local data when a wrapped backup has empty local settings", async () => {
|
||||||
@@ -285,8 +328,15 @@ describe("import/export flows", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(chrome.storage.local.clear).toHaveBeenCalled();
|
expect(chrome.storage.local.clear).not.toHaveBeenCalled();
|
||||||
|
expect(chrome.storage.local.remove).toHaveBeenCalledWith(
|
||||||
|
"customButtonIcons",
|
||||||
|
expect.any(Function)
|
||||||
|
);
|
||||||
expect(chrome.storage.local.set).not.toHaveBeenCalled();
|
expect(chrome.storage.local.set).not.toHaveBeenCalled();
|
||||||
|
expect(chrome.storage.local.__state.lucideTagsCacheV1).toEqual({
|
||||||
|
stale: true
|
||||||
|
});
|
||||||
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
|
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
|
||||||
{ rememberSpeed: true },
|
{ rememberSpeed: true },
|
||||||
expect.any(Function)
|
expect.any(Function)
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ function bootImportExport(options) {
|
|||||||
});
|
});
|
||||||
global.URL = window.URL;
|
global.URL = window.URL;
|
||||||
|
|
||||||
|
evaluateScript("extension/shared/settings-core.js");
|
||||||
evaluateScript("extension/shared/import-export.js");
|
evaluateScript("extension/shared/import-export.js");
|
||||||
evaluateScript("extension/options/import-export.js");
|
evaluateScript("extension/options/import-export.js");
|
||||||
fireDOMContentLoaded();
|
fireDOMContentLoaded();
|
||||||
@@ -69,7 +70,7 @@ describe("options/import-export.js", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("generates timestamped backup filenames", () => {
|
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();
|
bootImportExport();
|
||||||
|
|
||||||
expect(window.generateBackupFilename()).toBe(
|
expect(window.generateBackupFilename()).toBe(
|
||||||
@@ -136,9 +137,14 @@ describe("options/import-export.js", () => {
|
|||||||
expect(backup.localSettings.lucideTagsCacheV1At).toBeUndefined();
|
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();
|
const { chrome } = bootImportExport();
|
||||||
window.restore_options = vi.fn();
|
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 realCreateElement = document.createElement.bind(document);
|
||||||
const fakeInput = realCreateElement("input");
|
const fakeInput = realCreateElement("input");
|
||||||
@@ -191,11 +197,17 @@ describe("options/import-export.js", () => {
|
|||||||
},
|
},
|
||||||
expect.any(Function)
|
expect.any(Function)
|
||||||
);
|
);
|
||||||
expect(chrome.storage.sync.clear).toHaveBeenCalled();
|
expect(chrome.storage.sync.clear).not.toHaveBeenCalled();
|
||||||
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
|
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
|
||||||
{ rememberSpeed: true, enabled: false },
|
{ rememberSpeed: true, enabled: false },
|
||||||
expect.any(Function)
|
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);
|
vi.advanceTimersByTime(500);
|
||||||
expect(window.restore_options).toHaveBeenCalled();
|
expect(window.restore_options).toHaveBeenCalled();
|
||||||
@@ -237,4 +249,63 @@ describe("options/import-export.js", () => {
|
|||||||
"Failed to parse backup file"
|
"Failed to parse backup file"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects malformed custom icons without changing stored settings", async () => {
|
||||||
|
const { chrome } = bootImportExport({
|
||||||
|
syncData: { rememberSpeed: false },
|
||||||
|
localData: {
|
||||||
|
customButtonIcons: {
|
||||||
|
faster: { slug: "rocket", svg: "<svg></svg>" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const realCreateElement = document.createElement.bind(document);
|
||||||
|
const fakeInput = realCreateElement("input");
|
||||||
|
Object.defineProperty(fakeInput, "files", {
|
||||||
|
configurable: true,
|
||||||
|
value: [
|
||||||
|
{
|
||||||
|
__contents: JSON.stringify({
|
||||||
|
settings: { rememberSpeed: true },
|
||||||
|
localSettings: { customButtonIcons: "not-an-icon-map" }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
fakeInput.click = vi.fn(() => {
|
||||||
|
fakeInput.onchange({ target: fakeInput });
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.spyOn(document, "createElement").mockImplementation((tagName) => {
|
||||||
|
if (String(tagName).toLowerCase() === "input") return fakeInput;
|
||||||
|
return realCreateElement(tagName);
|
||||||
|
});
|
||||||
|
|
||||||
|
class FakeFileReader {
|
||||||
|
readAsText(file) {
|
||||||
|
this.onload({ target: { result: file.__contents } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.stubGlobal("FileReader", FakeFileReader);
|
||||||
|
window.FileReader = FakeFileReader;
|
||||||
|
chrome.storage.sync.set.mockClear();
|
||||||
|
chrome.storage.local.set.mockClear();
|
||||||
|
chrome.storage.local.remove.mockClear();
|
||||||
|
|
||||||
|
document.querySelector("#importSettings").click();
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
expect(document.querySelector("#status").textContent).toContain(
|
||||||
|
"Invalid backup file format"
|
||||||
|
);
|
||||||
|
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
|
||||||
|
expect(chrome.storage.local.set).not.toHaveBeenCalled();
|
||||||
|
expect(chrome.storage.local.remove).not.toHaveBeenCalled();
|
||||||
|
expect(chrome.storage.sync._dump().rememberSpeed).toBe(false);
|
||||||
|
expect(chrome.storage.local._dump().customButtonIcons).toEqual({
|
||||||
|
faster: { slug: "rocket", svg: "<svg></svg>" }
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,1263 @@
|
|||||||
|
const {
|
||||||
|
createChromeMock,
|
||||||
|
evaluateScript,
|
||||||
|
flushAsyncWork,
|
||||||
|
loadHtmlString
|
||||||
|
} = require("./helpers/extension-test-utils");
|
||||||
|
|
||||||
|
function bootInject(options) {
|
||||||
|
const config = options || {};
|
||||||
|
|
||||||
|
loadHtmlString("<!doctype html><html><body></body></html>", {
|
||||||
|
url: config.url || "https://example.org/"
|
||||||
|
});
|
||||||
|
window.history.replaceState({}, "", config.path || "/");
|
||||||
|
|
||||||
|
const chrome = createChromeMock({
|
||||||
|
syncData: config.syncData,
|
||||||
|
localData: config.localData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (config.syncGetDelayMs) {
|
||||||
|
chrome.storage.sync.get.mockImplementation((keys, callback) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
callback(chrome.storage.sync._dump());
|
||||||
|
}, config.syncGetDelayMs);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
global.chrome = chrome;
|
||||||
|
window.chrome = chrome;
|
||||||
|
|
||||||
|
// Keep extension timers under Vitest's clock when a test uses fake timers.
|
||||||
|
window.setTimeout = global.setTimeout.bind(global);
|
||||||
|
window.clearTimeout = global.clearTimeout.bind(global);
|
||||||
|
window.requestAnimationFrame = (callback) => setTimeout(callback, 0);
|
||||||
|
window.cancelAnimationFrame = (id) => clearTimeout(id);
|
||||||
|
window.requestIdleCallback = (callback) =>
|
||||||
|
setTimeout(
|
||||||
|
() =>
|
||||||
|
callback({
|
||||||
|
didTimeout: false,
|
||||||
|
timeRemaining() {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
window.cancelIdleCallback = (id) => clearTimeout(id);
|
||||||
|
|
||||||
|
evaluateScript("extension/shared/controller-utils.js");
|
||||||
|
evaluateScript("extension/shared/key-bindings.js");
|
||||||
|
evaluateScript("extension/shared/settings-core.js");
|
||||||
|
evaluateScript("extension/shared/site-rules.js");
|
||||||
|
evaluateScript("extension/shared/ui-icons.js");
|
||||||
|
evaluateScript("extension/content/inject.js");
|
||||||
|
|
||||||
|
return chrome;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settleLifecycle(turns = 4) {
|
||||||
|
await flushAsyncWork(turns);
|
||||||
|
if (vi.isFakeTimers()) {
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
await flushAsyncWork(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRect(left, top, width, height) {
|
||||||
|
return {
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
right: left + width,
|
||||||
|
bottom: top + height,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
x: left,
|
||||||
|
y: top,
|
||||||
|
toJSON() {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRect(element, rect) {
|
||||||
|
element.getBoundingClientRect = () => rect;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBoxMetrics(element, width, height) {
|
||||||
|
Object.defineProperties(element, {
|
||||||
|
offsetWidth: { configurable: true, value: width },
|
||||||
|
offsetHeight: { configurable: true, value: height },
|
||||||
|
clientLeft: { configurable: true, value: 0 },
|
||||||
|
clientTop: { configurable: true, value: 0 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createControlledVideo(options) {
|
||||||
|
const config = options || {};
|
||||||
|
const mount = config.mount || document.createElement("div");
|
||||||
|
const video = document.createElement("video");
|
||||||
|
const mountRect = config.mountRect || makeRect(0, 0, 640, 360);
|
||||||
|
const videoRect = config.videoRect || mountRect;
|
||||||
|
|
||||||
|
setRect(mount, mountRect);
|
||||||
|
setBoxMetrics(mount, mountRect.width, mountRect.height);
|
||||||
|
setRect(video, videoRect);
|
||||||
|
video.src = config.src || "https://example.org/video.mp4";
|
||||||
|
mount.appendChild(video);
|
||||||
|
if (!mount.isConnected) document.body.appendChild(mount);
|
||||||
|
|
||||||
|
window.ensureController(video, mount);
|
||||||
|
return { mount, video, controller: video.vsc, wrapper: video.vsc.div };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostIsGeometrySuppressed(host) {
|
||||||
|
const width = host.style.getPropertyValue("width");
|
||||||
|
const height = host.style.getPropertyValue("height");
|
||||||
|
return Boolean(
|
||||||
|
host.hidden ||
|
||||||
|
host.getAttribute("aria-hidden") === "true" ||
|
||||||
|
host.classList.contains("vsc-geometry-hidden") ||
|
||||||
|
host.style.display === "none" ||
|
||||||
|
host.style.visibility === "hidden" ||
|
||||||
|
((width === "0" || width === "0px") &&
|
||||||
|
(height === "0" || height === "0px"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("inject.js media/controller lifecycle regressions", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
delete global.chrome;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bridges page-world history and shadow creation events", () => {
|
||||||
|
loadHtmlString("<!doctype html><html><body></body></html>");
|
||||||
|
let locationEvents = 0;
|
||||||
|
let shadowEvents = 0;
|
||||||
|
document.addEventListener("speeder-location-changed", () => {
|
||||||
|
locationEvents += 1;
|
||||||
|
});
|
||||||
|
document.addEventListener("speeder-shadow-root-attached", () => {
|
||||||
|
shadowEvents += 1;
|
||||||
|
});
|
||||||
|
evaluateScript("extension/content/shadow-bridge.js");
|
||||||
|
|
||||||
|
window.history.pushState({}, "", "/new-route");
|
||||||
|
const bridgePlayer = document.createElement("bridge-player");
|
||||||
|
document.body.appendChild(bridgePlayer);
|
||||||
|
bridgePlayer.attachShadow({ mode: "open" });
|
||||||
|
|
||||||
|
expect(locationEvents).toBe(1);
|
||||||
|
expect(shadowEvents).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries a blocked page bridge with bounded shadow fallback scans", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
bootInject();
|
||||||
|
const firstBridge = document.querySelector(
|
||||||
|
'script[src$="content/shadow-bridge.js"]'
|
||||||
|
);
|
||||||
|
expect(firstBridge).not.toBeNull();
|
||||||
|
|
||||||
|
firstBridge.dispatchEvent(new Event("error"));
|
||||||
|
|
||||||
|
expect(window.vscPageShadowBridgeRequested).toBe(false);
|
||||||
|
expect(window.vscBoundedShadowFallbackStarted).toBe(true);
|
||||||
|
expect(window.vscBoundedShadowFallbackTimers).toHaveLength(3);
|
||||||
|
expect(window.vscPersistentShadowFallbackTimer).toBeUndefined();
|
||||||
|
await vi.advanceTimersByTimeAsync(250);
|
||||||
|
expect(
|
||||||
|
document.querySelector('script[src$="content/shadow-bridge.js"]')
|
||||||
|
).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops at an immediate player stacking boundary", async () => {
|
||||||
|
bootInject();
|
||||||
|
await settleLifecycle();
|
||||||
|
|
||||||
|
const outside = document.createElement("section");
|
||||||
|
const isolatedPlayer = document.createElement("div");
|
||||||
|
const video = document.createElement("video");
|
||||||
|
const rect = makeRect(10, 70, 640, 360);
|
||||||
|
|
||||||
|
isolatedPlayer.style.isolation = "isolate";
|
||||||
|
isolatedPlayer.appendChild(video);
|
||||||
|
outside.appendChild(isolatedPlayer);
|
||||||
|
document.body.appendChild(outside);
|
||||||
|
[outside, isolatedPlayer, video].forEach((element) => setRect(element, rect));
|
||||||
|
|
||||||
|
expect(window.getControllerMount(video)).toBe(isolatedPlayer);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes a video wrapper when a sibling gesture pane covers the player", async () => {
|
||||||
|
bootInject();
|
||||||
|
await settleLifecycle();
|
||||||
|
|
||||||
|
const player = document.createElement("div");
|
||||||
|
const videoLayer = document.createElement("div");
|
||||||
|
const gesturePane = document.createElement("div");
|
||||||
|
const video = document.createElement("video");
|
||||||
|
const rect = makeRect(20, 70, 640, 360);
|
||||||
|
|
||||||
|
videoLayer.style.transform = "translateZ(0)";
|
||||||
|
video.src = "https://example.org/sibling-pane.mp4";
|
||||||
|
videoLayer.appendChild(video);
|
||||||
|
player.append(videoLayer, gesturePane);
|
||||||
|
document.body.appendChild(player);
|
||||||
|
[player, videoLayer, gesturePane, video].forEach((element) => {
|
||||||
|
setRect(element, rect);
|
||||||
|
});
|
||||||
|
setBoxMetrics(player, rect.width, rect.height);
|
||||||
|
|
||||||
|
expect(window.getControllerMount(video)).toBe(player);
|
||||||
|
window.ensureController(video, videoLayer);
|
||||||
|
expect(video.vsc.controllerHostMount).toBe(player);
|
||||||
|
expect(player.lastElementChild).toBe(video.vsc.div);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recovers a controller measured before the player receives layout", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
bootInject();
|
||||||
|
await settleLifecycle();
|
||||||
|
|
||||||
|
const mount = document.createElement("div");
|
||||||
|
const video = document.createElement("video");
|
||||||
|
let mountRect = makeRect(0, 0, 0, 0);
|
||||||
|
let videoRect = makeRect(0, 0, 0, 0);
|
||||||
|
|
||||||
|
setBoxMetrics(mount, 640, 360);
|
||||||
|
mount.getBoundingClientRect = () => mountRect;
|
||||||
|
video.getBoundingClientRect = () => videoRect;
|
||||||
|
video.src = "https://example.org/late-layout.mp4";
|
||||||
|
mount.appendChild(video);
|
||||||
|
document.body.appendChild(mount);
|
||||||
|
|
||||||
|
window.ensureController(video, mount);
|
||||||
|
expect(video.vsc.div.classList.contains("vsc-geometry-hidden")).toBe(true);
|
||||||
|
|
||||||
|
mountRect = makeRect(20, 80, 640, 360);
|
||||||
|
videoRect = makeRect(20, 80, 640, 360);
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
|
||||||
|
expect(video.vsc.div.classList.contains("vsc-geometry-hidden")).toBe(false);
|
||||||
|
expect(video.vsc.div.style.display).not.toBe("none");
|
||||||
|
expect(video.vsc.div.style.getPropertyValue("width")).toBe("640px");
|
||||||
|
expect(video.vsc.div.style.getPropertyValue("height")).toBe("360px");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps YouTube watch, Shorts, and hover controls on the real player boundary", async () => {
|
||||||
|
bootInject({ url: "https://www.youtube.com/" });
|
||||||
|
await settleLifecycle();
|
||||||
|
|
||||||
|
const player = document.createElement("div");
|
||||||
|
player.id = "inline-preview-player";
|
||||||
|
player.className = "html5-video-player";
|
||||||
|
const zeroHeightVideoContainer = document.createElement("div");
|
||||||
|
zeroHeightVideoContainer.className = "html5-video-container";
|
||||||
|
const video = document.createElement("video");
|
||||||
|
const playerRect = makeRect(40, 120, 402, 226);
|
||||||
|
|
||||||
|
setRect(player, playerRect);
|
||||||
|
setBoxMetrics(player, playerRect.width, playerRect.height);
|
||||||
|
setRect(zeroHeightVideoContainer, makeRect(40, 120, 402, 0));
|
||||||
|
setBoxMetrics(zeroHeightVideoContainer, 402, 0);
|
||||||
|
setRect(video, playerRect);
|
||||||
|
video.src = "blob:https://www.youtube.com/hover-preview";
|
||||||
|
zeroHeightVideoContainer.appendChild(video);
|
||||||
|
player.appendChild(zeroHeightVideoContainer);
|
||||||
|
document.body.appendChild(player);
|
||||||
|
|
||||||
|
window.ensureController(video, zeroHeightVideoContainer);
|
||||||
|
window.ensureController(video, zeroHeightVideoContainer);
|
||||||
|
|
||||||
|
expect(video.vsc.div.parentElement).toBe(player);
|
||||||
|
expect(video.vsc.controllerHostMount).toBe(player);
|
||||||
|
expect(video.vsc.div.classList.contains("vsc-geometry-hidden")).toBe(false);
|
||||||
|
expect(video.vsc.div.style.getPropertyValue("width")).toBe("402px");
|
||||||
|
expect(video.vsc.div.style.getPropertyValue("height")).toBe("226px");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("targets the controller nearest the pointer unless change-all is selected", async () => {
|
||||||
|
bootInject();
|
||||||
|
await settleLifecycle();
|
||||||
|
|
||||||
|
const first = createControlledVideo({
|
||||||
|
src: "https://example.org/first.mp4",
|
||||||
|
mountRect: makeRect(0, 0, 320, 180)
|
||||||
|
});
|
||||||
|
const second = createControlledVideo({
|
||||||
|
src: "https://example.org/second.mp4",
|
||||||
|
mountRect: makeRect(500, 0, 320, 180)
|
||||||
|
});
|
||||||
|
setRect(window.getControllerElement(first.controller), makeRect(10, 10, 120, 30));
|
||||||
|
setRect(window.getControllerElement(second.controller), makeRect(510, 10, 120, 30));
|
||||||
|
|
||||||
|
document.dispatchEvent(
|
||||||
|
new MouseEvent("mousemove", { bubbles: true, clientX: 600, clientY: 20 })
|
||||||
|
);
|
||||||
|
window.runAction("faster", 0.1);
|
||||||
|
|
||||||
|
expect(first.video.playbackRate).toBe(1);
|
||||||
|
expect(second.video.playbackRate).toBe(1.1);
|
||||||
|
|
||||||
|
window.tc.settings.shortcutTargetMode = "all";
|
||||||
|
window.runAction("faster", 0.1);
|
||||||
|
expect(first.video.playbackRate).toBe(1.1);
|
||||||
|
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();
|
||||||
|
|
||||||
|
const mount = document.createElement("div");
|
||||||
|
const video = document.createElement("video");
|
||||||
|
const rect = makeRect(0, 0, 480, 270);
|
||||||
|
video.autoplay = true;
|
||||||
|
video.defaultMuted = true;
|
||||||
|
video.muted = true;
|
||||||
|
video.loop = true;
|
||||||
|
video.playsInline = true;
|
||||||
|
video.controls = false;
|
||||||
|
video.src = "https://example.org/card-loop.mp4";
|
||||||
|
setRect(mount, rect);
|
||||||
|
setBoxMetrics(mount, rect.width, rect.height);
|
||||||
|
setRect(video, rect);
|
||||||
|
mount.appendChild(video);
|
||||||
|
document.body.appendChild(mount);
|
||||||
|
|
||||||
|
expect(window.ensureController(video, mount)).toBeNull();
|
||||||
|
expect(video.vsc).toBeUndefined();
|
||||||
|
|
||||||
|
window.tc.settings.showAmbientLoopControls = true;
|
||||||
|
window.tc.siteRuleBase.showAmbientLoopControls = true;
|
||||||
|
expect(window.ensureController(video, mount)).toBeTruthy();
|
||||||
|
expect(video.vsc.div.isConnected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("always includes a genuine player even when its video has an ambient-loop signature", async () => {
|
||||||
|
bootInject();
|
||||||
|
await settleLifecycle();
|
||||||
|
|
||||||
|
const player = document.createElement("media-player");
|
||||||
|
const video = document.createElement("video");
|
||||||
|
const playButton = document.createElement("button");
|
||||||
|
const rect = makeRect(0, 0, 640, 360);
|
||||||
|
playButton.setAttribute("aria-label", "Play");
|
||||||
|
video.autoplay = true;
|
||||||
|
video.defaultMuted = true;
|
||||||
|
video.muted = true;
|
||||||
|
video.loop = true;
|
||||||
|
video.playsInline = true;
|
||||||
|
video.src = "https://example.org/interactive-loop.mp4";
|
||||||
|
player.append(video, playButton);
|
||||||
|
document.body.appendChild(player);
|
||||||
|
[player, video].forEach((element) => setRect(element, rect));
|
||||||
|
setBoxMetrics(player, rect.width, rect.height);
|
||||||
|
|
||||||
|
expect(window.tc.settings.showAmbientLoopControls).toBe(false);
|
||||||
|
expect(window.ensureController(video, player)).toBeTruthy();
|
||||||
|
expect(video.vsc.div.isConnected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies the ambient-loop setting from a matching site rule", async () => {
|
||||||
|
bootInject({
|
||||||
|
url: "https://news.example.org/story",
|
||||||
|
syncData: {
|
||||||
|
showAmbientLoopControls: false,
|
||||||
|
siteRules: [
|
||||||
|
{
|
||||||
|
pattern: "news.example.org",
|
||||||
|
enabled: true,
|
||||||
|
showAmbientLoopControls: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await settleLifecycle();
|
||||||
|
|
||||||
|
const mount = document.createElement("div");
|
||||||
|
const video = document.createElement("video");
|
||||||
|
const rect = makeRect(0, 0, 480, 270);
|
||||||
|
video.autoplay = true;
|
||||||
|
video.defaultMuted = true;
|
||||||
|
video.muted = true;
|
||||||
|
video.loop = true;
|
||||||
|
video.playsInline = true;
|
||||||
|
video.src = "https://news.example.org/card.mp4";
|
||||||
|
mount.appendChild(video);
|
||||||
|
document.body.appendChild(mount);
|
||||||
|
[mount, video].forEach((element) => setRect(element, rect));
|
||||||
|
setBoxMetrics(mount, rect.width, rect.height);
|
||||||
|
|
||||||
|
expect(window.ensureController(video, mount)).toBeTruthy();
|
||||||
|
expect(window.tc.settings.showAmbientLoopControls).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves the host into an ancestor fullscreen subtree and restores it", async () => {
|
||||||
|
bootInject();
|
||||||
|
await settleLifecycle();
|
||||||
|
|
||||||
|
const normalMount = document.createElement("div");
|
||||||
|
const fullscreenPlayer = document.createElement("media-player");
|
||||||
|
const provider = document.createElement("media-provider");
|
||||||
|
const video = document.createElement("video");
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
const rect = makeRect(0, 0, 640, 360);
|
||||||
|
|
||||||
|
normalMount.style.isolation = "isolate";
|
||||||
|
provider.appendChild(video);
|
||||||
|
fullscreenPlayer.appendChild(provider);
|
||||||
|
normalMount.append(fullscreenPlayer, wrapper);
|
||||||
|
document.body.appendChild(normalMount);
|
||||||
|
[normalMount, fullscreenPlayer, provider, video].forEach((element) => {
|
||||||
|
setRect(element, rect);
|
||||||
|
setBoxMetrics(element, rect.width, rect.height);
|
||||||
|
});
|
||||||
|
|
||||||
|
const controller = {
|
||||||
|
video,
|
||||||
|
div: wrapper,
|
||||||
|
normalControllerMount: normalMount
|
||||||
|
};
|
||||||
|
wrapper.showPopover = vi.fn();
|
||||||
|
window.setupControllerHostTracking(controller, wrapper, normalMount);
|
||||||
|
Object.defineProperty(document, "fullscreenElement", {
|
||||||
|
configurable: true,
|
||||||
|
value: fullscreenPlayer
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
value: null
|
||||||
|
});
|
||||||
|
window.syncControllerFullscreenMount(controller);
|
||||||
|
expect(wrapper.parentElement).toBe(normalMount);
|
||||||
|
|
||||||
|
wrapper.remove();
|
||||||
|
controller.controllerHostCleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only promotes the directly-fullscreen video's controller", async () => {
|
||||||
|
bootInject();
|
||||||
|
await settleLifecycle();
|
||||||
|
|
||||||
|
const fullscreenPlayer = document.createElement("div");
|
||||||
|
const fullscreenVideo = document.createElement("video");
|
||||||
|
const otherVideo = document.createElement("video");
|
||||||
|
const rect = makeRect(0, 0, 640, 360);
|
||||||
|
|
||||||
|
fullscreenVideo.src = "https://example.org/fullscreen.mp4";
|
||||||
|
otherVideo.src = "https://example.org/other.mp4";
|
||||||
|
fullscreenPlayer.appendChild(fullscreenVideo);
|
||||||
|
document.body.append(fullscreenPlayer, otherVideo);
|
||||||
|
[fullscreenPlayer, fullscreenVideo, otherVideo].forEach((element) => {
|
||||||
|
setRect(element, rect);
|
||||||
|
setBoxMetrics(element, rect.width, rect.height);
|
||||||
|
});
|
||||||
|
window.ensureController(fullscreenVideo, fullscreenPlayer);
|
||||||
|
window.ensureController(otherVideo, document.body);
|
||||||
|
fullscreenVideo.vsc.div.showPopover = vi.fn();
|
||||||
|
otherVideo.vsc.div.showPopover = vi.fn();
|
||||||
|
|
||||||
|
Object.defineProperty(document, "fullscreenElement", {
|
||||||
|
configurable: true,
|
||||||
|
value: fullscreenVideo
|
||||||
|
});
|
||||||
|
window.syncControllerFullscreenMount(fullscreenVideo.vsc);
|
||||||
|
window.syncControllerFullscreenMount(otherVideo.vsc);
|
||||||
|
|
||||||
|
expect(fullscreenVideo.vsc.div.showPopover).toHaveBeenCalledOnce();
|
||||||
|
expect(otherVideo.vsc.div.showPopover).not.toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
otherVideo.vsc.div.classList.contains("vsc-fullscreen-popover")
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves direct-video requestFullscreen semantics and overlays with a popover", async () => {
|
||||||
|
bootInject();
|
||||||
|
await settleLifecycle();
|
||||||
|
|
||||||
|
const fullscreenRequests = [];
|
||||||
|
Object.defineProperty(window.Element.prototype, "requestFullscreen", {
|
||||||
|
configurable: true,
|
||||||
|
value: function(options) {
|
||||||
|
fullscreenRequests.push({ target: this, options });
|
||||||
|
Object.defineProperty(document, "fullscreenElement", {
|
||||||
|
configurable: true,
|
||||||
|
value: this
|
||||||
|
});
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
evaluateScript("extension/content/shadow-bridge.js");
|
||||||
|
|
||||||
|
const { mount, video, controller, wrapper } = createControlledVideo();
|
||||||
|
wrapper.showPopover = vi.fn();
|
||||||
|
wrapper.hidePopover = vi.fn();
|
||||||
|
await video.requestFullscreen({ navigationUI: "hide" });
|
||||||
|
document.dispatchEvent(new Event("fullscreenchange"));
|
||||||
|
|
||||||
|
window.syncControllerFullscreenMount(controller);
|
||||||
|
|
||||||
|
expect(fullscreenRequests).toEqual([
|
||||||
|
{
|
||||||
|
target: video,
|
||||||
|
options: { navigationUI: "hide" }
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
expect(document.fullscreenElement).toBe(video);
|
||||||
|
expect(wrapper.showPopover).toHaveBeenCalledOnce();
|
||||||
|
expect(wrapper.parentNode).toBe(mount);
|
||||||
|
expect(video.contains(wrapper)).toBe(false);
|
||||||
|
expect(wrapper.isConnected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a top-layer popover when native controls fullscreen the video element", async () => {
|
||||||
|
bootInject();
|
||||||
|
await settleLifecycle();
|
||||||
|
|
||||||
|
const { mount, video, controller, wrapper } = createControlledVideo();
|
||||||
|
wrapper.showPopover = vi.fn();
|
||||||
|
wrapper.hidePopover = vi.fn();
|
||||||
|
Object.defineProperty(document, "fullscreenElement", {
|
||||||
|
configurable: true,
|
||||||
|
value: video
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(window.getControllerMount(video, video)).toBe(mount);
|
||||||
|
expect(window.syncControllerFullscreenMount(controller)).toBe(true);
|
||||||
|
expect(wrapper.showPopover).toHaveBeenCalledOnce();
|
||||||
|
expect(wrapper.parentNode).toBe(mount);
|
||||||
|
expect(video.contains(wrapper)).toBe(false);
|
||||||
|
expect(wrapper.getAttribute("popover")).toBe("manual");
|
||||||
|
expect(wrapper.classList.contains("vsc-fullscreen-popover")).toBe(true);
|
||||||
|
|
||||||
|
Object.defineProperty(document, "fullscreenElement", {
|
||||||
|
configurable: true,
|
||||||
|
value: null
|
||||||
|
});
|
||||||
|
window.syncControllerFullscreenMount(controller);
|
||||||
|
|
||||||
|
expect(wrapper.hidePopover).toHaveBeenCalledOnce();
|
||||||
|
expect(wrapper.hasAttribute("popover")).toBe(false);
|
||||||
|
expect(wrapper.classList.contains("vsc-fullscreen-popover")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the fullscreen boundary for a portrait video beneath a gesture pane", async () => {
|
||||||
|
bootInject();
|
||||||
|
await settleLifecycle();
|
||||||
|
|
||||||
|
const fullscreenPlayer = document.createElement("media-player");
|
||||||
|
const provider = document.createElement("media-provider");
|
||||||
|
const video = document.createElement("video");
|
||||||
|
const gesturePane = document.createElement("div");
|
||||||
|
const playerRect = makeRect(0, 0, 1280, 720);
|
||||||
|
const portraitRect = makeRect(440, 10, 400, 700);
|
||||||
|
|
||||||
|
gesturePane.className = "gesture-pane";
|
||||||
|
provider.appendChild(video);
|
||||||
|
fullscreenPlayer.append(provider, gesturePane);
|
||||||
|
document.body.appendChild(fullscreenPlayer);
|
||||||
|
setRect(fullscreenPlayer, playerRect);
|
||||||
|
setRect(gesturePane, playerRect);
|
||||||
|
setRect(provider, portraitRect);
|
||||||
|
setRect(video, portraitRect);
|
||||||
|
|
||||||
|
expect(window.getControllerMount(video, fullscreenPlayer)).toBe(
|
||||||
|
fullscreenPlayer
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("repairs a controller host removed independently of its video", async () => {
|
||||||
|
bootInject();
|
||||||
|
await settleLifecycle();
|
||||||
|
|
||||||
|
const { mount, video, wrapper } = createControlledVideo();
|
||||||
|
wrapper.remove();
|
||||||
|
expect(video.isConnected).toBe(true);
|
||||||
|
|
||||||
|
window.ensureController(video, mount);
|
||||||
|
|
||||||
|
expect(video.vsc).toBeDefined();
|
||||||
|
expect(video.vsc.div.isConnected).toBe(true);
|
||||||
|
expect(video.vsc.div.parentElement).toBe(mount);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still creates controls when a custom player throws from playbackRate", async () => {
|
||||||
|
bootInject();
|
||||||
|
await settleLifecycle();
|
||||||
|
const mount = document.createElement("div");
|
||||||
|
const video = document.createElement("video");
|
||||||
|
video.src = "https://example.org/rejecting-player.mp4";
|
||||||
|
Object.defineProperty(video, "playbackRate", {
|
||||||
|
configurable: true,
|
||||||
|
get() {
|
||||||
|
return 1;
|
||||||
|
},
|
||||||
|
set() {
|
||||||
|
throw new Error("rate rejected");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
mount.appendChild(video);
|
||||||
|
document.body.appendChild(mount);
|
||||||
|
|
||||||
|
expect(() => window.ensureController(video, mount)).not.toThrow();
|
||||||
|
expect(video.vsc).toBeDefined();
|
||||||
|
expect(video.vsc.div.isConnected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remounts an existing controller when its video moves between players", async () => {
|
||||||
|
bootInject();
|
||||||
|
await settleLifecycle();
|
||||||
|
|
||||||
|
const mountA = document.createElement("div");
|
||||||
|
const mountB = document.createElement("div");
|
||||||
|
const rect = makeRect(0, 0, 640, 360);
|
||||||
|
[mountA, mountB].forEach((mount) => {
|
||||||
|
setRect(mount, rect);
|
||||||
|
setBoxMetrics(mount, rect.width, rect.height);
|
||||||
|
document.body.appendChild(mount);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { video, wrapper } = createControlledVideo({ mount: mountA });
|
||||||
|
mountB.appendChild(video);
|
||||||
|
|
||||||
|
window.ensureController(video, mountB);
|
||||||
|
|
||||||
|
expect(video.vsc.div).toBe(wrapper);
|
||||||
|
expect(wrapper.parentElement).toBe(mountB);
|
||||||
|
expect(video.vsc.controllerHostMount).toBe(mountB);
|
||||||
|
expect(mountA.querySelector(".vsc-controller")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reconciles a burst of player mutations only once per idle batch", async () => {
|
||||||
|
bootInject();
|
||||||
|
await settleLifecycle();
|
||||||
|
|
||||||
|
const { mount, wrapper } = createControlledVideo();
|
||||||
|
const originalAppendChild = mount.appendChild.bind(mount);
|
||||||
|
let controllerReorders = 0;
|
||||||
|
mount.appendChild = function(node) {
|
||||||
|
if (node === wrapper) controllerReorders += 1;
|
||||||
|
return originalAppendChild(node);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let index = 0; index < 25; index += 1) {
|
||||||
|
mount.appendChild(document.createElement("span"));
|
||||||
|
}
|
||||||
|
await settleLifecycle(8);
|
||||||
|
|
||||||
|
expect(controllerReorders).toBe(1);
|
||||||
|
expect(mount.lastElementChild).toBe(wrapper);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps expanded controls visible and disables auto-hide while hovered", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
bootInject({
|
||||||
|
syncData: {
|
||||||
|
hideWithControls: true,
|
||||||
|
hideWithControlsTimer: 1
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await settleLifecycle();
|
||||||
|
|
||||||
|
const { controller, wrapper } = createControlledVideo();
|
||||||
|
const controllerElement = window.getControllerElement(controller);
|
||||||
|
controllerElement.dispatchEvent(new Event("pointerenter"));
|
||||||
|
|
||||||
|
expect(controller.controllerInteractionActive).toBe(true);
|
||||||
|
expect(controllerElement.classList.contains("vsc-controls-hovered")).toBe(true);
|
||||||
|
expect(wrapper.classList.contains("vsc-controls-hovered")).toBe(true);
|
||||||
|
await vi.advanceTimersByTimeAsync(1500);
|
||||||
|
expect(wrapper.classList.contains("vsc-idle-hidden")).toBe(false);
|
||||||
|
|
||||||
|
controllerElement.dispatchEvent(new Event("pointerleave"));
|
||||||
|
expect(controller.controllerInteractionActive).toBe(false);
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
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({
|
||||||
|
url: "https://www.youtube.com/watch?v=hover-lock",
|
||||||
|
syncData: {
|
||||||
|
hideWithControls: true,
|
||||||
|
hideWithControlsTimer: 1
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await settleLifecycle();
|
||||||
|
|
||||||
|
const player = document.createElement("div");
|
||||||
|
player.className = "html5-video-player ytp-autohide";
|
||||||
|
const { controller, wrapper } = createControlledVideo({ mount: player });
|
||||||
|
const controllerElement = window.getControllerElement(controller);
|
||||||
|
|
||||||
|
expect(wrapper.classList.contains("ytp-autohide")).toBe(true);
|
||||||
|
controllerElement.dispatchEvent(new Event("pointerenter"));
|
||||||
|
expect(wrapper.classList.contains("ytp-autohide")).toBe(false);
|
||||||
|
|
||||||
|
controllerElement.dispatchEvent(new Event("pointerleave"));
|
||||||
|
expect(wrapper.classList.contains("ytp-autohide")).toBe(true);
|
||||||
|
|
||||||
|
player.dispatchEvent(new MouseEvent("mousemove", { bubbles: true }));
|
||||||
|
expect(wrapper.classList.contains("vsc-show")).toBe(true);
|
||||||
|
player.classList.remove("ytp-autohide");
|
||||||
|
player.classList.add("ytp-autohide");
|
||||||
|
await settleLifecycle();
|
||||||
|
expect(wrapper.classList.contains("ytp-autohide")).toBe(true);
|
||||||
|
expect(wrapper.classList.contains("vsc-show")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suppresses a zero-size host and reveals it when the video becomes visible", async () => {
|
||||||
|
bootInject();
|
||||||
|
await settleLifecycle();
|
||||||
|
|
||||||
|
const mount = document.createElement("div");
|
||||||
|
const mountRect = makeRect(100, 50, 640, 360);
|
||||||
|
let videoRect = makeRect(100, 50, 0, 0);
|
||||||
|
setRect(mount, mountRect);
|
||||||
|
setBoxMetrics(mount, mountRect.width, mountRect.height);
|
||||||
|
|
||||||
|
const video = document.createElement("video");
|
||||||
|
video.src = "https://example.org/preload.mp4";
|
||||||
|
video.getBoundingClientRect = () => videoRect;
|
||||||
|
mount.appendChild(video);
|
||||||
|
document.body.appendChild(mount);
|
||||||
|
window.ensureController(video, mount);
|
||||||
|
|
||||||
|
const wrapper = video.vsc.div;
|
||||||
|
expect(hostIsGeometrySuppressed(wrapper)).toBe(true);
|
||||||
|
|
||||||
|
videoRect = makeRect(140, 80, 320, 180);
|
||||||
|
window.positionControllerHost(wrapper, video, mount);
|
||||||
|
|
||||||
|
expect(hostIsGeometrySuppressed(wrapper)).toBe(false);
|
||||||
|
expect(wrapper.style.getPropertyValue("width")).toBe("320px");
|
||||||
|
expect(wrapper.style.getPropertyValue("height")).toBe("180px");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("chooses visible media over an offscreen player for popup actions", async () => {
|
||||||
|
bootInject();
|
||||||
|
await settleLifecycle();
|
||||||
|
|
||||||
|
const offscreen = createControlledVideo({
|
||||||
|
mountRect: makeRect(0, 2000, 1280, 720),
|
||||||
|
videoRect: makeRect(0, 2000, 1280, 720),
|
||||||
|
src: "https://example.org/offscreen.mp4"
|
||||||
|
}).video;
|
||||||
|
const visible = createControlledVideo({
|
||||||
|
mountRect: makeRect(40, 40, 640, 360),
|
||||||
|
videoRect: makeRect(40, 40, 640, 360),
|
||||||
|
src: "https://example.org/visible.mp4"
|
||||||
|
}).video;
|
||||||
|
|
||||||
|
expect(window.getPrimaryVideoElement()).toBe(visible);
|
||||||
|
expect(window.getPrimaryVideoElement()).not.toBe(offscreen);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mounts a controller locally for video directly under an open ShadowRoot", async () => {
|
||||||
|
bootInject();
|
||||||
|
await settleLifecycle();
|
||||||
|
|
||||||
|
const host = document.createElement("custom-player");
|
||||||
|
const shadow = host.attachShadow({ mode: "open" });
|
||||||
|
const video = document.createElement("video");
|
||||||
|
const rect = makeRect(20, 30, 640, 360);
|
||||||
|
video.src = "https://example.org/shadow.mp4";
|
||||||
|
setRect(host, rect);
|
||||||
|
setBoxMetrics(host, rect.width, rect.height);
|
||||||
|
setRect(video, rect);
|
||||||
|
shadow.appendChild(video);
|
||||||
|
document.body.appendChild(host);
|
||||||
|
|
||||||
|
window.observeRoot(shadow);
|
||||||
|
window.scanRootForMedia(shadow);
|
||||||
|
|
||||||
|
expect(video.vsc).toBeDefined();
|
||||||
|
expect(video.vsc.div.getRootNode()).toBe(shadow);
|
||||||
|
expect(shadow.contains(video.vsc.div)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a shadow player controller in its visible root during host fullscreen", async () => {
|
||||||
|
bootInject();
|
||||||
|
await settleLifecycle();
|
||||||
|
|
||||||
|
const host = document.createElement("fullscreen-shadow-player");
|
||||||
|
const shadow = host.attachShadow({ mode: "open" });
|
||||||
|
const video = document.createElement("video");
|
||||||
|
const rect = makeRect(0, 0, 640, 360);
|
||||||
|
video.src = "https://example.org/shadow-fullscreen.mp4";
|
||||||
|
setRect(host, rect);
|
||||||
|
setBoxMetrics(host, rect.width, rect.height);
|
||||||
|
setRect(video, rect);
|
||||||
|
shadow.appendChild(video);
|
||||||
|
document.body.appendChild(host);
|
||||||
|
window.observeRoot(shadow);
|
||||||
|
window.scanRootForMedia(shadow);
|
||||||
|
|
||||||
|
Object.defineProperty(document, "fullscreenElement", {
|
||||||
|
configurable: true,
|
||||||
|
value: host
|
||||||
|
});
|
||||||
|
window.syncControllerFullscreenMount(video.vsc);
|
||||||
|
|
||||||
|
expect(window.getControllerMount(video, host)).toBe(shadow);
|
||||||
|
expect(video.vsc.div.parentNode).toBe(shadow);
|
||||||
|
expect(host.contains(video.vsc.div)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes controllers when their open-shadow host is detached", async () => {
|
||||||
|
bootInject();
|
||||||
|
await settleLifecycle();
|
||||||
|
|
||||||
|
const host = document.createElement("detached-shadow-player");
|
||||||
|
const shadow = host.attachShadow({ mode: "open" });
|
||||||
|
const video = document.createElement("video");
|
||||||
|
video.src = "https://example.org/detached-shadow.mp4";
|
||||||
|
shadow.appendChild(video);
|
||||||
|
document.body.appendChild(host);
|
||||||
|
window.observeRoot(shadow);
|
||||||
|
window.scanRootForMedia(shadow);
|
||||||
|
const wrapper = video.vsc.div;
|
||||||
|
|
||||||
|
host.remove();
|
||||||
|
await settleLifecycle(6);
|
||||||
|
|
||||||
|
expect(video.vsc).toBeUndefined();
|
||||||
|
expect(window.tc.mediaElements).not.toContain(video);
|
||||||
|
expect(wrapper.isConnected).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts and ends a controller drag mounted directly in an open ShadowRoot", async () => {
|
||||||
|
bootInject();
|
||||||
|
await settleLifecycle();
|
||||||
|
|
||||||
|
const host = document.createElement("drag-shadow-player");
|
||||||
|
const shadow = host.attachShadow({ mode: "open" });
|
||||||
|
const video = document.createElement("video");
|
||||||
|
const rect = makeRect(20, 30, 640, 360);
|
||||||
|
video.src = "https://example.org/drag-shadow.mp4";
|
||||||
|
setRect(host, rect);
|
||||||
|
setBoxMetrics(host, rect.width, rect.height);
|
||||||
|
setRect(video, rect);
|
||||||
|
shadow.appendChild(video);
|
||||||
|
document.body.appendChild(host);
|
||||||
|
window.observeRoot(shadow);
|
||||||
|
window.scanRootForMedia(shadow);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
window.handleDrag(video, { clientX: 30, clientY: 40 })
|
||||||
|
).not.toThrow();
|
||||||
|
expect(video.classList.contains("vcs-dragging")).toBe(true);
|
||||||
|
|
||||||
|
host.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
|
||||||
|
expect(video.classList.contains("vcs-dragging")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("queues early shadow media until async settings hydration completes", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
bootInject({ syncGetDelayMs: 100 });
|
||||||
|
|
||||||
|
const host = document.createElement("early-player");
|
||||||
|
const shadow = host.attachShadow({ mode: "open" });
|
||||||
|
const video = document.createElement("video");
|
||||||
|
video.src = "https://example.org/early.mp4";
|
||||||
|
shadow.appendChild(video);
|
||||||
|
document.body.appendChild(host);
|
||||||
|
await settleLifecycle();
|
||||||
|
|
||||||
|
expect(video.vsc).toBeUndefined();
|
||||||
|
await vi.advanceTimersByTimeAsync(120);
|
||||||
|
await settleLifecycle();
|
||||||
|
|
||||||
|
expect(video.vsc).toBeDefined();
|
||||||
|
expect(video.vsc.div.isConnected).toBe(true);
|
||||||
|
expect(video.vsc.div.getRootNode()).toBe(shadow);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reconciles source children and the emptied lifecycle on an existing controller", async () => {
|
||||||
|
bootInject();
|
||||||
|
await settleLifecycle();
|
||||||
|
|
||||||
|
const { video, wrapper } = createControlledVideo();
|
||||||
|
video.preload = "none";
|
||||||
|
video.removeAttribute("src");
|
||||||
|
video.dispatchEvent(new Event("emptied"));
|
||||||
|
await settleLifecycle();
|
||||||
|
expect(wrapper.classList.contains("vsc-nosource")).toBe(true);
|
||||||
|
|
||||||
|
const source = document.createElement("source");
|
||||||
|
source.src = "https://example.org/later.mp4";
|
||||||
|
video.appendChild(source);
|
||||||
|
await settleLifecycle();
|
||||||
|
expect(wrapper.classList.contains("vsc-nosource")).toBe(false);
|
||||||
|
|
||||||
|
source.remove();
|
||||||
|
video.dispatchEvent(new Event("emptied"));
|
||||||
|
await settleLifecycle();
|
||||||
|
expect(wrapper.classList.contains("vsc-nosource")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies the newly matched SPA rule before choosing source-transition speed", async () => {
|
||||||
|
bootInject({
|
||||||
|
path: "/watch/one",
|
||||||
|
syncData: {
|
||||||
|
lastSpeed: 1.8,
|
||||||
|
rememberSpeed: false,
|
||||||
|
siteRules: [
|
||||||
|
{
|
||||||
|
pattern: "example.org/watch/",
|
||||||
|
enabled: true,
|
||||||
|
rememberSpeed: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: "example.org/shorts/",
|
||||||
|
enabled: true,
|
||||||
|
rememberSpeed: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
localData: {
|
||||||
|
rememberedSpeeds: {
|
||||||
|
"https://example.org/short-two.mp4": {
|
||||||
|
speed: 1.8,
|
||||||
|
updatedAt: 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await settleLifecycle();
|
||||||
|
|
||||||
|
const { video } = createControlledVideo({
|
||||||
|
src: "https://example.org/watch-one.mp4"
|
||||||
|
});
|
||||||
|
expect(video.playbackRate).toBe(1);
|
||||||
|
|
||||||
|
window.history.replaceState({}, "", "/shorts/two");
|
||||||
|
video.src = "https://example.org/short-two.mp4";
|
||||||
|
window.applySourceTransitionPolicy(video, true);
|
||||||
|
|
||||||
|
expect(window.tc.activeSiteRule.pattern).toBe("example.org/shorts/");
|
||||||
|
expect(window.tc.settings.rememberSpeed).toBe(true);
|
||||||
|
expect(video.vsc.targetSpeed).toBe(1.8);
|
||||||
|
expect(video.playbackRate).toBe(1.8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("activates after an initially disabled SPA route becomes enabled", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
bootInject({
|
||||||
|
path: "/disabled",
|
||||||
|
syncData: {
|
||||||
|
siteRules: [
|
||||||
|
{ pattern: "example.org/disabled", enabled: false },
|
||||||
|
{ pattern: "example.org/enabled", enabled: true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const video = document.createElement("video");
|
||||||
|
video.src = "https://example.org/enabled.mp4";
|
||||||
|
document.body.appendChild(video);
|
||||||
|
expect(video.vsc).toBeUndefined();
|
||||||
|
|
||||||
|
window.history.pushState({}, "", "/enabled");
|
||||||
|
window.dispatchEvent(new window.PopStateEvent("popstate"));
|
||||||
|
await vi.advanceTimersByTimeAsync(350);
|
||||||
|
await settleLifecycle();
|
||||||
|
|
||||||
|
expect(video.vsc).toBeDefined();
|
||||||
|
expect(video.vsc.div.isConnected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rescans an existing open-shadow player when an SPA route becomes enabled", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
bootInject({
|
||||||
|
path: "/disabled",
|
||||||
|
syncData: {
|
||||||
|
siteRules: [
|
||||||
|
{ pattern: "example.org/disabled", enabled: false },
|
||||||
|
{ pattern: "example.org/enabled", enabled: true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const host = document.createElement("route-shadow-player");
|
||||||
|
const shadow = host.attachShadow({ mode: "open" });
|
||||||
|
const video = document.createElement("video");
|
||||||
|
video.src = "https://example.org/route-shadow.mp4";
|
||||||
|
shadow.appendChild(video);
|
||||||
|
document.body.appendChild(host);
|
||||||
|
window.observeRoot(shadow);
|
||||||
|
expect(video.vsc).toBeUndefined();
|
||||||
|
|
||||||
|
window.history.pushState({}, "", "/enabled");
|
||||||
|
window.dispatchEvent(new window.PopStateEvent("popstate"));
|
||||||
|
await vi.advanceTimersByTimeAsync(350);
|
||||||
|
await settleLifecycle();
|
||||||
|
|
||||||
|
expect(video.vsc).toBeDefined();
|
||||||
|
expect(video.vsc.div.getRootNode()).toBe(shadow);
|
||||||
|
expect(video.vsc.div.isConnected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tears controllers down when an enabled SPA route becomes disabled", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
bootInject({
|
||||||
|
path: "/enabled",
|
||||||
|
syncData: {
|
||||||
|
siteRules: [
|
||||||
|
{ pattern: "example.org/enabled", enabled: true },
|
||||||
|
{ pattern: "example.org/disabled", enabled: false }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { video } = createControlledVideo();
|
||||||
|
expect(video.vsc).toBeDefined();
|
||||||
|
|
||||||
|
window.history.pushState({}, "", "/disabled");
|
||||||
|
window.dispatchEvent(new window.PopStateEvent("popstate"));
|
||||||
|
await vi.advanceTimersByTimeAsync(350);
|
||||||
|
await settleLifecycle();
|
||||||
|
|
||||||
|
expect(video.vsc).toBeUndefined();
|
||||||
|
expect(document.querySelector(".vsc-controller")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes existing audio controllers when live settings disable audio", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const chrome = bootInject({ syncData: { audioBoolean: true } });
|
||||||
|
await settleLifecycle();
|
||||||
|
|
||||||
|
const mount = document.createElement("div");
|
||||||
|
const audio = document.createElement("audio");
|
||||||
|
audio.src = "https://example.org/podcast.mp3";
|
||||||
|
mount.appendChild(audio);
|
||||||
|
document.body.appendChild(mount);
|
||||||
|
window.ensureController(audio, mount);
|
||||||
|
expect(audio.vsc).toBeDefined();
|
||||||
|
|
||||||
|
chrome.storage.sync.set({ audioBoolean: false });
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
await settleLifecycle();
|
||||||
|
|
||||||
|
expect(audio.vsc).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -139,6 +139,15 @@ describe("inject.js helper logic", () => {
|
|||||||
expect(window.tc.settings.controllerMarginTop).toBe(200);
|
expect(window.tc.settings.controllerMarginTop).toBe(200);
|
||||||
expect(window.tc.settings.controllerMarginBottom).toBe(0);
|
expect(window.tc.settings.controllerMarginBottom).toBe(0);
|
||||||
expect(window.tc.settings.rememberSpeed).toBe(true);
|
expect(window.tc.settings.rememberSpeed).toBe(true);
|
||||||
|
|
||||||
|
window.resetSettingsFromSiteRuleBase();
|
||||||
|
window.tc.settings.shortcutTargetMode = "closest";
|
||||||
|
window.tc.settings.siteRules = [
|
||||||
|
{ pattern: "example.org", shortcutTargetMode: "all" }
|
||||||
|
];
|
||||||
|
window.captureSiteRuleBase();
|
||||||
|
window.applySiteRuleOverrides();
|
||||||
|
expect(window.tc.settings.shortcutTargetMode).toBe("all");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sizes and positions the controller host to the video bounds", async () => {
|
it("sizes and positions the controller host to the video bounds", async () => {
|
||||||
@@ -183,4 +192,197 @@ describe("inject.js helper logic", () => {
|
|||||||
expect(wrapper.style.getPropertyValue("height")).toBe("180px");
|
expect(wrapper.style.getPropertyValue("height")).toBe("180px");
|
||||||
expect(wrapper.style.getPropertyPriority("width")).toBe("important");
|
expect(wrapper.style.getPropertyPriority("width")).toBe("important");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not mount the controller outside a player stacking boundary", async () => {
|
||||||
|
bootInject();
|
||||||
|
await flushAsyncWork(3);
|
||||||
|
|
||||||
|
const page = document.createElement("main");
|
||||||
|
const outsidePlayer = document.createElement("section");
|
||||||
|
const isolatedPlayer = document.createElement("div");
|
||||||
|
const videoParent = document.createElement("div");
|
||||||
|
const video = document.createElement("video");
|
||||||
|
isolatedPlayer.style.isolation = "isolate";
|
||||||
|
videoParent.appendChild(video);
|
||||||
|
isolatedPlayer.appendChild(videoParent);
|
||||||
|
outsidePlayer.appendChild(isolatedPlayer);
|
||||||
|
page.appendChild(outsidePlayer);
|
||||||
|
document.body.appendChild(page);
|
||||||
|
|
||||||
|
const rect = {
|
||||||
|
left: 10,
|
||||||
|
top: 100,
|
||||||
|
right: 650,
|
||||||
|
bottom: 460,
|
||||||
|
width: 640,
|
||||||
|
height: 360
|
||||||
|
};
|
||||||
|
[video, videoParent, isolatedPlayer, outsidePlayer].forEach((element) => {
|
||||||
|
element.getBoundingClientRect = () => rect;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(window.getControllerMount(video)).toBe(isolatedPlayer);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the controller inside a nested fullscreen player subtree", async () => {
|
||||||
|
bootInject();
|
||||||
|
await flushAsyncWork(3);
|
||||||
|
|
||||||
|
const isolatedPlayer = document.createElement("div");
|
||||||
|
isolatedPlayer.style.isolation = "isolate";
|
||||||
|
const fullscreenPlayer = document.createElement("media-player");
|
||||||
|
const provider = document.createElement("media-provider");
|
||||||
|
const video = document.createElement("video");
|
||||||
|
provider.appendChild(video);
|
||||||
|
fullscreenPlayer.appendChild(provider);
|
||||||
|
isolatedPlayer.appendChild(fullscreenPlayer);
|
||||||
|
document.body.appendChild(isolatedPlayer);
|
||||||
|
|
||||||
|
const rect = {
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
right: 640,
|
||||||
|
bottom: 360,
|
||||||
|
width: 640,
|
||||||
|
height: 360
|
||||||
|
};
|
||||||
|
[video, provider, fullscreenPlayer, isolatedPlayer].forEach((element) => {
|
||||||
|
element.getBoundingClientRect = () => rect;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(window.getControllerMount(video)).toBe(isolatedPlayer);
|
||||||
|
expect(window.getControllerMount(video, fullscreenPlayer)).toBe(
|
||||||
|
fullscreenPlayer
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remounts the controller on fullscreen entry and restores it on exit", async () => {
|
||||||
|
bootInject();
|
||||||
|
await flushAsyncWork(3);
|
||||||
|
|
||||||
|
const isolatedPlayer = document.createElement("div");
|
||||||
|
isolatedPlayer.style.isolation = "isolate";
|
||||||
|
const fullscreenPlayer = document.createElement("media-player");
|
||||||
|
const provider = document.createElement("media-provider");
|
||||||
|
const video = document.createElement("video");
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
wrapper.className = "vsc-controller";
|
||||||
|
provider.appendChild(video);
|
||||||
|
fullscreenPlayer.appendChild(provider);
|
||||||
|
isolatedPlayer.append(fullscreenPlayer, wrapper);
|
||||||
|
document.body.appendChild(isolatedPlayer);
|
||||||
|
|
||||||
|
const rect = {
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
right: 640,
|
||||||
|
bottom: 360,
|
||||||
|
width: 640,
|
||||||
|
height: 360
|
||||||
|
};
|
||||||
|
[video, provider, fullscreenPlayer, isolatedPlayer].forEach((element) => {
|
||||||
|
element.getBoundingClientRect = () => rect;
|
||||||
|
});
|
||||||
|
|
||||||
|
const controller = {
|
||||||
|
video,
|
||||||
|
div: wrapper,
|
||||||
|
normalControllerMount: isolatedPlayer
|
||||||
|
};
|
||||||
|
window.setupControllerHostTracking(controller, wrapper, isolatedPlayer);
|
||||||
|
Object.defineProperty(document, "fullscreenElement", {
|
||||||
|
configurable: true,
|
||||||
|
value: fullscreenPlayer
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(window.syncControllerFullscreenMount(controller)).toBe(true);
|
||||||
|
expect(wrapper.parentElement).toBe(fullscreenPlayer);
|
||||||
|
|
||||||
|
Object.defineProperty(document, "fullscreenElement", {
|
||||||
|
configurable: true,
|
||||||
|
value: null
|
||||||
|
});
|
||||||
|
expect(window.syncControllerFullscreenMount(controller)).toBe(true);
|
||||||
|
expect(wrapper.parentElement).toBe(isolatedPlayer);
|
||||||
|
|
||||||
|
wrapper.remove();
|
||||||
|
controller.controllerHostCleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("force-rescans media missed during initial hydration", async () => {
|
||||||
|
bootInject();
|
||||||
|
await flushAsyncWork(3);
|
||||||
|
|
||||||
|
const video = document.createElement("video");
|
||||||
|
const source = document.createElement("source");
|
||||||
|
source.src = "https://example.org/late-source.mp4";
|
||||||
|
video.appendChild(source);
|
||||||
|
document.body.appendChild(video);
|
||||||
|
|
||||||
|
expect(video.vsc).toBeUndefined();
|
||||||
|
window.initializeWhenReady(document, true);
|
||||||
|
|
||||||
|
expect(video.vsc).toBeDefined();
|
||||||
|
expect(video.vsc.div).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only blocks page key handlers when the shortcut force option is enabled", async () => {
|
||||||
|
bootInject({
|
||||||
|
syncData: {
|
||||||
|
keyBindings: [
|
||||||
|
{
|
||||||
|
action: "faster",
|
||||||
|
code: "KeyD",
|
||||||
|
value: 0.1,
|
||||||
|
force: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await flushAsyncWork(3);
|
||||||
|
|
||||||
|
const video = document.createElement("video");
|
||||||
|
video.src = "https://example.org/shortcut.mp4";
|
||||||
|
document.body.appendChild(video);
|
||||||
|
window.ensureController(video, document.body);
|
||||||
|
let pageKeydowns = 0;
|
||||||
|
let pageCaptureKeydowns = 0;
|
||||||
|
document.addEventListener(
|
||||||
|
"keydown",
|
||||||
|
function() {
|
||||||
|
pageCaptureKeydowns += 1;
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
document.body.addEventListener("keydown", function() {
|
||||||
|
pageKeydowns += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const unblocked = new KeyboardEvent("keydown", {
|
||||||
|
key: "d",
|
||||||
|
code: "KeyD",
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true
|
||||||
|
});
|
||||||
|
video.dispatchEvent(unblocked);
|
||||||
|
|
||||||
|
expect(video.playbackRate).toBeCloseTo(1.1, 5);
|
||||||
|
expect(pageKeydowns).toBe(1);
|
||||||
|
expect(pageCaptureKeydowns).toBe(1);
|
||||||
|
expect(unblocked.defaultPrevented).toBe(false);
|
||||||
|
|
||||||
|
window.tc.settings.keyBindings[0].force = "true";
|
||||||
|
const blocked = new KeyboardEvent("keydown", {
|
||||||
|
key: "d",
|
||||||
|
code: "KeyD",
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true
|
||||||
|
});
|
||||||
|
video.dispatchEvent(blocked);
|
||||||
|
|
||||||
|
expect(video.playbackRate).toBeCloseTo(1.2, 5);
|
||||||
|
expect(pageKeydowns).toBe(1);
|
||||||
|
expect(pageCaptureKeydowns).toBe(1);
|
||||||
|
expect(blocked.defaultPrevented).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+31
-1
@@ -190,6 +190,34 @@ describe("inject runtime", () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("attaches a controller when Vidstack appends a source after the video", async () => {
|
||||||
|
await bootInject();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const provider = document.createElement("media-provider");
|
||||||
|
const video = document.createElement("video");
|
||||||
|
provider.appendChild(video);
|
||||||
|
document.body.appendChild(provider);
|
||||||
|
|
||||||
|
await flushAsyncWork();
|
||||||
|
vi.runAllTimers();
|
||||||
|
await flushAsyncWork();
|
||||||
|
expect(video.vsc).toBeUndefined();
|
||||||
|
|
||||||
|
const source = document.createElement("source");
|
||||||
|
source.src = "https://example.org/vidstack.mp4";
|
||||||
|
video.appendChild(source);
|
||||||
|
|
||||||
|
await flushAsyncWork();
|
||||||
|
vi.runAllTimers();
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
expect(video.vsc).toBeDefined();
|
||||||
|
expect(video.vsc.div).toBeDefined();
|
||||||
|
expect(video.vsc.div.classList.contains("vsc-nosource")).toBe(false);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
it("detects media in pre-existing shadow DOMs via delayed rescan", async () => {
|
it("detects media in pre-existing shadow DOMs via delayed rescan", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
loadHtmlString("<!doctype html><html><body></body></html>");
|
loadHtmlString("<!doctype html><html><body></body></html>");
|
||||||
@@ -222,7 +250,9 @@ describe("inject runtime", () => {
|
|||||||
loadScript("extension/content/inject.js");
|
loadScript("extension/content/inject.js");
|
||||||
|
|
||||||
// Fast-forward 3000ms for delayed rescan to trigger
|
// Fast-forward 3000ms for delayed rescan to trigger
|
||||||
vi.advanceTimersByTime(3000);
|
await vi.advanceTimersByTimeAsync(3000);
|
||||||
|
// The bounded discovery pass yields to an idle callback before traversing.
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
|
||||||
for (let i = 0; i < 5; i += 1) {
|
for (let i = 0; i < 5; i += 1) {
|
||||||
await flushAsyncWork();
|
await flushAsyncWork();
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ async function setupOptions(overrides = {}) {
|
|||||||
globalThis.fetch = vi.fn();
|
globalThis.fetch = vi.fn();
|
||||||
loadScript("extension/shared/controller-utils.js");
|
loadScript("extension/shared/controller-utils.js");
|
||||||
loadScript("extension/shared/key-bindings.js");
|
loadScript("extension/shared/key-bindings.js");
|
||||||
|
loadScript("extension/shared/settings-core.js");
|
||||||
loadScript("extension/shared/popup-controls.js");
|
loadScript("extension/shared/popup-controls.js");
|
||||||
loadScript("extension/shared/ui-icons.js");
|
loadScript("extension/shared/ui-icons.js");
|
||||||
loadScript("extension/options/lucide-client.js");
|
loadScript("extension/options/lucide-client.js");
|
||||||
@@ -28,7 +29,9 @@ describe("options page", () => {
|
|||||||
manifestVersion: "5.1.7.0",
|
manifestVersion: "5.1.7.0",
|
||||||
sync: {
|
sync: {
|
||||||
rememberSpeed: true,
|
rememberSpeed: true,
|
||||||
|
showAmbientLoopControls: true,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
shortcutTargetMode: "all",
|
||||||
subtitleNudgeEnabledByDefault: false,
|
subtitleNudgeEnabledByDefault: false,
|
||||||
popupMatchHoverControls: false,
|
popupMatchHoverControls: false,
|
||||||
popupControllerButtons: ["rewind", "settings", "advance", "advance"],
|
popupControllerButtons: ["rewind", "settings", "advance", "advance"],
|
||||||
@@ -38,8 +41,12 @@ describe("options page", () => {
|
|||||||
],
|
],
|
||||||
siteRules: [
|
siteRules: [
|
||||||
{
|
{
|
||||||
|
title: "YouTube testing",
|
||||||
pattern: "youtube.com",
|
pattern: "youtube.com",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
shortcutTargetMode: "all",
|
||||||
|
preferredSpeed: 2.4,
|
||||||
|
showAmbientLoopControls: false,
|
||||||
subtitleNudgeEnabledByDefault: false,
|
subtitleNudgeEnabledByDefault: false,
|
||||||
showPopupControlBar: false,
|
showPopupControlBar: false,
|
||||||
popupControllerButtons: ["advance", "settings", "advance"]
|
popupControllerButtons: ["advance", "settings", "advance"]
|
||||||
@@ -50,7 +57,9 @@ describe("options page", () => {
|
|||||||
|
|
||||||
expect(document.getElementById("app-version").textContent).toBe("5.1.7.0");
|
expect(document.getElementById("app-version").textContent).toBe("5.1.7.0");
|
||||||
expect(document.getElementById("rememberSpeed").checked).toBe(true);
|
expect(document.getElementById("rememberSpeed").checked).toBe(true);
|
||||||
|
expect(document.getElementById("showAmbientLoopControls").checked).toBe(true);
|
||||||
expect(document.getElementById("enabled").checked).toBe(false);
|
expect(document.getElementById("enabled").checked).toBe(false);
|
||||||
|
expect(document.getElementById("shortcutTargetMode").value).toBe("all");
|
||||||
expect(document.getElementById("subtitleNudgeEnabledByDefault").checked).toBe(
|
expect(document.getElementById("subtitleNudgeEnabledByDefault").checked).toBe(
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
@@ -63,6 +72,18 @@ describe("options page", () => {
|
|||||||
expect(document.querySelector(".site-rule .override-subtitleNudge").checked).toBe(
|
expect(document.querySelector(".site-rule .override-subtitleNudge").checked).toBe(
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
|
expect(document.querySelector(".site-rule .site-preferredSpeed").value).toBe(
|
||||||
|
"2.4"
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
document.querySelector(".site-rule .site-showAmbientLoopControls").checked
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
document.querySelector(".site-rule .site-shortcutTargetMode").value
|
||||||
|
).toBe("all");
|
||||||
|
expect(document.querySelector(".site-rule .site-title").value).toBe(
|
||||||
|
"YouTube testing"
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
document.querySelector(".site-rule .site-subtitleNudgeEnabledByDefault")
|
document.querySelector(".site-rule .site-subtitleNudgeEnabledByDefault")
|
||||||
.checked
|
.checked
|
||||||
@@ -70,12 +91,58 @@ describe("options page", () => {
|
|||||||
expect(globalThis.getPopupControlBarOrder()).toEqual(["rewind", "advance"]);
|
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 () => {
|
it("validates site rule regexes before saving", async () => {
|
||||||
const chrome = await setupOptions();
|
const chrome = await setupOptions();
|
||||||
chrome.storage.sync.set.mockClear();
|
chrome.storage.sync.set.mockClear();
|
||||||
globalThis.createSiteRule(null);
|
globalThis.createSiteRule(null);
|
||||||
const rule = document.querySelector(".site-rule");
|
const rule = document.querySelector(".site-rule");
|
||||||
rule.querySelector(".site-pattern").value = "/(/";
|
rule.querySelector(".site-pattern").value = "/youtube";
|
||||||
|
|
||||||
globalThis.save_options();
|
globalThis.save_options();
|
||||||
|
|
||||||
@@ -85,6 +152,48 @@ describe("options page", () => {
|
|||||||
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
|
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 () => {
|
it("shows a more-menu trigger for collapsed site rules and a collapse trigger when open", async () => {
|
||||||
await setupOptions({ sync: { siteRules: [] } });
|
await setupOptions({ sync: { siteRules: [] } });
|
||||||
|
|
||||||
@@ -166,6 +275,7 @@ describe("options page", () => {
|
|||||||
const chrome = await setupOptions();
|
const chrome = await setupOptions();
|
||||||
|
|
||||||
document.getElementById("rememberSpeed").checked = true;
|
document.getElementById("rememberSpeed").checked = true;
|
||||||
|
document.getElementById("showAmbientLoopControls").checked = true;
|
||||||
document.getElementById("hideWithControlsTimer").value = "20";
|
document.getElementById("hideWithControlsTimer").value = "20";
|
||||||
document.getElementById("controllerOpacity").value = "0";
|
document.getElementById("controllerOpacity").value = "0";
|
||||||
document.getElementById("controllerMarginTop").value = "250";
|
document.getElementById("controllerMarginTop").value = "250";
|
||||||
@@ -185,9 +295,14 @@ describe("options page", () => {
|
|||||||
|
|
||||||
globalThis.createSiteRule(null);
|
globalThis.createSiteRule(null);
|
||||||
const rule = document.querySelector(".site-rule");
|
const rule = document.querySelector(".site-rule");
|
||||||
|
rule.querySelector(".site-title").value = "My YouTube rule";
|
||||||
rule.querySelector(".site-pattern").value = "youtube.com";
|
rule.querySelector(".site-pattern").value = "youtube.com";
|
||||||
rule.querySelector(".override-playback").checked = true;
|
rule.querySelector(".override-playback").checked = true;
|
||||||
rule.querySelector(".site-rememberSpeed").checked = true;
|
rule.querySelector(".site-rememberSpeed").checked = true;
|
||||||
|
rule.querySelector(".site-showAmbientLoopControls").checked = false;
|
||||||
|
rule.querySelector(".site-preferredSpeed").value = "2.4";
|
||||||
|
rule.querySelector(".override-shortcut-target").checked = true;
|
||||||
|
rule.querySelector(".site-shortcutTargetMode").value = "all";
|
||||||
rule.querySelector(".override-opacity").checked = true;
|
rule.querySelector(".override-opacity").checked = true;
|
||||||
rule.querySelector(".site-controllerOpacity").value = "0";
|
rule.querySelector(".site-controllerOpacity").value = "0";
|
||||||
rule.querySelector(".override-subtitleNudge").checked = true;
|
rule.querySelector(".override-subtitleNudge").checked = true;
|
||||||
@@ -207,35 +322,90 @@ describe("options page", () => {
|
|||||||
|
|
||||||
globalThis.save_options();
|
globalThis.save_options();
|
||||||
|
|
||||||
expect(chrome.storage.sync.remove).toHaveBeenCalled();
|
const savedSettings = globalThis.vscExpandStoredSettings(
|
||||||
const savedSettings =
|
chrome.storage.sync.__state
|
||||||
chrome.storage.sync.set.mock.calls[
|
);
|
||||||
chrome.storage.sync.set.mock.calls.length - 1
|
|
||||||
][0];
|
|
||||||
|
|
||||||
expect(savedSettings.rememberSpeed).toBe(true);
|
expect(savedSettings.rememberSpeed).toBe(true);
|
||||||
|
expect(savedSettings.showAmbientLoopControls).toBe(true);
|
||||||
expect(savedSettings.hideWithControlsTimer).toBe(15);
|
expect(savedSettings.hideWithControlsTimer).toBe(15);
|
||||||
expect(savedSettings.controllerOpacity).toBe(0);
|
expect(savedSettings.controllerOpacity).toBe(0);
|
||||||
expect(savedSettings.controllerMarginTop).toBe(200);
|
expect(savedSettings.controllerMarginTop).toBe(200);
|
||||||
expect(savedSettings.controllerMarginBottom).toBe(0);
|
expect(savedSettings.controllerMarginBottom).toBe(0);
|
||||||
expect(savedSettings.subtitleNudgeEnabledByDefault).toBe(false);
|
expect(savedSettings.subtitleNudgeEnabledByDefault).toBe(false);
|
||||||
expect(savedSettings.subtitleNudgeInterval).toBe(10);
|
expect(savedSettings.subtitleNudgeInterval).toBe(250);
|
||||||
expect(savedSettings.showPopupControlBar).toBe(false);
|
expect(savedSettings.showPopupControlBar).toBe(false);
|
||||||
expect(savedSettings.popupMatchHoverControls).toBe(false);
|
expect(savedSettings.popupMatchHoverControls).toBe(false);
|
||||||
expect(savedSettings.popupControllerButtons).toEqual(["rewind", "faster"]);
|
expect(savedSettings.popupControllerButtons).toEqual(["rewind", "faster"]);
|
||||||
expect(savedSettings.siteRules).toEqual(
|
expect(savedSettings.siteRules).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
title: "My YouTube rule",
|
||||||
pattern: "youtube.com",
|
pattern: "youtube.com",
|
||||||
rememberSpeed: true,
|
rememberSpeed: true,
|
||||||
|
showAmbientLoopControls: false,
|
||||||
|
shortcutTargetMode: "all",
|
||||||
|
preferredSpeed: 2.4,
|
||||||
controllerOpacity: 0,
|
controllerOpacity: 0,
|
||||||
enableSubtitleNudge: true,
|
enableSubtitleNudge: true,
|
||||||
subtitleNudgeEnabledByDefault: false,
|
subtitleNudgeEnabledByDefault: false,
|
||||||
subtitleNudgeInterval: 75,
|
subtitleNudgeInterval: 250,
|
||||||
showPopupControlBar: false,
|
showPopupControlBar: false,
|
||||||
popupControllerButtons: ["advance"]
|
popupControllerButtons: ["advance"]
|
||||||
})
|
})
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("restores managed defaults and clears private local UI/runtime data", async () => {
|
||||||
|
const chrome = await setupOptions({
|
||||||
|
sync: {
|
||||||
|
rememberSpeed: true,
|
||||||
|
siteRules: [],
|
||||||
|
lastSpeed: 1.6,
|
||||||
|
futureRuntimeValue: "keep"
|
||||||
|
},
|
||||||
|
local: {
|
||||||
|
customButtonIcons: { faster: { slug: "rocket" } },
|
||||||
|
rememberedSpeeds: { "https://example.com/video.mp4": 1.75 },
|
||||||
|
unrelatedLocalValue: "keep"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
globalThis.restore_defaults();
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
const raw = chrome.storage.sync.__state;
|
||||||
|
const restored = globalThis.vscExpandStoredSettings(raw);
|
||||||
|
expect(restored.rememberSpeed).toBe(false);
|
||||||
|
expect(restored.siteRules).toEqual(
|
||||||
|
globalThis.vscGetSettingsDefaults().siteRules
|
||||||
|
);
|
||||||
|
expect(raw.lastSpeed).toBe(1);
|
||||||
|
expect(raw.futureRuntimeValue).toBe("keep");
|
||||||
|
expect(chrome.storage.local.__state.customButtonIcons).toBeUndefined();
|
||||||
|
expect(chrome.storage.local.__state.rememberedSpeeds).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
chrome.storage.local.__state.rememberedSpeedsResetAt
|
||||||
|
).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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+268
-19
@@ -32,6 +32,7 @@ function bootOptions(options) {
|
|||||||
|
|
||||||
evaluateScript("extension/shared/controller-utils.js");
|
evaluateScript("extension/shared/controller-utils.js");
|
||||||
evaluateScript("extension/shared/key-bindings.js");
|
evaluateScript("extension/shared/key-bindings.js");
|
||||||
|
evaluateScript("extension/shared/settings-core.js");
|
||||||
evaluateScript("extension/shared/popup-controls.js");
|
evaluateScript("extension/shared/popup-controls.js");
|
||||||
evaluateScript("extension/shared/ui-icons.js");
|
evaluateScript("extension/shared/ui-icons.js");
|
||||||
evaluateScript("extension/options/lucide-client.js");
|
evaluateScript("extension/options/lucide-client.js");
|
||||||
@@ -49,6 +50,270 @@ describe("options.js", () => {
|
|||||||
delete global.fetch;
|
delete global.fetch;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects invalid zero-valued speed steps instead of saving broken shortcuts", async () => {
|
||||||
|
const chrome = bootOptions();
|
||||||
|
await flushAsyncWork(3);
|
||||||
|
const fasterRow = Array.from(
|
||||||
|
document.querySelectorAll("#customs .shortcut-row")
|
||||||
|
).find((row) => row.dataset.action === "faster");
|
||||||
|
fasterRow.querySelector(".customValue").value = "0";
|
||||||
|
chrome.storage.sync.set.mockClear();
|
||||||
|
|
||||||
|
window.save_options();
|
||||||
|
|
||||||
|
expect(document.getElementById("status").textContent).toContain(
|
||||||
|
"must be greater than 0"
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
window.createSiteRule({
|
||||||
|
pattern: "example.org",
|
||||||
|
shortcuts: [
|
||||||
|
{
|
||||||
|
action: "faster",
|
||||||
|
code: "KeyD",
|
||||||
|
value: 0.1,
|
||||||
|
disabled: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const rule = document.getElementById("siteRulesContainer").lastElementChild;
|
||||||
|
rule.querySelector('.shortcut-row[data-action="faster"] .customKey').vscBinding =
|
||||||
|
null;
|
||||||
|
document.getElementById("rememberSpeed").checked = true;
|
||||||
|
chrome.storage.sync.set.mockClear();
|
||||||
|
chrome.storage.sync.remove.mockClear();
|
||||||
|
|
||||||
|
window.save_options();
|
||||||
|
|
||||||
|
expect(document.getElementById("status").textContent).toContain(
|
||||||
|
"cannot be empty"
|
||||||
|
);
|
||||||
|
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
|
||||||
|
expect(chrome.storage.sync.remove).not.toHaveBeenCalled();
|
||||||
|
expect(chrome.storage.sync._dump().rememberSpeed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the supported unexposed subtitle nudge amount when saving", async () => {
|
||||||
|
const chrome = bootOptions({ syncData: { subtitleNudgeAmount: 0.004 } });
|
||||||
|
await flushAsyncWork(3);
|
||||||
|
|
||||||
|
window.save_options();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
window.vscExpandStoredSettings(chrome.storage.sync._dump())
|
||||||
|
.subtitleNudgeAmount
|
||||||
|
).toBe(0.004);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not turn a blank per-site preferred speed into the 1.8 fast default", async () => {
|
||||||
|
const chrome = bootOptions();
|
||||||
|
await flushAsyncWork(3);
|
||||||
|
const shortsRule = Array.from(document.querySelectorAll(".site-rule")).find(
|
||||||
|
(rule) => rule.querySelector(".site-title").value === "YouTube Shorts"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(shortsRule).toBeTruthy();
|
||||||
|
expect(shortsRule.querySelector(".site-preferredSpeed").value).toBe("");
|
||||||
|
window.save_options();
|
||||||
|
|
||||||
|
const expanded = window.vscExpandStoredSettings(chrome.storage.sync._dump());
|
||||||
|
const savedShortsRule = expanded.siteRules.find(
|
||||||
|
(rule) => rule.title === "YouTube Shorts"
|
||||||
|
);
|
||||||
|
expect(savedShortsRule.preferredSpeed).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the loaded UI and blocks saves when a settings reload fails", async () => {
|
||||||
|
const chrome = bootOptions({
|
||||||
|
syncData: {
|
||||||
|
rememberSpeed: true,
|
||||||
|
siteRules: [{ pattern: "example.org", enabled: true }]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await flushAsyncWork(3);
|
||||||
|
const originalRuleCount = document.querySelectorAll(".site-rule").length;
|
||||||
|
|
||||||
|
chrome.storage.sync.get.mockImplementationOnce(function (_keys, callback) {
|
||||||
|
chrome.runtime.lastError = { message: "temporary read failure" };
|
||||||
|
callback({});
|
||||||
|
chrome.runtime.lastError = null;
|
||||||
|
});
|
||||||
|
chrome.storage.sync.set.mockClear();
|
||||||
|
chrome.storage.sync.remove.mockClear();
|
||||||
|
|
||||||
|
window.restore_options();
|
||||||
|
|
||||||
|
expect(document.getElementById("rememberSpeed").checked).toBe(true);
|
||||||
|
expect(document.querySelectorAll(".site-rule")).toHaveLength(
|
||||||
|
originalRuleCount
|
||||||
|
);
|
||||||
|
expect(document.getElementById("save").disabled).toBe(true);
|
||||||
|
expect(document.getElementById("status").textContent).toContain(
|
||||||
|
"temporary read failure"
|
||||||
|
);
|
||||||
|
|
||||||
|
document.getElementById("rememberSpeed").checked = false;
|
||||||
|
window.save_options();
|
||||||
|
|
||||||
|
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
|
||||||
|
expect(chrome.storage.sync.remove).not.toHaveBeenCalled();
|
||||||
|
expect(chrome.storage.sync._dump().rememberSpeed).toBe(true);
|
||||||
|
expect(document.getElementById("status").textContent).toContain(
|
||||||
|
"Settings have not loaded"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not clear local data or live rows when the sync reset fails", async () => {
|
||||||
|
const chrome = bootOptions({
|
||||||
|
syncData: {
|
||||||
|
keyBindings: [
|
||||||
|
{ action: "display", code: "KeyV", value: 0, predefined: true },
|
||||||
|
{ action: "pause", code: "KeyQ", value: 0, predefined: false }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
localData: {
|
||||||
|
customButtonIcons: { faster: { slug: "rocket" } },
|
||||||
|
rememberedSpeeds: { "https://example.org/video.mp4": 1.5 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await flushAsyncWork(3);
|
||||||
|
expect(
|
||||||
|
document.querySelector('.shortcut-row.customs[data-action="pause"]')
|
||||||
|
).not.toBeNull();
|
||||||
|
|
||||||
|
chrome.storage.local.remove.mockClear();
|
||||||
|
chrome.storage.sync.remove.mockImplementationOnce(function (_keys, callback) {
|
||||||
|
chrome.runtime.lastError = { message: "sync reset failed" };
|
||||||
|
callback();
|
||||||
|
chrome.runtime.lastError = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
window.restore_defaults();
|
||||||
|
await flushAsyncWork(3);
|
||||||
|
|
||||||
|
expect(chrome.storage.local.remove).not.toHaveBeenCalled();
|
||||||
|
expect(chrome.storage.local._dump().customButtonIcons).toBeDefined();
|
||||||
|
expect(chrome.storage.local._dump().rememberedSpeeds).toBeDefined();
|
||||||
|
expect(
|
||||||
|
document.querySelector('.shortcut-row.customs[data-action="pause"]')
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(document.getElementById("save").disabled).toBe(false);
|
||||||
|
expect(document.getElementById("status").textContent).toContain(
|
||||||
|
"sync reset failed"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a local reset failure after resetting sync defaults", async () => {
|
||||||
|
const chrome = bootOptions({
|
||||||
|
syncData: { rememberSpeed: true, lastSpeed: 1.75 },
|
||||||
|
localData: { customButtonIcons: { faster: { slug: "rocket" } } }
|
||||||
|
});
|
||||||
|
await flushAsyncWork(3);
|
||||||
|
|
||||||
|
chrome.storage.local.remove.mockImplementationOnce(function (_keys, callback) {
|
||||||
|
chrome.runtime.lastError = { message: "local reset failed" };
|
||||||
|
callback();
|
||||||
|
chrome.runtime.lastError = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
window.restore_defaults();
|
||||||
|
await flushAsyncWork(3);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
window.vscExpandStoredSettings(chrome.storage.sync._dump()).rememberSpeed
|
||||||
|
).toBe(false);
|
||||||
|
expect(chrome.storage.sync._dump().lastSpeed).toBe(1);
|
||||||
|
expect(chrome.storage.local._dump().customButtonIcons).toBeDefined();
|
||||||
|
expect(document.getElementById("save").disabled).toBe(false);
|
||||||
|
expect(document.getElementById("status").textContent).toContain(
|
||||||
|
"local reset failed"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not remove local data when the speed reset marker cannot be written", async () => {
|
||||||
|
const chrome = bootOptions({
|
||||||
|
localData: {
|
||||||
|
customButtonIcons: { faster: { slug: "rocket" } },
|
||||||
|
rememberedSpeeds: { "https://example.org/video.mp4": 1.5 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await flushAsyncWork(3);
|
||||||
|
chrome.storage.local.remove.mockClear();
|
||||||
|
chrome.storage.local.set.mockImplementationOnce(function (_items, callback) {
|
||||||
|
chrome.runtime.lastError = { message: "reset marker failed" };
|
||||||
|
callback();
|
||||||
|
chrome.runtime.lastError = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
window.restore_defaults();
|
||||||
|
await flushAsyncWork(3);
|
||||||
|
|
||||||
|
expect(chrome.storage.local.remove).not.toHaveBeenCalled();
|
||||||
|
expect(chrome.storage.local._dump().customButtonIcons).toBeDefined();
|
||||||
|
expect(chrome.storage.local._dump().rememberedSpeeds).toBeDefined();
|
||||||
|
expect(document.getElementById("status").textContent).toContain(
|
||||||
|
"reset marker failed"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("restores saved settings, bindings, site rules, and popup bar order", async () => {
|
it("restores saved settings, bindings, site rules, and popup bar order", async () => {
|
||||||
bootOptions({
|
bootOptions({
|
||||||
syncData: {
|
syncData: {
|
||||||
@@ -145,32 +410,16 @@ describe("options.js", () => {
|
|||||||
|
|
||||||
window.save_options();
|
window.save_options();
|
||||||
|
|
||||||
expect(chrome.storage.sync.remove).toHaveBeenCalledWith(
|
const savedSettings = window.vscExpandStoredSettings(
|
||||||
[
|
chrome.storage.sync._dump()
|
||||||
"resetSpeed",
|
|
||||||
"speedStep",
|
|
||||||
"fastSpeed",
|
|
||||||
"rewindTime",
|
|
||||||
"advanceTime",
|
|
||||||
"resetKeyCode",
|
|
||||||
"slowerKeyCode",
|
|
||||||
"fasterKeyCode",
|
|
||||||
"rewindKeyCode",
|
|
||||||
"advanceKeyCode",
|
|
||||||
"fastKeyCode",
|
|
||||||
"blacklist"
|
|
||||||
],
|
|
||||||
expect.any(Function)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const savedSettings = chrome.storage.sync.set.mock.calls.at(-1)[0];
|
|
||||||
expect(savedSettings.rememberSpeed).toBe(true);
|
expect(savedSettings.rememberSpeed).toBe(true);
|
||||||
expect(savedSettings.hideWithControlsTimer).toBe(15);
|
expect(savedSettings.hideWithControlsTimer).toBe(15);
|
||||||
expect(savedSettings.controllerLocation).toBe("bottom-left");
|
expect(savedSettings.controllerLocation).toBe("bottom-left");
|
||||||
expect(savedSettings.controllerMarginTop).toBe(200);
|
expect(savedSettings.controllerMarginTop).toBe(200);
|
||||||
expect(savedSettings.controllerMarginBottom).toBe(0);
|
expect(savedSettings.controllerMarginBottom).toBe(0);
|
||||||
expect(savedSettings.popupControllerButtons).toEqual(["advance", "rewind"]);
|
expect(savedSettings.popupControllerButtons).toEqual(["advance", "rewind"]);
|
||||||
expect(savedSettings.siteRules).toHaveLength(3);
|
expect(savedSettings.siteRules).toHaveLength(4);
|
||||||
expect(savedSettings.siteRules).toEqual(
|
expect(savedSettings.siteRules).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ async function setupPopup(overrides = {}) {
|
|||||||
loadHtml("extension/popup/popup.html");
|
loadHtml("extension/popup/popup.html");
|
||||||
globalThis.chrome = createChromeMock(overrides);
|
globalThis.chrome = createChromeMock(overrides);
|
||||||
window.chrome = globalThis.chrome;
|
window.chrome = globalThis.chrome;
|
||||||
|
loadScript("extension/shared/settings-core.js");
|
||||||
loadScript("extension/shared/site-rules.js");
|
loadScript("extension/shared/site-rules.js");
|
||||||
loadScript("extension/shared/popup-controls.js");
|
loadScript("extension/shared/popup-controls.js");
|
||||||
loadScript("extension/shared/ui-icons.js");
|
loadScript("extension/shared/ui-icons.js");
|
||||||
@@ -34,6 +35,12 @@ describe("popup UI", () => {
|
|||||||
expect(
|
expect(
|
||||||
document.querySelectorAll("#popupControlBar button").length
|
document.querySelectorAll("#popupControlBar button").length
|
||||||
).toBeGreaterThan(0);
|
).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 () => {
|
it("shows controls when globally disabled but a whitelist site rule matches", async () => {
|
||||||
@@ -69,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();
|
const chrome = await setupPopup();
|
||||||
chrome.storage.sync.set.mockClear();
|
chrome.storage.sync.set.mockClear();
|
||||||
chrome.browserAction.setIcon.mockClear();
|
chrome.browserAction.setIcon.mockClear();
|
||||||
@@ -83,13 +90,10 @@ describe("popup UI", () => {
|
|||||||
expect(document.getElementById("enable").classList.contains("hide")).toBe(
|
expect(document.getElementById("enable").classList.contains("hide")).toBe(
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
expect(chrome.browserAction.setIcon).toHaveBeenCalledWith({
|
expect(document.getElementById("status").textContent).toBe(
|
||||||
path: {
|
"Disabled. Open pages update automatically."
|
||||||
19: "assets/icons/icon19_disabled.png",
|
);
|
||||||
38: "assets/icons/icon38_disabled.png",
|
expect(chrome.browserAction.setIcon).not.toHaveBeenCalled();
|
||||||
48: "assets/icons/icon48_disabled.png"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles refresh responses for unsupported and successful pages", async () => {
|
it("handles refresh responses for unsupported and successful pages", async () => {
|
||||||
@@ -152,10 +156,44 @@ describe("popup UI", () => {
|
|||||||
1,
|
1,
|
||||||
{
|
{
|
||||||
action: "set_force_last_saved_speed",
|
action: "set_force_last_saved_speed",
|
||||||
enabled: true,
|
enabled: true
|
||||||
speed: 1.8
|
|
||||||
},
|
},
|
||||||
expect.any(Function)
|
expect.any(Function)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the global force setting unchanged when a site rule controls it", async () => {
|
||||||
|
const chrome = await setupPopup({
|
||||||
|
sync: {
|
||||||
|
forceLastSavedSpeed: false,
|
||||||
|
siteRules: [
|
||||||
|
{
|
||||||
|
pattern: "example.com",
|
||||||
|
enabled: true,
|
||||||
|
forceLastSavedSpeed: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const forceButton = document.getElementById("forceLastSavedSpeed");
|
||||||
|
expect(forceButton.getAttribute("aria-pressed")).toBe("true");
|
||||||
|
const storageBeforeClick = JSON.parse(
|
||||||
|
JSON.stringify(chrome.storage.sync.__state)
|
||||||
|
);
|
||||||
|
chrome.storage.sync.set.mockClear();
|
||||||
|
chrome.storage.sync.remove.mockClear();
|
||||||
|
chrome.tabs.sendMessage.mockClear();
|
||||||
|
|
||||||
|
forceButton.click();
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
expect(chrome.storage.sync.__state).toEqual(storageBeforeClick);
|
||||||
|
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
|
||||||
|
expect(chrome.storage.sync.remove).not.toHaveBeenCalled();
|
||||||
|
expect(chrome.tabs.sendMessage).not.toHaveBeenCalled();
|
||||||
|
expect(forceButton.getAttribute("aria-pressed")).toBe("true");
|
||||||
|
expect(document.getElementById("status").textContent).toBe(
|
||||||
|
"Force setting is controlled by this site rule."
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+139
-14
@@ -6,6 +6,7 @@ const {
|
|||||||
installCommonWindowMocks,
|
installCommonWindowMocks,
|
||||||
loadHtmlFile
|
loadHtmlFile
|
||||||
} = require("./helpers/extension-test-utils");
|
} = require("./helpers/extension-test-utils");
|
||||||
|
const manifest = require("../extension/manifest.json");
|
||||||
|
|
||||||
function bootPopup(options) {
|
function bootPopup(options) {
|
||||||
const config = options || {};
|
const config = options || {};
|
||||||
@@ -17,6 +18,7 @@ function bootPopup(options) {
|
|||||||
manifest: { version: "9.9.9-test" },
|
manifest: { version: "9.9.9-test" },
|
||||||
syncData: config.syncData,
|
syncData: config.syncData,
|
||||||
localData: config.localData,
|
localData: config.localData,
|
||||||
|
runtimeSendMessageImpl: config.runtimeSendMessageImpl,
|
||||||
tabsQueryResult: [
|
tabsQueryResult: [
|
||||||
config.activeTab || { id: 99, active: true, url: "https://example.com/" }
|
config.activeTab || { id: 99, active: true, url: "https://example.com/" }
|
||||||
]
|
]
|
||||||
@@ -46,6 +48,7 @@ function bootPopup(options) {
|
|||||||
global.chrome = chrome;
|
global.chrome = chrome;
|
||||||
window.chrome = chrome;
|
window.chrome = chrome;
|
||||||
|
|
||||||
|
evaluateScript("extension/shared/settings-core.js");
|
||||||
evaluateScript("extension/shared/site-rules.js");
|
evaluateScript("extension/shared/site-rules.js");
|
||||||
evaluateScript("extension/shared/popup-controls.js");
|
evaluateScript("extension/shared/popup-controls.js");
|
||||||
evaluateScript("extension/shared/ui-icons.js");
|
evaluateScript("extension/shared/ui-icons.js");
|
||||||
@@ -62,6 +65,10 @@ describe("popup.js", () => {
|
|||||||
delete global.chrome;
|
delete global.chrome;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("declares activeTab for the popup all-frame speed snapshot", () => {
|
||||||
|
expect(manifest.permissions).toContain("activeTab");
|
||||||
|
});
|
||||||
|
|
||||||
it("renders the popup disabled state when a site rule disables Speeder", async () => {
|
it("renders the popup disabled state when a site rule disables Speeder", async () => {
|
||||||
bootPopup({
|
bootPopup({
|
||||||
syncData: {
|
syncData: {
|
||||||
@@ -101,10 +108,10 @@ describe("popup.js", () => {
|
|||||||
executeScriptImpl: (tabId, details, callback) => {
|
executeScriptImpl: (tabId, details, callback) => {
|
||||||
speedQueryCount += 1;
|
speedQueryCount += 1;
|
||||||
callback(
|
callback(
|
||||||
speedQueryCount <= 2
|
speedQueryCount === 1
|
||||||
? [
|
? [
|
||||||
{ speed: 1.25, preferred: false },
|
{ speed: 1.25, preferred: false },
|
||||||
{ speed: 1.5, preferred: true }
|
{ speed: 1.5, frameToken: "playing-frame", preferred: true }
|
||||||
]
|
]
|
||||||
: [{ speed: 1.75, preferred: true }]
|
: [{ speed: 1.75, preferred: true }]
|
||||||
);
|
);
|
||||||
@@ -132,12 +139,136 @@ describe("popup.js", () => {
|
|||||||
|
|
||||||
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(
|
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(
|
||||||
99,
|
99,
|
||||||
{ action: "run_action", actionName: "faster" },
|
{
|
||||||
|
action: "run_action",
|
||||||
|
actionName: "faster",
|
||||||
|
targetFrameToken: "playing-frame"
|
||||||
|
},
|
||||||
expect.any(Function)
|
expect.any(Function)
|
||||||
);
|
);
|
||||||
expect(document.querySelector("#popupSpeed").textContent).toBe("1.75");
|
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 () => {
|
it("toggles enablement and closes after a successful refresh", async () => {
|
||||||
const chrome = bootPopup({
|
const chrome = bootPopup({
|
||||||
syncData: {
|
syncData: {
|
||||||
@@ -152,17 +283,11 @@ describe("popup.js", () => {
|
|||||||
expect(document.querySelector("#disable").classList.contains("hide")).toBe(true);
|
expect(document.querySelector("#disable").classList.contains("hide")).toBe(true);
|
||||||
|
|
||||||
document.querySelector("#enable").click();
|
document.querySelector("#enable").click();
|
||||||
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
|
expect(chrome.storage.sync._dump()).not.toHaveProperty("enabled");
|
||||||
{ enabled: true },
|
expect(
|
||||||
expect.any(Function)
|
window.vscExpandStoredSettings(chrome.storage.sync._dump()).enabled
|
||||||
);
|
).toBe(true);
|
||||||
expect(chrome.browserAction.setIcon).toHaveBeenCalledWith({
|
expect(chrome.browserAction.setIcon).not.toHaveBeenCalled();
|
||||||
path: {
|
|
||||||
19: "assets/icons/icon19.png",
|
|
||||||
38: "assets/icons/icon38.png",
|
|
||||||
48: "assets/icons/icon48.png"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelector("#refresh").click();
|
document.querySelector("#refresh").click();
|
||||||
expect(document.querySelector("#status").textContent).toContain("Closing");
|
expect(document.querySelector("#status").textContent).toContain("Closing");
|
||||||
|
|||||||
@@ -0,0 +1,398 @@
|
|||||||
|
const {
|
||||||
|
evaluateScript,
|
||||||
|
loadHtmlString
|
||||||
|
} = require("./helpers/extension-test-utils");
|
||||||
|
|
||||||
|
function bootSettingsCore() {
|
||||||
|
loadHtmlString("<!doctype html><html><head></head><body></body></html>");
|
||||||
|
evaluateScript("extension/shared/settings-core.js");
|
||||||
|
evaluateScript("extension/shared/site-rules.js");
|
||||||
|
}
|
||||||
|
|
||||||
|
function youtubeShortsRule(settings) {
|
||||||
|
return settings.siteRules.find(function (rule) {
|
||||||
|
return String(rule.pattern).includes("\\/shorts\\/");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("canonical settings storage", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
bootSettingsCore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expands missing settings to the built-in Shorts remember-speed rule", () => {
|
||||||
|
const settings = window.vscExpandStoredSettings({});
|
||||||
|
const shortsRule = youtubeShortsRule(settings);
|
||||||
|
|
||||||
|
expect(shortsRule).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
title: "YouTube Shorts",
|
||||||
|
enabled: true,
|
||||||
|
rememberSpeed: true,
|
||||||
|
controllerMarginTop: 60,
|
||||||
|
controllerMarginBottom: 85
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(settings.shortcutTargetMode).toBe("closest");
|
||||||
|
expect(settings.showAmbientLoopControls).toBe(false);
|
||||||
|
expect(settings.subtitleNudgeInterval).toBe(250);
|
||||||
|
expect(settings.siteRules[0].enableSubtitleNudge).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips global and site-specific ambient-loop visibility", () => {
|
||||||
|
const settings = window.vscExpandStoredSettings({
|
||||||
|
showAmbientLoopControls: true,
|
||||||
|
siteRules: [
|
||||||
|
{
|
||||||
|
pattern: "cnn.com",
|
||||||
|
showAmbientLoopControls: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const restored = window.vscExpandStoredSettings(
|
||||||
|
window.vscBuildStoredSettingsDiff(settings)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(restored.showAmbientLoopControls).toBe(true);
|
||||||
|
expect(restored.siteRules).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
pattern: "cnn.com",
|
||||||
|
showAmbientLoopControls: false
|
||||||
|
})
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips global and site-specific shortcut targeting", () => {
|
||||||
|
const settings = window.vscExpandStoredSettings({
|
||||||
|
shortcutTargetMode: "all",
|
||||||
|
siteRules: [
|
||||||
|
{
|
||||||
|
title: "One player at a time",
|
||||||
|
pattern: "example.org",
|
||||||
|
shortcutTargetMode: "closest"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const stored = window.vscBuildStoredSettingsDiff(settings);
|
||||||
|
const restored = window.vscExpandStoredSettings(stored);
|
||||||
|
|
||||||
|
expect(restored.shortcutTargetMode).toBe("all");
|
||||||
|
expect(restored.siteRules).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
pattern: "example.org",
|
||||||
|
shortcutTargetMode: "closest"
|
||||||
|
})
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("provides titles for built-in video rules and round-trips title edits sparsely", () => {
|
||||||
|
const settings = window.vscExpandStoredSettings({});
|
||||||
|
expect(settings.siteRules.map((rule) => rule.title)).toEqual([
|
||||||
|
"YouTube videos",
|
||||||
|
"YouTube Shorts",
|
||||||
|
"YouTube Shorts (mobile)"
|
||||||
|
]);
|
||||||
|
|
||||||
|
settings.siteRules[1].title = "Short-form videos";
|
||||||
|
const stored = window.vscBuildStoredSettingsDiff(settings);
|
||||||
|
expect(stored.siteRules).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
pattern: settings.siteRules[1].pattern,
|
||||||
|
title: "Short-form videos"
|
||||||
|
})
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(window.vscExpandStoredSettings(stored).siteRules[1].title).toBe(
|
||||||
|
"Short-form videos"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an intentionally cleared built-in title cleared", () => {
|
||||||
|
const settings = window.vscExpandStoredSettings({});
|
||||||
|
const pattern = settings.siteRules[0].pattern;
|
||||||
|
delete settings.siteRules[0].title;
|
||||||
|
|
||||||
|
const stored = window.vscBuildStoredSettingsDiff(settings);
|
||||||
|
const tombstone = stored.siteRulesMeta.removedDefaultRuleKeys.find(
|
||||||
|
(entry) => entry.pattern === pattern
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tombstone.keys).toContain("title");
|
||||||
|
expect(window.vscExpandStoredSettings(stored).siteRules[0]).not.toHaveProperty(
|
||||||
|
"title"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips an intentionally empty site-rule list", () => {
|
||||||
|
const expanded = window.vscExpandStoredSettings({ siteRules: [] });
|
||||||
|
expect(expanded.siteRules).toEqual([]);
|
||||||
|
|
||||||
|
const stored = window.vscBuildStoredSettingsDiff(expanded);
|
||||||
|
expect(stored.siteRulesFormat).toBe("defaults-diff-v2");
|
||||||
|
expect(stored.siteRules).toBeUndefined();
|
||||||
|
expect(stored.siteRulesMeta.removedDefaultPatterns).toHaveLength(3);
|
||||||
|
expect(window.vscExpandStoredSettings(stored).siteRules).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps removals from built-in rules through sparse persistence", () => {
|
||||||
|
const settings = window.vscExpandStoredSettings({});
|
||||||
|
const shortsRule = youtubeShortsRule(settings);
|
||||||
|
delete shortsRule.rememberSpeed;
|
||||||
|
|
||||||
|
const stored = window.vscBuildStoredSettingsDiff(settings);
|
||||||
|
const tombstone = stored.siteRulesMeta.removedDefaultRuleKeys.find(
|
||||||
|
function (entry) {
|
||||||
|
return entry.pattern === shortsRule.pattern;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(tombstone.keys).toContain("rememberSpeed");
|
||||||
|
|
||||||
|
const restoredRule = youtubeShortsRule(
|
||||||
|
window.vscExpandStoredSettings(stored)
|
||||||
|
);
|
||||||
|
expect(restoredRule).not.toHaveProperty("rememberSpeed");
|
||||||
|
expect(restoredRule.controllerMarginTop).toBe(60);
|
||||||
|
expect(restoredRule.controllerMarginBottom).toBe(85);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expands legacy defaults-diff-v1 site-rule patches", () => {
|
||||||
|
const defaults = window.vscGetSettingsDefaults();
|
||||||
|
const shortsPattern = youtubeShortsRule(defaults).pattern;
|
||||||
|
const settings = window.vscExpandStoredSettings({
|
||||||
|
siteRulesFormat: "defaults-diff-v1",
|
||||||
|
siteRules: [{ pattern: shortsPattern, controllerMarginTop: 72 }]
|
||||||
|
});
|
||||||
|
const shortsRule = youtubeShortsRule(settings);
|
||||||
|
|
||||||
|
expect(shortsRule.rememberSpeed).toBe(true);
|
||||||
|
expect(shortsRule.controllerMarginTop).toBe(72);
|
||||||
|
expect(shortsRule.controllerMarginBottom).toBe(85);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("migrates legacy shortcut, hide, and blacklist values", () => {
|
||||||
|
const settings = window.vscExpandStoredSettings({
|
||||||
|
speedStep: 0.25,
|
||||||
|
rewindTime: 7,
|
||||||
|
advanceTime: 14,
|
||||||
|
fastSpeed: 2.25,
|
||||||
|
displayKeyCode: 81,
|
||||||
|
fasterKeyCode: 70,
|
||||||
|
hideWithYouTubeControls: true,
|
||||||
|
blacklist: "bad.test\nhttps://blocked.test/watch"
|
||||||
|
});
|
||||||
|
const bindings = Object.fromEntries(
|
||||||
|
settings.keyBindings.map(function (binding) {
|
||||||
|
return [binding.action, binding];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(bindings.slower.value).toBe(0.25);
|
||||||
|
expect(bindings.faster).toEqual(
|
||||||
|
expect.objectContaining({ code: "KeyF", value: 0.25 })
|
||||||
|
);
|
||||||
|
expect(bindings.rewind.value).toBe(7);
|
||||||
|
expect(bindings.advance.value).toBe(14);
|
||||||
|
expect(bindings.fast.value).toBe(2.25);
|
||||||
|
expect(bindings.display.code).toBe("KeyQ");
|
||||||
|
expect(settings.hideWithControls).toBe(true);
|
||||||
|
expect(settings.siteRules).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{ pattern: "bad.test", enabled: false },
|
||||||
|
{ pattern: "https://blocked.test/watch", enabled: false }
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds the mobile Shorts default to legacy full default-rule lists", () => {
|
||||||
|
const defaults = window.vscGetSettingsDefaults();
|
||||||
|
const legacyRules = defaults.siteRules.slice(0, 2).map(function(rule) {
|
||||||
|
return Object.assign({}, rule, {
|
||||||
|
forceLastSavedSpeed: false,
|
||||||
|
audioBoolean: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const expanded = window.vscExpandStoredSettings({ siteRules: legacyRules });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
expanded.siteRules.some(function(rule) {
|
||||||
|
return String(rule.pattern).includes("m\\.youtube") &&
|
||||||
|
rule.rememberSpeed === true;
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
expect(expanded.siteRules.map((rule) => rule.title)).toEqual([
|
||||||
|
"YouTube videos",
|
||||||
|
"YouTube Shorts",
|
||||||
|
"YouTube Shorts (mobile)"
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes imported rule types and unsafe numeric ranges", () => {
|
||||||
|
const expanded = window.vscExpandStoredSettings({
|
||||||
|
enabled: "false",
|
||||||
|
siteRules: [
|
||||||
|
{
|
||||||
|
title: " Example videos ",
|
||||||
|
pattern: "example.org",
|
||||||
|
enabled: "false",
|
||||||
|
rememberSpeed: "false",
|
||||||
|
forceLastSavedSpeed: "true",
|
||||||
|
controllerOpacity: 3,
|
||||||
|
subtitleNudgeInterval: -5,
|
||||||
|
preferredSpeed: 500
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const rule = expanded.siteRules[0];
|
||||||
|
|
||||||
|
expect(expanded.enabled).toBe(false);
|
||||||
|
expect(rule.title).toBe("Example videos");
|
||||||
|
expect(rule.enabled).toBe(false);
|
||||||
|
expect(rule.rememberSpeed).toBe(false);
|
||||||
|
expect(rule.forceLastSavedSpeed).toBe(true);
|
||||||
|
expect(rule.controllerOpacity).toBe(1);
|
||||||
|
expect(rule.subtitleNudgeInterval).toBe(250);
|
||||||
|
expect(rule.preferredSpeed).toBe(16);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back for null and blank numeric settings instead of coercing zero", () => {
|
||||||
|
const expanded = window.vscExpandStoredSettings({
|
||||||
|
lastSpeed: null,
|
||||||
|
controllerMarginBottom: " ",
|
||||||
|
siteRules: [
|
||||||
|
{
|
||||||
|
pattern: "example.org",
|
||||||
|
preferredSpeed: "",
|
||||||
|
controllerMarginBottom: null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(expanded.lastSpeed).toBe(1);
|
||||||
|
expect(expanded.controllerMarginBottom).toBe(65);
|
||||||
|
expect(expanded.siteRules[0].preferredSpeed).toBeUndefined();
|
||||||
|
expect(expanded.siteRules[0].controllerMarginBottom).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("repairs invalid imported shortcut values to safe action defaults", () => {
|
||||||
|
const expanded = window.vscExpandStoredSettings({
|
||||||
|
keyBindings: [
|
||||||
|
{ action: "faster", code: "KeyD", value: 0 },
|
||||||
|
{ action: "fast", code: "KeyG", value: "not-a-number" },
|
||||||
|
{ action: "rewind", code: "KeyZ", value: -2 }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(expanded.keyBindings.map((binding) => binding.value)).toEqual([
|
||||||
|
0.1,
|
||||||
|
1.8,
|
||||||
|
10
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops malformed primitive shortcut entries instead of crashing hydration", () => {
|
||||||
|
expect(() =>
|
||||||
|
window.vscExpandStoredSettings({
|
||||||
|
keyBindings: [null, "bad", 7, { action: "faster", code: "KeyD", value: 0.2 }]
|
||||||
|
})
|
||||||
|
).not.toThrow();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
window.vscExpandStoredSettings({
|
||||||
|
keyBindings: [null, "bad", { action: "faster", code: "KeyD", value: 0.2 }]
|
||||||
|
}).keyBindings
|
||||||
|
).toEqual([
|
||||||
|
expect.objectContaining({ action: "faster", value: 0.2 })
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes stale managed keys without touching runtime or future data", () => {
|
||||||
|
const raw = {
|
||||||
|
rememberSpeed: true,
|
||||||
|
speedStep: 0.2,
|
||||||
|
lastSpeed: 1.75,
|
||||||
|
futureRuntimeValue: { keep: true }
|
||||||
|
};
|
||||||
|
const settings = window.vscExpandStoredSettings(raw);
|
||||||
|
settings.rememberSpeed = false;
|
||||||
|
|
||||||
|
const mutation = window.vscBuildManagedStorageMutation(raw, settings);
|
||||||
|
expect(mutation.remove).toEqual(
|
||||||
|
expect.arrayContaining(["rememberSpeed", "speedStep"])
|
||||||
|
);
|
||||||
|
expect(mutation.remove).not.toContain("lastSpeed");
|
||||||
|
expect(mutation.remove).not.toContain("futureRuntimeValue");
|
||||||
|
expect(mutation.set.keyBindings).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ action: "faster", value: 0.2 })
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("site-rule matching", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
bootSettingsCore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges all matches in order so a later specific rule wins", () => {
|
||||||
|
const matched = window.vscMatchSiteRule(
|
||||||
|
"https://www.youtube.com/shorts/abc",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
pattern: "youtube.com",
|
||||||
|
enabled: false,
|
||||||
|
rememberSpeed: false,
|
||||||
|
controllerOpacity: 0.2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: "youtube.com/shorts",
|
||||||
|
enabled: true,
|
||||||
|
rememberSpeed: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(matched).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
enabled: true,
|
||||||
|
rememberSpeed: true,
|
||||||
|
controllerOpacity: 0.2
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches plain host patterns only on safe hostname boundaries", () => {
|
||||||
|
const matcher = window.SpeederShared.siteRules;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
matcher.plainSitePatternMatchesUrl(
|
||||||
|
"example.com",
|
||||||
|
"https://media.example.com/watch"
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
matcher.plainSitePatternMatchesUrl(
|
||||||
|
"example.com",
|
||||||
|
"https://notexample.com/watch"
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
matcher.plainSitePatternMatchesUrl(
|
||||||
|
"example.com",
|
||||||
|
"https://example.com.evil.test/watch"
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
matcher.plainSitePatternMatchesUrl(
|
||||||
|
"example.com",
|
||||||
|
"https://safe.test/watch?next=example.com"
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
+96
-1
@@ -22,6 +22,60 @@ describe("shared helpers", () => {
|
|||||||
])
|
])
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
expect(siteRules.isSiteRuleDisabled({ enabled: false })).toBe(true);
|
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", () => {
|
||||||
|
expect(
|
||||||
|
siteRules.siteRuleMatchesUrl(
|
||||||
|
{ pattern: "example.com/path?title=Hello World#part one" },
|
||||||
|
"https://example.com/%70ath?title=Hello%20World#part%20one"
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
siteRules.siteRuleMatchesUrl(
|
||||||
|
{ pattern: "/example\\.com\\/path\\?title=Hello World#part one/i" },
|
||||||
|
"https://example.com/%70ath?title=Hello%20World#part%20one"
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
// Raw matching remains available for rules intentionally written against
|
||||||
|
// the encoded form.
|
||||||
|
expect(
|
||||||
|
siteRules.siteRuleMatchesUrl(
|
||||||
|
{ pattern: "/%70ath\\?title=Hello%20World/" },
|
||||||
|
"https://example.com/%70ath?title=Hello%20World"
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tolerates malformed URL escapes and keeps regex matching deterministic", () => {
|
||||||
|
expect(siteRules.safelyDecodeUrlText("/%70%ZZ%E2/end")).toBe(
|
||||||
|
"/p%ZZ%E2/end"
|
||||||
|
);
|
||||||
|
expect(siteRules.safelyDecodeUrlText("/%E2%82%AC%FF%70")).toBe(
|
||||||
|
"/€%FFp"
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
siteRules.siteRuleMatchesUrl(
|
||||||
|
{ pattern: "/example\\.com\\/p%ZZ%E2/" },
|
||||||
|
"https://example.com/%70%ZZ%E2"
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
const globalRule = { pattern: "/example\\.com\\/watch/g" };
|
||||||
|
expect(
|
||||||
|
siteRules.siteRuleMatchesUrl(globalRule, "https://example.com/watch")
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
siteRules.siteRuleMatchesUrl(globalRule, "https://example.com/watch")
|
||||||
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("combines global enabled with matched site rules (whitelist / blacklist)", () => {
|
it("combines global enabled with matched site rules (whitelist / blacklist)", () => {
|
||||||
@@ -87,6 +141,15 @@ describe("shared helpers", () => {
|
|||||||
).toEqual(["advance"]);
|
).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", () => {
|
it("normalizes controller locations and margins", () => {
|
||||||
expect(controllerUtils.normalizeControllerLocation("top-right")).toBe(
|
expect(controllerUtils.normalizeControllerLocation("top-right")).toBe(
|
||||||
"top-right"
|
"top-right"
|
||||||
@@ -111,6 +174,14 @@ describe("shared helpers", () => {
|
|||||||
"NumpadAdd"
|
"NumpadAdd"
|
||||||
);
|
);
|
||||||
expect(keyBindingUtils.getLegacyKeyCode({ key: 65 })).toBe(65);
|
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", () => {
|
it("builds and parses import/export payloads", () => {
|
||||||
@@ -173,12 +244,36 @@ describe("shared helpers", () => {
|
|||||||
expect(importExportUtils.isRecognizedRawSettingsObject({ wat: true })).toBe(
|
expect(importExportUtils.isRecognizedRawSettingsObject({ wat: true })).toBe(
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
importExportUtils.isRecognizedRawSettingsObject({
|
||||||
|
siteRulesFormat: "defaults-diff-v2"
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
importExportUtils.extractImportSettings({ settings: [] })
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
importExportUtils.extractImportSettings({
|
||||||
|
settings: { enabled: true },
|
||||||
|
localSettings: []
|
||||||
|
})
|
||||||
|
).toBeNull();
|
||||||
|
[null, "bad", []].forEach((customButtonIcons) => {
|
||||||
|
expect(
|
||||||
|
importExportUtils.extractImportSettings({
|
||||||
|
settings: { enabled: true },
|
||||||
|
localSettings: { customButtonIcons }
|
||||||
|
})
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
importExportUtils.filterLocalSettingsForExport({
|
importExportUtils.filterLocalSettingsForExport({
|
||||||
customButtonIcons: { faster: { slug: "zap" } },
|
customButtonIcons: { faster: { slug: "zap" } },
|
||||||
lucideTagsCacheV1: { "a-arrow-down": ["letter"] },
|
lucideTagsCacheV1: { "a-arrow-down": ["letter"] },
|
||||||
lucideTagsCacheV1At: 123
|
lucideTagsCacheV1At: 123,
|
||||||
|
rememberedSpeeds: { "https://example.com/video.mp4": 1.75 },
|
||||||
|
futureRuntimeData: true
|
||||||
})
|
})
|
||||||
).toEqual({
|
).toEqual({
|
||||||
customButtonIcons: { faster: { slug: "zap" } }
|
customButtonIcons: { faster: { slug: "zap" } }
|
||||||
|
|||||||
@@ -0,0 +1,528 @@
|
|||||||
|
const {
|
||||||
|
createChromeMock,
|
||||||
|
evaluateScript,
|
||||||
|
flushAsyncWork,
|
||||||
|
loadHtmlString
|
||||||
|
} = require("./helpers/extension-test-utils");
|
||||||
|
|
||||||
|
function bootInject(syncData, url, localData) {
|
||||||
|
loadHtmlString("<!doctype html><html><body></body></html>", {
|
||||||
|
url: url || "https://example.org/"
|
||||||
|
});
|
||||||
|
const chrome = createChromeMock({
|
||||||
|
syncData: syncData || {},
|
||||||
|
localData: localData || {}
|
||||||
|
});
|
||||||
|
global.chrome = chrome;
|
||||||
|
window.chrome = chrome;
|
||||||
|
window.requestAnimationFrame = (callback) => setTimeout(callback, 0);
|
||||||
|
window.cancelAnimationFrame = (id) => clearTimeout(id);
|
||||||
|
window.requestIdleCallback = (callback) =>
|
||||||
|
setTimeout(
|
||||||
|
() => callback({ didTimeout: false, timeRemaining: () => 1 }),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
window.cancelIdleCallback = (id) => clearTimeout(id);
|
||||||
|
|
||||||
|
evaluateScript("extension/shared/settings-core.js");
|
||||||
|
evaluateScript("extension/shared/controller-utils.js");
|
||||||
|
evaluateScript("extension/shared/key-bindings.js");
|
||||||
|
evaluateScript("extension/shared/site-rules.js");
|
||||||
|
evaluateScript("extension/shared/ui-icons.js");
|
||||||
|
evaluateScript("extension/content/inject.js");
|
||||||
|
return chrome;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createVideo() {
|
||||||
|
await flushAsyncWork(4);
|
||||||
|
const mount = document.createElement("div");
|
||||||
|
const video = document.createElement("video");
|
||||||
|
video.src = "https://example.org/a.mp4";
|
||||||
|
mount.appendChild(video);
|
||||||
|
document.body.appendChild(mount);
|
||||||
|
window.ensureController(video, mount);
|
||||||
|
return video;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("durable playback-speed intent", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
delete global.chrome;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("activates the built-in Shorts remember-speed rule on empty storage", async () => {
|
||||||
|
bootInject({}, "https://www.youtube.com/shorts/abc123");
|
||||||
|
await flushAsyncWork(4);
|
||||||
|
|
||||||
|
expect(window.tc.settings.siteRules).toHaveLength(3);
|
||||||
|
expect(window.tc.activeSiteRule).toEqual(
|
||||||
|
expect.objectContaining({ rememberSpeed: true })
|
||||||
|
);
|
||||||
|
expect(window.tc.settings.rememberSpeed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("also activates the built-in remember rule on mobile YouTube Shorts", async () => {
|
||||||
|
bootInject({}, "https://m.youtube.com/shorts/mobile123");
|
||||||
|
await flushAsyncWork(4);
|
||||||
|
|
||||||
|
expect(window.tc.activeSiteRule).toEqual(
|
||||||
|
expect.objectContaining({ rememberSpeed: true })
|
||||||
|
);
|
||||||
|
expect(window.tc.settings.rememberSpeed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("seeds a brand-new Short from lastSpeed when remember is active", async () => {
|
||||||
|
bootInject(
|
||||||
|
{ lastSpeed: 1.8 },
|
||||||
|
"https://www.youtube.com/shorts/new-source"
|
||||||
|
);
|
||||||
|
const video = await createVideo();
|
||||||
|
|
||||||
|
expect(window.tc.settings.rememberSpeed).toBe(true);
|
||||||
|
expect(window.getRememberedSpeed(video)).toBe(1.8);
|
||||||
|
expect(video.playbackRate).toBe(1.8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers per-source speed over lastSpeed fallback", async () => {
|
||||||
|
bootInject(
|
||||||
|
{ lastSpeed: 2.0, rememberSpeed: true },
|
||||||
|
"https://example.org/watch",
|
||||||
|
{
|
||||||
|
rememberedSpeeds: {
|
||||||
|
"https://example.org/a.mp4": { speed: 1.4, updatedAt: 100 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const video = await createVideo();
|
||||||
|
|
||||||
|
expect(window.getRememberedSpeed(video)).toBe(1.4);
|
||||||
|
expect(video.playbackRate).toBe(1.4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fall back to lastSpeed when remember is off", async () => {
|
||||||
|
bootInject(
|
||||||
|
{ lastSpeed: 1.8, rememberSpeed: false },
|
||||||
|
"https://example.org/watch"
|
||||||
|
);
|
||||||
|
const video = await createVideo();
|
||||||
|
|
||||||
|
expect(window.getRememberedSpeed(video)).toBeNull();
|
||||||
|
expect(video.playbackRate).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let an automatic remember-off reset overwrite lastSpeed", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const chrome = bootInject({ lastSpeed: 1.8, rememberSpeed: false });
|
||||||
|
const video = await createVideo();
|
||||||
|
|
||||||
|
video.src = "https://example.org/b.mp4";
|
||||||
|
window.applySourceTransitionPolicy(video, true);
|
||||||
|
video.dispatchEvent(new Event("ratechange"));
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
|
||||||
|
expect(video.playbackRate).toBe(1);
|
||||||
|
expect(window.tc.settings.lastSpeed).toBe(1.8);
|
||||||
|
expect(chrome.storage.sync._dump().lastSpeed).toBe(1.8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists an explicit Speeder speed even before a native event arrives", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const chrome = bootInject({ lastSpeed: 1.25, rememberSpeed: false });
|
||||||
|
const video = await createVideo();
|
||||||
|
|
||||||
|
window.setSpeed(video, 1.75, false, true);
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
|
||||||
|
expect(window.tc.settings.lastSpeed).toBe(1.75);
|
||||||
|
expect(chrome.storage.sync._dump().lastSpeed).toBe(1.75);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat an external ratechange as saved intent when remember is off", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const chrome = bootInject({ lastSpeed: 1.8, rememberSpeed: false });
|
||||||
|
const video = await createVideo();
|
||||||
|
|
||||||
|
video.playbackRate = 0.75;
|
||||||
|
video.dispatchEvent(new Event("ratechange"));
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
|
||||||
|
expect(window.tc.settings.lastSpeed).toBe(1.8);
|
||||||
|
expect(chrome.storage.sync._dump().lastSpeed).toBe(1.8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let a site reset corrupt saved intent when remember is on", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const chrome = bootInject({ lastSpeed: 1.8, rememberSpeed: true });
|
||||||
|
const video = await createVideo();
|
||||||
|
|
||||||
|
video.playbackRate = 1;
|
||||||
|
video.dispatchEvent(new Event("ratechange"));
|
||||||
|
await vi.advanceTimersByTimeAsync(600);
|
||||||
|
|
||||||
|
expect(window.tc.settings.lastSpeed).toBe(1.8);
|
||||||
|
expect(chrome.storage.sync._dump().lastSpeed).toBe(1.8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives force mode precedence over per-source memory", async () => {
|
||||||
|
bootInject({
|
||||||
|
lastSpeed: 1.8,
|
||||||
|
rememberSpeed: true,
|
||||||
|
forceLastSavedSpeed: true
|
||||||
|
});
|
||||||
|
const video = await createVideo();
|
||||||
|
const sourceKey = window.getVideoSourceKey(video);
|
||||||
|
window.tc.settings.speeds[sourceKey] = 1.25;
|
||||||
|
video.vsc.targetSpeed = 1.25;
|
||||||
|
video.vsc.targetSpeedSourceKey = sourceKey;
|
||||||
|
|
||||||
|
expect(window.getRememberedSpeed(video)).toBe(1.8);
|
||||||
|
expect(window.getDesiredSpeed(video)).toBe(1.8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists a bounded per-source speed map in local storage", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const chrome = bootInject({ lastSpeed: 1.25, rememberSpeed: true });
|
||||||
|
const video = await createVideo();
|
||||||
|
|
||||||
|
window.setSpeed(video, 1.6, false, true);
|
||||||
|
await vi.advanceTimersByTimeAsync(600);
|
||||||
|
|
||||||
|
const remembered = chrome.storage.local._dump().rememberedSpeeds;
|
||||||
|
expect(remembered["https://example.org/a.mp4"]).toEqual(
|
||||||
|
expect.objectContaining({ speed: 1.6 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hydrates per-source speed before creating the first controller", async () => {
|
||||||
|
bootInject(
|
||||||
|
{ lastSpeed: 1.8, rememberSpeed: true },
|
||||||
|
"https://example.org/watch",
|
||||||
|
{
|
||||||
|
rememberedSpeeds: {
|
||||||
|
"https://example.org/a.mp4": {
|
||||||
|
speed: 1.4,
|
||||||
|
updatedAt: 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const video = await createVideo();
|
||||||
|
|
||||||
|
expect(video.playbackRate).toBe(1.4);
|
||||||
|
expect(window.getRememberedSpeed(video)).toBe(1.4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges another frame's source map without losing a queued local speed", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const chrome = bootInject({ lastSpeed: 1.25, rememberSpeed: true });
|
||||||
|
const video = await createVideo();
|
||||||
|
|
||||||
|
window.setSpeed(video, 1.6, false, true);
|
||||||
|
chrome.storage.local.set({
|
||||||
|
rememberedSpeeds: {
|
||||||
|
"https://example.org/other.mp4": {
|
||||||
|
speed: 1.3,
|
||||||
|
updatedAt: Date.now() + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await vi.advanceTimersByTimeAsync(700);
|
||||||
|
|
||||||
|
const remembered = chrome.storage.local._dump().rememberedSpeeds;
|
||||||
|
expect(remembered["https://example.org/a.mp4"].speed).toBe(1.6);
|
||||||
|
expect(remembered["https://example.org/other.mp4"].speed).toBe(1.3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a stale pre-reset write without deleting a newer cross-frame speed", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const chrome = bootInject({ lastSpeed: 1.25, rememberSpeed: true });
|
||||||
|
const video = await createVideo();
|
||||||
|
const originalSet = chrome.storage.local.set.getMockImplementation();
|
||||||
|
let finishStaleSet;
|
||||||
|
chrome.storage.local.set.mockImplementationOnce((items, callback) => {
|
||||||
|
finishStaleSet = () => originalSet(items, callback);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.setSpeed(video, 1.6, false, true);
|
||||||
|
await vi.advanceTimersByTimeAsync(500);
|
||||||
|
expect(finishStaleSet).toBeTypeOf("function");
|
||||||
|
|
||||||
|
const resetAt = Date.now();
|
||||||
|
chrome.storage.local.set({
|
||||||
|
rememberedSpeedsResetAt: resetAt,
|
||||||
|
rememberedSpeeds: {}
|
||||||
|
});
|
||||||
|
chrome.storage.local.set({
|
||||||
|
rememberedSpeeds: {
|
||||||
|
"https://example.org/new-after-reset.mp4": {
|
||||||
|
speed: 1.4,
|
||||||
|
updatedAt: resetAt + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
finishStaleSet();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
|
||||||
|
expect(chrome.storage.local._dump().rememberedSpeeds).toEqual({
|
||||||
|
"https://example.org/new-after-reset.mp4": {
|
||||||
|
speed: 1.4,
|
||||||
|
updatedAt: resetAt + 1
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(window.tc.settings.speeds).not.toHaveProperty(
|
||||||
|
"https://example.org/a.mp4"
|
||||||
|
);
|
||||||
|
expect(window.tc.settings.speeds).toHaveProperty(
|
||||||
|
"https://example.org/new-after-reset.mp4",
|
||||||
|
1.4
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reapplies the Shorts rule on same-source SPA navigation and resists an immediate reset", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
bootInject(
|
||||||
|
{ lastSpeed: 1.8 },
|
||||||
|
"https://www.youtube.com/watch?v=same-source",
|
||||||
|
{
|
||||||
|
rememberedSpeeds: {
|
||||||
|
"https://example.org/a.mp4": { speed: 1.8, updatedAt: 100 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const video = await createVideo();
|
||||||
|
Object.defineProperty(video, "paused", {
|
||||||
|
configurable: true,
|
||||||
|
value: false
|
||||||
|
});
|
||||||
|
|
||||||
|
window.history.pushState({}, "", "/shorts/same-source");
|
||||||
|
await vi.advanceTimersByTimeAsync(350);
|
||||||
|
await flushAsyncWork(2);
|
||||||
|
|
||||||
|
expect(window.tc.settings.rememberSpeed).toBe(true);
|
||||||
|
expect(video.playbackRate).toBe(1.8);
|
||||||
|
|
||||||
|
video.playbackRate = 1;
|
||||||
|
video.dispatchEvent(new Event("ratechange"));
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(video.playbackRate).toBe(1.8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disarms remembered policy when same-source SPA navigation leaves Shorts", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
bootInject(
|
||||||
|
{ lastSpeed: 1.8 },
|
||||||
|
"https://www.youtube.com/shorts/leaving",
|
||||||
|
{
|
||||||
|
rememberedSpeeds: {
|
||||||
|
"https://example.org/a.mp4": { speed: 1.8, updatedAt: 100 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const video = await createVideo();
|
||||||
|
expect(video.vsc.targetSpeedOrigin).toBe("policy");
|
||||||
|
|
||||||
|
window.history.pushState({}, "", "/watch?v=leaving");
|
||||||
|
await vi.advanceTimersByTimeAsync(350);
|
||||||
|
await flushAsyncWork(2);
|
||||||
|
|
||||||
|
expect(window.tc.settings.rememberSpeed).toBe(false);
|
||||||
|
expect(video.vsc.targetSpeedOrigin).not.toBe("policy");
|
||||||
|
video.playbackRate = 1;
|
||||||
|
video.dispatchEvent(new Event("ratechange"));
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(video.playbackRate).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("live-reloads sparse settings without leaking a route override into the global base", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const chrome = bootInject(
|
||||||
|
{
|
||||||
|
rememberSpeed: false,
|
||||||
|
siteRules: [
|
||||||
|
{
|
||||||
|
pattern: "example.org/shorts/",
|
||||||
|
enabled: true,
|
||||||
|
rememberSpeed: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"https://example.org/shorts/one"
|
||||||
|
);
|
||||||
|
await createVideo();
|
||||||
|
expect(window.tc.settings.rememberSpeed).toBe(true);
|
||||||
|
|
||||||
|
chrome.storage.sync.set({ controllerOpacity: 0.7 });
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(window.tc.siteRuleBase.rememberSpeed).toBe(false);
|
||||||
|
expect(window.tc.settings.rememberSpeed).toBe(true);
|
||||||
|
|
||||||
|
window.history.pushState({}, "", "/watch/two");
|
||||||
|
await vi.advanceTimersByTimeAsync(350);
|
||||||
|
expect(window.tc.activeSiteRule).toBeNull();
|
||||||
|
expect(window.tc.settings.rememberSpeed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not trust forged force-mode ratechange metadata as user intent", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const chrome = bootInject({
|
||||||
|
lastSpeed: 1.8,
|
||||||
|
forceLastSavedSpeed: true
|
||||||
|
});
|
||||||
|
const video = await createVideo();
|
||||||
|
video.vsc.pendingRateChange = null;
|
||||||
|
|
||||||
|
video.dispatchEvent(
|
||||||
|
new CustomEvent("ratechange", {
|
||||||
|
detail: {
|
||||||
|
origin: "videoSpeed",
|
||||||
|
speed: 0.5,
|
||||||
|
fromUserInput: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
|
||||||
|
expect(video.playbackRate).toBe(1.8);
|
||||||
|
expect(window.tc.settings.lastSpeed).toBe(1.8);
|
||||||
|
expect(chrome.storage.sync._dump().lastSpeed).toBe(1.8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a stale popup speed payload and can still persist that value later", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const chrome = bootInject({ lastSpeed: 2 });
|
||||||
|
const video = await createVideo();
|
||||||
|
const listener = chrome.runtime.onMessage.listeners[0];
|
||||||
|
let response;
|
||||||
|
|
||||||
|
listener(
|
||||||
|
{
|
||||||
|
action: "set_force_last_saved_speed",
|
||||||
|
enabled: true,
|
||||||
|
speed: 1.5
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
(value) => {
|
||||||
|
response = value;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.enabled).toBe(true);
|
||||||
|
expect(window.tc.settings.lastSpeed).toBe(2);
|
||||||
|
expect(window.tc.persistedLastSpeed).toBe(2);
|
||||||
|
|
||||||
|
window.setSpeed(video, 1.5, false, true);
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
expect(chrome.storage.sync._dump().lastSpeed).toBe(1.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the anti-reset window when force mode is turned off", async () => {
|
||||||
|
const chrome = bootInject({
|
||||||
|
lastSpeed: 1.8,
|
||||||
|
forceLastSavedSpeed: true
|
||||||
|
});
|
||||||
|
const video = await createVideo();
|
||||||
|
video.vsc.speedRestoreUntil = Date.now() + 5000;
|
||||||
|
video.vsc.restoreSpeedTimer = setTimeout(() => {}, 5000);
|
||||||
|
const listener = chrome.runtime.onMessage.listeners[0];
|
||||||
|
|
||||||
|
listener(
|
||||||
|
{ action: "set_force_last_saved_speed", enabled: false },
|
||||||
|
{},
|
||||||
|
() => {}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(window.tc.settings.forceLastSavedSpeed).toBe(false);
|
||||||
|
expect(video.vsc.speedRestoreUntil).toBe(0);
|
||||||
|
expect(video.vsc.restoreSpeedTimer).toBeNull();
|
||||||
|
expect(video.vsc.targetSpeedOrigin).not.toBe("policy");
|
||||||
|
|
||||||
|
video.playbackRate = 1;
|
||||||
|
video.dispatchEvent(new Event("ratechange"));
|
||||||
|
expect(video.playbackRate).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps force ownership through a redundant event so disabling force fully releases it", async () => {
|
||||||
|
const chrome = bootInject({
|
||||||
|
lastSpeed: 1.8,
|
||||||
|
forceLastSavedSpeed: true
|
||||||
|
});
|
||||||
|
const video = await createVideo();
|
||||||
|
const listener = chrome.runtime.onMessage.listeners[0];
|
||||||
|
|
||||||
|
video.dispatchEvent(new Event("ratechange"));
|
||||||
|
expect(video.vsc.targetSpeedOrigin).toBe("policy");
|
||||||
|
|
||||||
|
listener(
|
||||||
|
{ action: "set_force_last_saved_speed", enabled: false },
|
||||||
|
{},
|
||||||
|
() => {}
|
||||||
|
);
|
||||||
|
video.playbackRate = 1;
|
||||||
|
video.dispatchEvent(new Event("ratechange"));
|
||||||
|
|
||||||
|
expect(video.playbackRate).toBe(1);
|
||||||
|
expect(video.vsc.targetSpeedOrigin).toBe("external");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a native speed change while paused when no Speeder policy owns the target", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const chrome = bootInject({ lastSpeed: 1.8, rememberSpeed: false });
|
||||||
|
const video = await createVideo();
|
||||||
|
|
||||||
|
expect(video.paused).toBe(true);
|
||||||
|
expect(video.vsc.targetSpeedOrigin).toBe("initial");
|
||||||
|
video.playbackRate = 1.5;
|
||||||
|
video.dispatchEvent(new Event("ratechange"));
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
|
||||||
|
expect(video.playbackRate).toBe(1.5);
|
||||||
|
expect(video.vsc.targetSpeedOrigin).toBe("external");
|
||||||
|
expect(window.tc.settings.lastSpeed).toBe(1.8);
|
||||||
|
expect(chrome.storage.sync._dump().lastSpeed).toBe(1.8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defensively replaces an invalid zero speed step instead of jumping to 16x", async () => {
|
||||||
|
bootInject({
|
||||||
|
keyBindings: [
|
||||||
|
{
|
||||||
|
action: "faster",
|
||||||
|
code: "KeyD",
|
||||||
|
value: 0,
|
||||||
|
disabled: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const video = await createVideo();
|
||||||
|
|
||||||
|
window.runAction("faster", 0, null, video);
|
||||||
|
|
||||||
|
expect(video.playbackRate).toBeCloseTo(1.1, 5);
|
||||||
|
expect(video.playbackRate).not.toBe(16);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds verification retries when a player silently rejects forced speed", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
bootInject({ lastSpeed: 1.8, forceLastSavedSpeed: true });
|
||||||
|
const video = await createVideo();
|
||||||
|
await vi.advanceTimersByTimeAsync(200);
|
||||||
|
let assignmentCount = 0;
|
||||||
|
Object.defineProperty(video, "playbackRate", {
|
||||||
|
configurable: true,
|
||||||
|
get() {
|
||||||
|
return 1;
|
||||||
|
},
|
||||||
|
set() {
|
||||||
|
assignmentCount += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.setSpeed(video, 1.8, false, false);
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
|
||||||
|
expect(assignmentCount).toBe(4);
|
||||||
|
expect(video.vsc.speedVerificationTimer).toBeNull();
|
||||||
|
expect(video.vsc.speedVerificationAttempts).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user