Compare commits

...
Author SHA1 Message Date
joshpatra f8cfbf05c8 chore(release): bump version to 6.0.8.0 2026-08-02 23:34:34 -04:00
joshpatra b4234e0387 feat(options): add diagnostics copy action 2026-08-02 22:23:08 -04:00
joshpatra d55006dd0a feat(popup): pause Speeder per tab 2026-08-02 21:18:39 -04:00
joshpatra 745af2f1b9 feat(popup): add rule for current site 2026-08-02 21:03:39 -04:00
joshpatra 7e9ce436d3 feat(popup): copy privacy-safe diagnostics 2026-08-02 21:02:08 -04:00
joshpatra 98546adf41 Bump version to 6.0.7.0 2026-08-02 10:09:09 -04:00
joshpatra daeff63d2a fix(controller): avoid popovers over fullscreen players 2026-08-02 10:07:32 -04:00
joshpatra aa4f1d15e7 test: make export dates timezone independent 2026-08-02 00:07:40 -04:00
joshpatra 1d9f6f7e13 chore(release): bump version to 6.0.6.0 2026-08-02 00:05:24 -04:00
joshpatra 0f64fd5ab1 docs: complete implementation checklist 2026-07-31 22:40:19 -04:00
joshpatra e555c4c116 ci: run tests before extension packaging 2026-07-31 22:39:49 -04:00
joshpatra 91d530e29b docs: correct extension behavior guidance 2026-07-31 22:39:25 -04:00
joshpatra 34f2561b50 fix(background): synchronize toolbar icon state 2026-07-31 22:37:44 -04:00
joshpatra fd29883d84 fix(rules): reject malformed regex patterns 2026-07-31 22:36:53 -04:00
joshpatra e083fdcafe fix(import): report partial settings imports 2026-07-31 22:36:12 -04:00
joshpatra 2fa7d8963a fix(settings): confirm destructive defaults reset 2026-07-31 22:35:23 -04:00
joshpatra e4edf10440 fix(ui): improve focus and status accessibility 2026-07-31 22:34:25 -04:00
joshpatra d5c2d0e343 fix(options): label generated settings controls 2026-07-31 22:33:18 -04:00
joshpatra 5cd559b883 feat(options): add keyboard control bar editing 2026-07-31 22:32:04 -04:00
joshpatra 0c9a6a1a1b fix(controller): make overlay controls accessible 2026-07-31 22:29:44 -04:00
joshpatra 3ee970a54b fix(shortcuts): ignore editable control events 2026-07-31 22:28:18 -04:00
joshpatra e12375f2d2 fix(popup): target the displayed media frame 2026-07-31 22:27:33 -04:00
joshpatra 8d7d31a4db fix(controller): restore fullscreen overlay visibility 2026-07-31 22:25:38 -04:00
joshpatra ed21281fa3 docs: add implementation checklist 2026-07-31 22:24:02 -04:00
joshpatra 39b7ba27fb Bump version to 6.0.4.1 2026-07-26 11:46:48 -04:00
joshpatra ee346ee9d8 fix(controller): defer SPA initialization until settings load 2026-07-26 11:46:18 -04:00
joshpatra bff65230bb Bump version to 6.0.4 2026-07-26 11:15:20 -04:00
joshpatra 0afb56abe0 fix(options): align shortcuts and show autosave status 2026-07-26 11:14:55 -04:00
joshpatra a02b7b57f7 fix: restore native playback speed range 2026-07-26 11:14:55 -04:00
joshpatra 9b6d1ccd3c Bump version to 6.0.3 2026-07-26 10:19:29 -04:00
joshpatra a2d7f0d9d6 feat: expand shortcuts and improve YouTube navigation 2026-07-26 10:19:11 -04:00
joshpatra 04f1414118 chore(release): bump version to 6.0.2 2026-07-23 00:08:45 -04:00
joshpatra dad223d4ad fix(controller): reset shortcut target after navigation 2026-07-23 00:08:38 -04:00
joshpatra cb892a36a4 Bump version to 6.0.1 2026-07-16 23:58:32 -04:00
joshpatra 722eb0754d fix(controller): fall back to lastSpeed for Shorts when rememberSpeed is active 2026-07-16 23:58:25 -04:00
joshpatra 6b2a5ed472 Bump version to 6.0.0 2026-07-16 17:42:10 -04:00
joshpatra 29689ae4ec fix(controller): promote fullscreen controls to top layer 2026-07-16 17:40:34 -04:00
joshpatra 9c10183ab6 feat(controller): configure ambient video overlays 2026-07-16 17:05:52 -04:00
joshpatra 7073330916 Bump version to 5.3.6.1 2026-07-16 02:12:10 -04:00
joshpatra f10818964c fix(controller): keep controls open while hovered 2026-07-16 02:10:56 -04:00
joshpatra 2adafb51f2 Bump version to 5.3.6.0 2026-07-16 01:59:46 -04:00
joshpatra 22c8f9d793 fix(controller): elevate controls above sibling panes 2026-07-16 01:46:36 -04:00
joshpatra c0bd997287 fix(performance): bound media discovery work 2026-07-16 01:44:34 -04:00
joshpatra 85d4a0588a fix(extension): harden controls, rules, and speed persistence 2026-07-16 01:31:57 -04:00
joshpatra cd3088a6db Bump version to 5.3.5.0 2026-07-14 21:07:28 -04:00
joshpatra a2ab1ad261 fix(controller): restore dynamic and fullscreen overlays 2026-07-14 21:07:19 -04:00
joshpatra 477e5ba8b2 Bump version to 5.3.4.0 2026-07-12 16:01:31 -04:00
joshpatra 8b1820823b fix(controller): keep overlay below sticky headers 2026-07-12 15:58:40 -04:00
joshpatra 9b7a6edd14 Bump version to 5.3.3.0 2026-07-12 15:51:12 -04:00
joshpatra bbab33f55f fix(controller): keep video controls accessible 2026-07-12 15:50:37 -04:00
38 changed files with 8926 additions and 1053 deletions
+12 -1
View File
@@ -14,7 +14,18 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Install deps
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install project dependencies
run: npm ci
- name: Test
run: npm test
- name: Install web-ext
run: npm install -g web-ext
- name: Lint
+34
View File
@@ -0,0 +1,34 @@
# Implementation Plan
Constraints: local commits only; no push; no browser testing; one commit per feature.
## Controller and targeting
- [x] Keep the controller visible for its own video in element and ancestor fullscreen.
- [x] Target popup actions at the frame represented by the displayed speed; keep “all videos” intentional.
- [x] Ignore shortcuts originating from editable controls, including shadow-DOM inputs.
## Accessibility and usability
- [x] Give in-player controls accessible names, keyboard behavior, and visible focus.
- [x] Make control-bar customization operable by keyboard as well as drag and drop.
- [x] Label generated shortcut and site-rule form controls.
- [x] Improve popup status announcements, focus indicators, and icon-search semantics.
## Settings safety and validation
- [x] Confirm before Restore Defaults removes preferences and remembered data.
- [x] Report partial imports accurately when custom icons cannot be restored.
- [x] Reject malformed slash-prefixed regular expressions before saving site rules.
## Extension lifecycle and copy
- [x] Initialize and synchronize the disabled toolbar icon from background state.
- [x] Correct shortcut, subtitle-nudge, live-update, and obsolete troubleshooting copy.
- [x] Run automated tests in the release workflow before packaging.
## Verification
- [x] Run focused automated checks after each non-trivial change.
- [x] Run the complete non-browser test suite and review the final local commit series.
- [x] Leave cross-site fullscreen visual verification for reporter/user validation.
+9 -13
View File
@@ -56,16 +56,14 @@ settings page, as well as add additional shortcut keys to match your
preferences. For example, you can assign multiple different "preferred speed"
shortcuts with different values, which will allow you to quickly toggle between
your most commonly used speeds. To add a new shortcut, open extension settings
and click "Add New".
and choose an action from "Add shortcut…".
<img width="1760" height="1330" alt="image" src="https://github.com/user-attachments/assets/32e814dd-93ea-4943-8ec9-3eca735447ac" />
Some sites may assign other functionality to one of the assigned shortcut keys
these collisions are inevitable, unfortunately. As a workaround, the extension
listens both for lower and upper case values (i.e. you can use
`Shift-<shortcut>`) if there is other functionality assigned to the lowercase
key. This is not a perfect solution, as some sites may listen to both, but works
most of the time.
Some sites may assign other functionality to one of the assigned shortcut keys.
You can record `Shift+<shortcut>` as a separate, exact binding, or use a site
rule to block the site from capturing a particular Speeder shortcut. Shift is
not applied automatically to an unshifted binding.
## Development
@@ -81,12 +79,10 @@ npx --yes web-ext lint --source-dir extension
### The video controls are not showing up?
This extension is only compatible
with HTML5 video. If you don't see the controls showing up, chances are you are
viewing a Flash video. If you want to confirm, try right-clicking on the video
and inspect the menu: if it mentions flash, then that's the issue. That said,
most sites will fallback to HTML5 if they detect that Flash is not available.
You can try manually disabling Flash from the browser.
Speeder works with HTML5 video and, when enabled in settings, HTML5 audio. Check
that Speeder is enabled for the current site, then use the popup's "Rescan page
for videos" action after a player loads dynamically. Browser-internal pages and
players that do not expose HTML5 media to extensions cannot be controlled.
### What is this fork all about?
+65 -3
View File
@@ -1,5 +1,67 @@
chrome.runtime.onMessage.addListener(function (request) {
if (request.action === "openOptions") {
chrome.tabs.create({ url: chrome.runtime.getURL("options/options.html") });
function setToolbarIcon(enabled) {
var suffix = enabled === false ? "_disabled" : "";
chrome.browserAction.setIcon({
path: {
19: "assets/icons/icon19" + suffix + ".png",
38: "assets/icons/icon38" + suffix + ".png",
48: "assets/icons/icon48" + suffix + ".png"
}
});
}
var pausedTabIds = new Set();
function getMessageTabId(request, sender) {
if (request && Number.isInteger(request.tabId)) return request.tabId;
return sender && sender.tab && Number.isInteger(sender.tab.id)
? sender.tab.id
: null;
}
chrome.storage.sync.get(["enabled"], function(storage) {
if (!chrome.runtime.lastError) setToolbarIcon(storage.enabled !== false);
});
chrome.storage.onChanged.addListener(function(changes, areaName) {
if (areaName === "sync" && changes.enabled) {
setToolbarIcon(changes.enabled.newValue !== false);
}
});
chrome.runtime.onMessage.addListener(function (request, sender, sendResponse) {
if (request.action === "openOptions") {
chrome.tabs.create({ url: chrome.runtime.getURL("options/options.html") });
return false;
}
if (request.action === "get_tab_pause_state") {
var queriedTabId = getMessageTabId(request, sender);
sendResponse({
paused: queriedTabId !== null && pausedTabIds.has(queriedTabId)
});
return false;
}
if (request.action === "set_tab_paused") {
var tabId = getMessageTabId(request, sender);
if (tabId === null) {
sendResponse({ paused: false });
return false;
}
var paused = request.paused === true;
if (paused) pausedTabIds.add(tabId);
else pausedTabIds.delete(tabId);
chrome.tabs.sendMessage(
tabId,
{ action: "set_tab_paused", paused: paused },
function () {
void chrome.runtime.lastError;
}
);
sendResponse({ paused: paused });
return false;
}
return false;
});
chrome.tabs.onRemoved.addListener(function(tabId) {
pausedTabIds.delete(tabId);
});
+17 -1
View File
@@ -8,7 +8,23 @@
if (!v) return null;
return {
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) {
return null;
+20 -1
View File
@@ -6,11 +6,30 @@
width: 100% !important;
height: 100% !important;
pointer-events: none !important;
z-index: 2147483646 !important;
/* Keep the interactive controller above player-owned click/pause panes. */
z-index: 2147483647 !important;
white-space: normal;
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 {
display: none !important;
}
+2809 -441
View File
@@ -5,62 +5,274 @@ var speederShared =
var controllerUtils = speederShared.controllerUtils || {};
var keyBindingUtils = speederShared.keyBindings || {};
var siteRuleUtils = speederShared.siteRules || {};
var sharedSettingsDefaults =
typeof vscGetSettingsDefaults === "function"
? vscGetSettingsDefaults()
: null;
function getPrimaryVideoElement() {
if (!tc.mediaElements || tc.mediaElements.length === 0) return null;
for (var i = 0; i < tc.mediaElements.length; i++) {
var el = tc.mediaElements[i];
if (el && !el.paused) return el;
function getSharedDefault(key, fallback) {
if (
sharedSettingsDefaults &&
Object.prototype.hasOwnProperty.call(sharedSettingsDefaults, key)
) {
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 = {
settings: {
lastSpeed: 1.0,
enabled: true,
lastSpeed: getSharedDefault("lastSpeed", 1.0),
enabled: getSharedDefault("enabled", true),
speeds: {},
rememberSpeed: false,
forceLastSavedSpeed: false,
audioBoolean: false,
startHidden: false,
hideWithYouTubeControls: false,
hideWithControls: false,
hideWithControlsTimer: 2.0,
controllerLocation: "top-left",
controllerOpacity: 0.3,
controllerMarginTop: 0,
controllerMarginRight: 0,
controllerMarginBottom: 65,
controllerMarginLeft: 0,
keyBindings: [],
siteRules: [],
controllerButtons: ["rewind", "slower", "faster", "advance", "display"],
rememberSpeed: getSharedDefault("rememberSpeed", false),
forceLastSavedSpeed: getSharedDefault("forceLastSavedSpeed", false),
audioBoolean: getSharedDefault("audioBoolean", false),
showAmbientLoopControls: getSharedDefault(
"showAmbientLoopControls",
false
),
startHidden: getSharedDefault("startHidden", false),
hideWithYouTubeControls: getSharedDefault(
"hideWithYouTubeControls",
false
),
hideWithControls: getSharedDefault("hideWithControls", false),
hideWithControlsTimer: getSharedDefault("hideWithControlsTimer", 2.0),
controllerLocation: getSharedDefault("controllerLocation", "top-left"),
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,
logLevel: 3,
enableSubtitleNudge: false,
subtitleNudgeEnabledByDefault: true,
subtitleNudgeInterval: 50, // Default 50ms balances subtitle tracking with CPU cost
subtitleNudgeAmount: 0.001,
enableSubtitleNudge: getSharedDefault("enableSubtitleNudge", false),
subtitleNudgeEnabledByDefault: getSharedDefault(
"subtitleNudgeEnabledByDefault",
true
),
subtitleNudgeInterval: getSharedDefault("subtitleNudgeInterval", 250),
subtitleNudgeAmount: getSharedDefault("subtitleNudgeAmount", 0.001),
customButtonIcons: {}
},
mediaElements: [],
isNudging: false,
pendingLastSpeedSave: null,
pendingLastSpeedValue: null,
lastSpeedSaveRetries: 0,
lastSpeedWriteInFlight: false,
lastSpeedWriteValue: null,
pendingRememberedSpeedsSave: null,
rememberedSpeedsSaveRetries: 0,
rememberedSpeedsWriteEpoch: 0,
rememberedSpeedsWriteInFlight: null,
rememberedSpeedsResetAt: 0,
speedAccessTimes: {},
persistedLastSpeed: 1.0,
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;
var MAX_SPEED = 16;
function isSpeederActiveForCurrentPage() {
return (
tc.tabPaused !== true &&
siteRuleUtils.isSpeederActiveForSite(
tc.settings.enabled,
tc.activeSiteRule
)
);
}
function applyTabPausedState(paused) {
tc.tabPaused = paused === true;
if (!tc.runtimeSettingsHydrated) return;
if (tc.tabPaused) {
clearAllSpeedRestoreEnforcement();
tc.mediaElements.slice().forEach(function(media) {
removeController(media);
});
return;
}
initializeWhenReady(document, true);
}
var MIN_SPEED = Number(keyBindingUtils.MIN_SPEED) || 0.1;
var MAX_SPEED = Number(keyBindingUtils.MAX_SPEED) || 16;
var YT_NATIVE_MIN = 0.25;
var YT_NATIVE_MAX = 2.0;
var YT_NATIVE_STEP = 0.05;
var vscObservedRoots = new WeakSet();
var vscObservedRootList = [];
var vscConnectedScannedRoots = new WeakSet();
var vscInitializedDocuments = new Set();
var vscInitializedDocuments = new WeakSet();
var vscSourceObjectIds = new WeakMap();
var vscNextSourceObjectId = 1;
var requestIdle =
typeof window.requestIdleCallback === "function"
? window.requestIdleCallback.bind(window)
@@ -126,21 +338,25 @@ var controllerLocationStyles = {
/* `label` fallback only when ui-icons has no path for the action. */
var controllerButtonDefs = {
rewind: { label: "", className: "rw" },
slower: { label: "", className: "" },
faster: { label: "", className: "" },
advance: { label: "", className: "rw" },
display: { label: "", className: "hideButton" },
reset: { label: "\u21BB", className: "" },
fast: { label: "", className: "" },
nudge: { label: "", className: "" },
pause: { label: "", className: "" },
muted: { label: "", className: "" },
louder: { label: "", className: "" },
softer: { label: "", className: "" },
mark: { label: "", className: "" },
jump: { label: "", className: "" },
settings: { label: "", className: "" }
rewind: { label: "", name: "Rewind", className: "rw" },
slower: { label: "", name: "Decrease speed", className: "" },
faster: { label: "", name: "Increase speed", className: "" },
advance: { label: "", name: "Advance", className: "rw" },
display: {
label: "",
name: "Show or hide controller",
className: "hideButton"
},
reset: { label: "\u21BB", name: "Reset speed", className: "" },
fast: { label: "", name: "Toggle preferred speed", className: "" },
nudge: { label: "", name: "Toggle subtitle nudge", className: "" },
pause: { label: "", name: "Play or pause", className: "" },
muted: { label: "", name: "Mute or unmute", className: "" },
louder: { label: "", name: "Increase volume", className: "" },
softer: { label: "", name: "Decrease volume", className: "" },
mark: { label: "", name: "Mark position", className: "" },
jump: { label: "", name: "Jump to marked position", className: "" },
settings: { label: "", name: "Open Speeder settings", className: "" }
};
function createDefaultBinding(action, code, value) {
@@ -383,9 +599,11 @@ function captureSiteRuleBase() {
rememberSpeed: tc.settings.rememberSpeed,
forceLastSavedSpeed: tc.settings.forceLastSavedSpeed,
audioBoolean: tc.settings.audioBoolean,
showAmbientLoopControls: tc.settings.showAmbientLoopControls,
controllerOpacity: tc.settings.controllerOpacity,
controllerMarginTop: tc.settings.controllerMarginTop,
controllerMarginBottom: tc.settings.controllerMarginBottom,
shortcutTargetMode: tc.settings.shortcutTargetMode,
enableSubtitleNudge: tc.settings.enableSubtitleNudge,
subtitleNudgeEnabledByDefault: tc.settings.subtitleNudgeEnabledByDefault,
subtitleNudgeInterval: tc.settings.subtitleNudgeInterval,
@@ -410,9 +628,11 @@ function resetSettingsFromSiteRuleBase() {
tc.settings.rememberSpeed = base.rememberSpeed;
tc.settings.forceLastSavedSpeed = base.forceLastSavedSpeed;
tc.settings.audioBoolean = base.audioBoolean;
tc.settings.showAmbientLoopControls = base.showAmbientLoopControls;
tc.settings.controllerOpacity = base.controllerOpacity;
tc.settings.controllerMarginTop = base.controllerMarginTop;
tc.settings.controllerMarginBottom = base.controllerMarginBottom;
tc.settings.shortcutTargetMode = base.shortcutTargetMode;
tc.settings.enableSubtitleNudge = base.enableSubtitleNudge;
tc.settings.subtitleNudgeEnabledByDefault = base.subtitleNudgeEnabledByDefault;
tc.settings.subtitleNudgeInterval = base.subtitleNudgeInterval;
@@ -521,7 +741,11 @@ function normalizeStoredBinding(binding, fallbackCode) {
action: binding.action,
code: null,
disabled: true,
value: Number(binding.value),
value: keyBindingUtils.sanitizeActionValue(
binding.action,
binding.value,
0
),
force: String(binding.force) === "true" ? "true" : "false",
predefined: Boolean(binding.predefined)
};
@@ -535,8 +759,13 @@ function normalizeStoredBinding(binding, fallbackCode) {
var normalized = {
action: binding.action,
code: normalizedCode,
shiftKey: binding.shiftKey === true,
disabled: false,
value: Number(binding.value),
value: keyBindingUtils.sanitizeActionValue(
binding.action,
binding.value,
0
),
force: String(binding.force) === "true" ? "true" : "false",
predefined: Boolean(binding.predefined)
};
@@ -554,7 +783,20 @@ function sanitizeSpeed(speed, fallback) {
}
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) {
@@ -589,21 +831,30 @@ function getRememberedSpeed(video) {
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);
if (sourceKey !== "unknown_src") {
var videoSpeed = tc.settings.speeds[sourceKey];
if (isValidSpeed(videoSpeed)) return videoSpeed;
}
if (tc.settings.forceLastSavedSpeed && isValidSpeed(tc.settings.lastSpeed)) {
return tc.settings.lastSpeed;
}
if (tc.settings.rememberSpeed && isValidSpeed(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
// next Short even when that exact source URL hasn't been visited before.
if (isValidSpeed(tc.settings.lastSpeed)) {
return tc.settings.lastSpeed;
}
return null;
}
function getDesiredSpeed(video) {
if (tc.settings.forceLastSavedSpeed && isValidSpeed(tc.settings.lastSpeed)) {
return tc.settings.lastSpeed;
}
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) {
if (!isValidSpeed(speed)) return;
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 speedToPersist = tc.pendingLastSpeedValue;
tc.pendingLastSpeedSave = null;
var MAX_PERSISTED_SOURCE_SPEEDS = 200;
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;
}
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;
}, 250);
}, 500);
}
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) {
@@ -829,29 +1283,34 @@ function resolveTargetSpeed(video) {
function applySourceTransitionPolicy(video, forceUpdate) {
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);
if (!forceUpdate && video.vsc.mediaSourceKey === sourceKey) return;
video.vsc.mediaSourceKey = sourceKey;
var desiredSpeed =
tc.settings.rememberSpeed || tc.settings.forceLastSavedSpeed
? sanitizeSpeed(tc.settings.lastSpeed, 1.0)
: 1.0;
var rememberedSpeed = getRememberedSpeed(video);
var desiredSpeed = isValidSpeed(rememberedSpeed) ? rememberedSpeed : 1.0;
video.vsc.targetSpeed = desiredSpeed;
video.vsc.targetSpeedSourceKey = sourceKey;
video.vsc.targetSpeedOrigin = isValidSpeed(rememberedSpeed)
? "policy"
: "source-reset";
if (video.vsc.speedIndicator) {
video.vsc.speedIndicator.textContent = desiredSpeed.toFixed(2);
}
if (isValidSpeed(rememberedSpeed)) {
extendSpeedRestoreWindow(video);
}
if (Math.abs(video.playbackRate - desiredSpeed) > 0.01) {
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) {
@@ -864,6 +1323,91 @@ function extendSpeedRestoreWindow(video, duration) {
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) {
if (!video || !video.vsc || !isValidSpeed(desiredSpeed)) return;
@@ -888,11 +1432,12 @@ function scheduleSpeedRestore(video, desiredSpeed, reason) {
}, 0);
}
function rememberPendingRateChange(video, speed) {
function rememberPendingRateChange(video, speed, shouldPersist) {
if (!video || !video.vsc || !isValidSpeed(speed)) return;
video.vsc.pendingRateChange = {
speed: Number(speed),
shouldPersist: shouldPersist === true,
expiresAt: Date.now() + 1000
};
}
@@ -923,7 +1468,8 @@ function matchesKeyBinding(binding, event) {
binding.disabled !== true &&
typeof binding.code === "string" &&
binding.code.length > 0 &&
binding.code === event.code
binding.code === event.code &&
(binding.shiftKey === true) === event.shiftKey
);
}
@@ -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) {
if (!isMediaElement(node) || !node.isConnected) return false;
if (node.currentSrc || node.src || node.srcObject) return true;
@@ -965,8 +1587,80 @@ function hasUsableMediaSource(node) {
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) {
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)) {
log(
`Deferring controller creation for ${node.tagName}: no usable source yet`,
@@ -975,26 +1669,74 @@ function ensureController(node, parent) {
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();
log(
`Creating controller for ${node.tagName}: ${node.src || node.currentSrc || "no src"}`,
4
);
node.vsc = new tc.videoController(
node,
parent || node.parentElement || node.parentNode
);
try {
node.vsc = new tc.videoController(
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;
}
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) {
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) {
@@ -1019,6 +1761,17 @@ function scanNodeForMedia(node, parent, added) {
if (isMediaElement(node)) {
if (added) ensureController(node, parent);
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
@@ -1042,21 +1795,6 @@ function scanNodeForMedia(node, parent, added) {
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) {
@@ -1086,9 +1824,53 @@ function scanRootForMedia(root) {
var scanRoot = getScanNodeForRoot(root);
if (!scanRoot) return;
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) {
@@ -1106,12 +1888,16 @@ function observeRoot(root) {
if (!vscObservedRoots.has(root)) {
vscObservedRoots.add(root);
vscObservedRootList.push(root);
setupListener(root);
attachMutationObserver(root);
attachMediaDetectionListeners(root);
}
if (isConnected && !vscConnectedScannedRoots.has(root)) {
if (
isConnected &&
(!vscConnectedScannedRoots.has(root) || isShadowRootNode(root))
) {
vscConnectedScannedRoots.add(root);
if (rootMayContainMedia(root)) {
scanRootForMedia(root);
@@ -1143,6 +1929,109 @@ function patchAttachShadow() {
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 */
function log(message, level) {
verbosity = tc.settings.logLevel;
@@ -1160,123 +2049,398 @@ function log(message, level) {
}
}
// 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();
chrome.storage.sync.get(tc.settings, function(storage) {
function hydrateRuntimeSettings(rawStorage, options) {
var config = options || {};
var storage =
typeof vscExpandStoredSettings === "function"
? vscExpandStoredSettings(rawStorage || {})
: Object.assign({}, tc.settings, rawStorage || {});
var storedBindings = Array.isArray(storage.keyBindings)
? storage.keyBindings
: [];
tc.settings.keyBindings = storedBindings
.map((binding) => normalizeStoredBinding(binding))
.map(function(binding) {
return normalizeStoredBinding(binding);
})
.filter(Boolean);
if (tc.settings.keyBindings.length === 0) {
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);
tc.settings.lastSpeed = 1.0;
tc.persistedLastSpeed = 1.0;
chrome.storage.sync.set({ lastSpeed: 1.0 });
} else if (!isValidSpeed(tc.settings.lastSpeed)) {
tc.settings.lastSpeed = 1.0;
nextLastSpeed = 1.0;
if (!hasPendingLastSpeed) {
chrome.storage.sync.set({ lastSpeed: 1.0 });
}
}
tc.persistedLastSpeed = tc.settings.lastSpeed;
tc.settings.rememberSpeed = Boolean(storage.rememberSpeed);
tc.settings.forceLastSavedSpeed = Boolean(storage.forceLastSavedSpeed);
tc.settings.audioBoolean = Boolean(storage.audioBoolean);
tc.settings.enabled = Boolean(storage.enabled);
tc.settings.startHidden = Boolean(storage.startHidden);
tc.settings.lastSpeed = nextLastSpeed;
if (isValidSpeed(storedLastSpeed)) {
tc.persistedLastSpeed = storedLastSpeed;
} else if (!hasPendingLastSpeed) {
tc.persistedLastSpeed = nextLastSpeed;
}
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 =
typeof storage.hideWithControls !== "undefined"
? Boolean(storage.hideWithControls)
: Boolean(storage.hideWithYouTubeControls);
tc.settings.hideWithControlsTimer =
Math.min(15, Math.max(0.1, Number(storage.hideWithControlsTimer) || 2.0));
tc.settings.hideWithControlsTimer = Math.min(
15,
Math.max(0.1, Number(storage.hideWithControlsTimer) || 2.0)
);
tc.settings.hideWithYouTubeControls = tc.settings.hideWithControls;
tc.settings.controllerLocation = normalizeControllerLocation(
storage.controllerLocation
);
tc.settings.controllerOpacity = Number(storage.controllerOpacity);
if (!Number.isFinite(tc.settings.controllerOpacity)) {
tc.settings.controllerOpacity = getSharedDefault("controllerOpacity", 0.3);
}
tc.settings.controllerMarginTop = normalizeControllerMarginPx(
storage.controllerMarginTop,
0
getSharedDefault("controllerMarginTop", 0)
);
tc.settings.controllerMarginRight = normalizeControllerMarginPx(
0,
0
storage.controllerMarginRight,
getSharedDefault("controllerMarginRight", 0)
);
tc.settings.controllerMarginBottom = normalizeControllerMarginPx(
storage.controllerMarginBottom,
typeof storage.controllerMarginBottom !== "undefined" ? 0 : 65
getSharedDefault("controllerMarginBottom", 65)
);
tc.settings.controllerMarginLeft = normalizeControllerMarginPx(
0,
0
storage.controllerMarginLeft,
getSharedDefault("controllerMarginLeft", 0)
);
tc.settings.shortcutTargetMode =
storage.shortcutTargetMode === "all" ? "all" : "closest";
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)
? storage.controllerButtons
: tc.settings.controllerButtons;
tc.settings.enableSubtitleNudge =
typeof storage.enableSubtitleNudge !== "undefined"
? Boolean(storage.enableSubtitleNudge)
: tc.settings.enableSubtitleNudge;
? storage.controllerButtons.slice()
: getSharedDefault("controllerButtons", [
"rewind",
"slower",
"faster",
"advance",
"display"
]);
tc.settings.enableSubtitleNudge = Boolean(storage.enableSubtitleNudge);
tc.settings.subtitleNudgeEnabledByDefault =
typeof storage.subtitleNudgeEnabledByDefault !== "undefined"
? Boolean(storage.subtitleNudgeEnabledByDefault)
: tc.settings.subtitleNudgeEnabledByDefault;
: getSharedDefault("subtitleNudgeEnabledByDefault", true);
tc.settings.subtitleNudgeInterval = Math.min(
1000,
Math.max(10, Number(storage.subtitleNudgeInterval) || 50)
Math.max(250, Number(storage.subtitleNudgeInterval) || 250)
);
tc.settings.subtitleNudgeAmount =
Number(storage.subtitleNudgeAmount) || tc.settings.subtitleNudgeAmount;
Number(storage.subtitleNudgeAmount) ||
getSharedDefault("subtitleNudgeAmount", 0.001);
var addedDefaultBinding = false;
addedDefaultBinding =
ensureDefaultKeyBinding(
"display",
"KeyV",
0
) || addedDefaultBinding;
ensureDefaultKeyBinding("display", "KeyV", 0) || addedDefaultBinding;
addedDefaultBinding =
ensureDefaultKeyBinding("move", "KeyP", 0) || addedDefaultBinding;
addedDefaultBinding =
ensureDefaultKeyBinding("toggleSubtitleNudge", "KeyN", 0) ||
addedDefaultBinding;
if (addedDefaultBinding) {
if (addedDefaultBinding && config.persistMissingBindings !== false) {
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();
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
// Add a listener for messages from the popup.
// We use a global flag to ensure the listener is only attached once.
if (!window.vscMessageListener) {
chrome.runtime.onMessage.addListener(
function(request, sender, sendResponse) {
if (request.action === "set_tab_paused") {
applyTabPausedState(request.paused === true);
sendResponse({ paused: tc.tabPaused });
return false;
}
if (request.action === "rescan_page") {
log("Re-scan command received from popup.", 4);
initializeWhenReady(document, true);
@@ -1289,7 +2453,14 @@ chrome.storage.sync.get(tc.settings, function(storage) {
var videoGs = getPrimaryVideoElement();
if (!videoGs) return false;
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;
}
@@ -1297,7 +2468,41 @@ chrome.storage.sync.get(tc.settings, function(storage) {
sendResponse({ url: location.href });
return false;
}
if (request.action === "set_force_last_saved_speed") {
var forceEnabled = Boolean(request.enabled);
if (tc.siteRuleBase) {
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();
if (tc.settings.forceLastSavedSpeed) {
tc.mediaElements.forEach(function(video) {
if (!video || !video.vsc) return;
setSpeed(video, tc.settings.lastSpeed, false, false);
video.vsc.targetSpeedOrigin = "policy";
extendSpeedRestoreWindow(video);
});
}
sendResponse({
enabled: tc.settings.forceLastSavedSpeed,
speed: forceVideo ? forceVideo.playbackRate : null
});
return false;
}
if (request.action === "run_action") {
if (
request.targetFrameToken &&
request.targetFrameToken !== tc.frameToken
) {
return false;
}
if (
!isSpeederActiveForCurrentPage()
) {
return false;
}
var value = request.value;
if (value === undefined || value === null) {
value = getKeyBindings(request.actionName, "value");
@@ -1317,64 +2522,178 @@ chrome.storage.sync.get(tc.settings, function(storage) {
// Set the flag to prevent adding the listener again.
window.vscMessageListener = true;
}
chrome.storage.local.get(["customButtonIcons"], function(loc) {
tc.settings.customButtonIcons =
loc &&
loc.customButtonIcons &&
typeof loc.customButtonIcons === "object"
? loc.customButtonIcons
: {};
loadInitialLocalSettings(0, function(loc) {
applyCustomButtonIcons(loc && loc.customButtonIcons);
applyRememberedSpeedsResetMarker(
loc && loc.rememberedSpeedsResetAt
);
replaceRememberedSpeeds(loc && loc.rememberedSpeeds);
if (!window.vscCustomIconListener) {
window.vscCustomIconListener = true;
chrome.storage.onChanged.addListener(function(changes, area) {
if (area !== "local" || !changes.customButtonIcons) return;
var nv = changes.customButtonIcons.newValue;
tc.settings.customButtonIcons =
nv && typeof nv === "object" ? nv : {};
if (tc.mediaElements && tc.mediaElements.length) {
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 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) || "?";
if (area === "sync") {
var changedKeys = Object.keys(changes || {});
var isLastSpeedOnly =
changedKeys.length === 1 && changedKeys[0] === "lastSpeed";
if (isLastSpeedOnly) {
var changedLastSpeed = Number(changes.lastSpeed.newValue);
if (isValidSpeed(changedLastSpeed)) {
tc.persistedLastSpeed = changedLastSpeed;
var newerLocalLastSpeed = isValidSpeed(tc.pendingLastSpeedValue)
? Number(tc.pendingLastSpeedValue)
: tc.lastSpeedWriteInFlight &&
isValidSpeed(tc.lastSpeedWriteValue)
? Number(tc.lastSpeedWriteValue)
: null;
if (
isValidSpeed(newerLocalLastSpeed) &&
newerLocalLastSpeed !== changedLastSpeed
) {
return;
}
});
updateSubtitleNudgeIndicator(video);
});
tc.settings.lastSpeed = changedLastSpeed;
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") {
try {
@@ -1385,13 +2704,21 @@ function getKeyBindings(action, what = "value") {
}
function setKeyBindings(action, value) {
tc.settings.keyBindings.find((item) => item.action === action)["value"] =
value;
var binding = tc.settings.keyBindings.find(function(item) {
return item.action === action;
});
if (!binding) return false;
binding.value = value;
return true;
}
function createControllerButton(doc, action, label, className) {
var button = doc.createElement("button");
var name = controllerButtonDefs[action] && controllerButtonDefs[action].name;
button.type = "button";
button.dataset.action = action;
button.setAttribute("aria-label", name || action);
button.title = name || action;
var custom =
tc.settings.customButtonIcons &&
tc.settings.customButtonIcons[action] &&
@@ -1419,6 +2746,610 @@ function createControllerButton(doc, action, label, className) {
return button;
}
function createsControllerStackingContext(element) {
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 mount = video.parentElement;
var candidate = mount;
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.
// Climb through tightly-sized wrappers so our host shares their stacking
// context, but stop before broad page-layout containers.
while (candidate && candidate.parentElement && depth < 5) {
if (mountBoundary && candidate === mountBoundary) break;
var next = candidate.parentElement;
if (
mountBoundary &&
next !== mountBoundary &&
!mountBoundary.contains(next)
) {
break;
}
var nextRect = next.getBoundingClientRect();
var widthLimit = Math.max(videoRect.width * 1.35, videoRect.width + 80);
var heightLimit = Math.max(videoRect.height * 1.35, videoRect.height + 80);
var containsVideo =
nextRect.left <= videoRect.left + 1 &&
nextRect.top <= videoRect.top + 1 &&
nextRect.right >= videoRect.right - 1 &&
nextRect.bottom >= videoRect.bottom - 1;
if (
videoRect.width <= 0 ||
videoRect.height <= 0 ||
!containsVideo ||
nextRect.width > widthLimit ||
nextRect.height > heightLimit
) {
break;
}
mount = next;
candidate = next;
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;
}
function getFullscreenElement(doc) {
if (!doc) return null;
return (
doc.fullscreenElement ||
doc.webkitFullscreenElement ||
doc.mozFullScreenElement ||
doc.msFullscreenElement ||
null
);
}
function positionControllerHost(wrapper, video, mount) {
if (!wrapper || !video || !mount || !wrapper.isConnected) return;
var geometryMount = getControllerGeometryMount(mount);
if (!geometryMount || typeof geometryMount.getBoundingClientRect !== "function") {
return;
}
var videoRect = video.getBoundingClientRect();
if (wrapper.classList.contains("vsc-fullscreen-popover")) {
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
// the overlay aligned even when a player or ancestor is scaled.
var mountWidth = geometryMount.offsetWidth || mountRect.width || 1;
var mountHeight = geometryMount.offsetHeight || mountRect.height || 1;
var scaleX = mountRect.width > 0 ? mountRect.width / mountWidth : 1;
var scaleY = mountRect.height > 0 ? mountRect.height / mountHeight : 1;
var left =
(videoRect.left - mountRect.left) / scaleX -
(geometryMount.clientLeft || 0) +
(geometryMount.scrollLeft || 0);
var top =
(videoRect.top - mountRect.top) / scaleY -
(geometryMount.clientTop || 0) +
(geometryMount.scrollTop || 0);
wrapper.style.setProperty("left", left + "px", "important");
wrapper.style.setProperty("top", top + "px", "important");
wrapper.style.setProperty(
"width",
videoRect.width / scaleX + "px",
"important"
);
wrapper.style.setProperty(
"height",
videoRect.height / scaleY + "px",
"important"
);
}
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) {
if (!videoController || !wrapper || !mount) return;
var doc = videoController.video.ownerDocument;
var win = doc.defaultView || window;
var geometryMount = getControllerGeometryMount(mount);
if (!geometryMount || !geometryMount.style) return;
var frameId = null;
var geometryRetryTimer = null;
var geometryRetryAttempts = 0;
var maxGeometryRetryAttempts = 12;
var update = function() {
frameId = null;
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() {
if (frameId !== null) return;
frameId = win.requestAnimationFrame(update);
};
if (win.getComputedStyle(geometryMount).position === "static") {
geometryMount.dataset.vscPositionOwner = "true";
geometryMount.dataset.vscOriginalPosition =
geometryMount.style.getPropertyValue("position");
geometryMount.dataset.vscOriginalPositionPriority =
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;
if (typeof win.ResizeObserver === "function") {
resizeObserver = new win.ResizeObserver(schedule);
resizeObserver.observe(videoController.video);
resizeObserver.observe(geometryMount);
}
win.addEventListener("resize", schedule, { passive: true });
doc.addEventListener("fullscreenchange", schedule, { passive: true });
geometryMount.addEventListener("scroll", schedule, { passive: true });
update();
videoController.controllerHostMount = mount;
videoController.controllerHostCleanup = function(forceRestore) {
if (resizeObserver) resizeObserver.disconnect();
if (frameId !== null) win.cancelAnimationFrame(frameId);
if (geometryRetryTimer !== null) win.clearTimeout(geometryRetryTimer);
win.removeEventListener("resize", schedule);
doc.removeEventListener("fullscreenchange", 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 (
geometryMount.dataset.vscPositionOwner === "true" &&
!hasOtherController &&
(forceRestore === true || !wrapper.isConnected)
) {
if (
geometryMount.style.getPropertyValue("position") ===
geometryMount.dataset.vscAppliedPosition &&
geometryMount.style.getPropertyPriority("position") === ""
) {
if (geometryMount.dataset.vscOriginalPosition) {
geometryMount.style.setProperty(
"position",
geometryMount.dataset.vscOriginalPosition,
geometryMount.dataset.vscOriginalPositionPriority || ""
);
} else {
geometryMount.style.removeProperty("position");
}
}
delete geometryMount.dataset.vscPositionOwner;
delete geometryMount.dataset.vscOriginalPosition;
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() {
tc.videoController = function(target, parent) {
if (target.vsc) return target.vsc;
@@ -1430,6 +3361,9 @@ function defineVideoController() {
this.restoreSpeedTimer = null;
this.pendingRateChange = null;
this.speedRestoreUntil = 0;
this.speedVerificationTimer = null;
this.speedVerificationTarget = null;
this.speedVerificationAttempts = 0;
this.subtitleNudgeEnabledOverride = null;
this.suppressedRateChangeCount = 0;
this.suppressedRateChangeUntil = 0;
@@ -1439,19 +3373,28 @@ function defineVideoController() {
this.controllerLocation = normalizeControllerLocation(
tc.settings.controllerLocation
);
this.structureSignature = getControllerStructureSignature();
log(`Creating video controller for ${target.tagName} with src: ${target.src || target.currentSrc || "none"}`, 4);
let storedSpeed = sanitizeSpeed(resolveTargetSpeed(target), 1.0);
this.targetSpeed = storedSpeed;
this.targetSpeedSourceKey = getVideoSourceKey(target);
this.targetSpeedOrigin =
tc.settings.rememberSpeed || tc.settings.forceLastSavedSpeed
? "policy"
: "initial";
this.mediaSourceKey = getVideoSourceKey(target);
if (!tc.settings.rememberSpeed && !tc.settings.forceLastSavedSpeed) {
setKeyBindings("reset", getKeyBindings("fast"));
}
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();
@@ -1463,11 +3406,39 @@ function defineVideoController() {
log(`Controller created and attached to DOM. Hidden: ${this.div.classList.contains("vsc-hidden")}`, 4);
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 (
event.type === "loadedmetadata" ||
event.type === "loadeddata" ||
event.type === "canplay"
) {
if (this.div) this.div.classList.remove("vsc-nosource");
applySourceTransitionPolicy(event.target, false);
}
@@ -1519,6 +3490,14 @@ function defineVideoController() {
}
};
target.addEventListener(
"loadstart",
(this.handleLoadStart = mediaEventAction.bind(this))
);
target.addEventListener(
"emptied",
(this.handleEmptied = mediaEventAction.bind(this))
);
target.addEventListener(
"loadedmetadata",
(this.handleLoadedMetadata = mediaEventAction.bind(this))
@@ -1563,7 +3542,10 @@ function defineVideoController() {
if (this.div) {
this.stopSubtitleNudge();
if (!mutation.target.src && !mutation.target.currentSrc) {
this.div.classList.add("vsc-nosource");
this.div.classList.toggle(
"vsc-nosource",
!hasUsableMediaSource(mutation.target)
);
} else {
this.div.classList.remove("vsc-nosource");
applySourceTransitionPolicy(this.video, true);
@@ -1582,6 +3564,19 @@ function defineVideoController() {
tc.videoController.prototype.remove = function() {
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) {
this.youTubeAutoHideObserver.disconnect();
this.youTubeAutoHideObserver = null;
@@ -1595,8 +3590,15 @@ function defineVideoController() {
this.genericAutoHideCleanup = null;
}
if (this.div) this.div.remove();
if (this.controllerHostCleanup) {
this.controllerHostCleanup();
this.controllerHostCleanup = null;
}
if (this.restoreSpeedTimer) clearTimeout(this.restoreSpeedTimer);
clearSpeedVerification(this);
if (this.video) {
this.video.removeEventListener("loadstart", this.handleLoadStart);
this.video.removeEventListener("emptied", this.handleEmptied);
this.video.removeEventListener("loadedmetadata", this.handleLoadedMetadata);
this.video.removeEventListener("loadeddata", this.handleLoadedData);
this.video.removeEventListener("canplay", this.handleCanPlay);
@@ -1663,17 +3665,35 @@ function defineVideoController() {
// Cache values to avoid repeated property access
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)
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
Promise.resolve().then(() => {
if (this.video && targetSpeed) {
this.video.playbackRate = targetSpeed;
try {
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
@@ -1716,18 +3736,34 @@ function defineVideoController() {
}
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;
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
Promise.resolve().then(() => {
if (this.video) {
this.video.playbackRate = targetRate;
try {
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);
@@ -1747,12 +3783,19 @@ function defineVideoController() {
// YouTube adds ytp-autohide class to the player when controls should be hidden
// We mirror this class state to enable CSS-based hiding
// 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");
// Immediately end any temporary "vsc-show" state to hide with YouTube
// UNLESS it was forced by a shortcut (vsc-forced-show)
if (!wrapper.classList.contains("vsc-forced-show")) {
// Preserve an active pointer reveal or shortcut-forced reveal. The
// ytp-autohide class remains in place, so CSS hides the host as soon as
// vsc-show's bounded timer expires.
if (
!wrapper.classList.contains("vsc-forced-show") &&
!wrapper.classList.contains("vsc-show")
) {
wrapper.classList.remove("vsc-show");
if (wrapper.showTimeOut) {
clearTimeout(wrapper.showTimeOut);
@@ -1792,6 +3835,16 @@ function defineVideoController() {
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"];
activityEvents.forEach((type) => {
video.addEventListener(type, resetTimer, { passive: true });
@@ -1806,6 +3859,9 @@ function defineVideoController() {
wrapper.removeEventListener(type, resetTimer);
ytPlayer.removeEventListener(type, resetTimer);
});
if (this.controllerInteractionChanged === handleControllerInteractionChange) {
this.controllerInteractionChanged = null;
}
};
};
@@ -1822,23 +3878,41 @@ function defineVideoController() {
clearTimeout(timer);
}
timer = setTimeout(() => {
timer = null;
if (this.controllerInteractionActive) return;
// Only hide if the video is not paused
// (Many players keep controls visible while paused)
// However, the user said "Reveal on every mouse and keyboard input"
// and "auto-hidden after timespan".
// and "auto-hidden after timespan".
// We'll follow the timer strictly.
wrapper.classList.add("vsc-idle-hidden");
log("Generic hide: controller hidden due to inactivity", 5);
}, 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
resetTimer();
// 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.
const activityEvents = ["mousemove", "mousedown", "keydown", "touchstart"];
const parentEl = video.parentElement;
const parentEl =
getControllerGeometryMount(this.controllerHostMount) ||
video.parentElement;
activityEvents.forEach((type) => {
video.addEventListener(type, resetTimer, { passive: true });
wrapper.addEventListener(type, resetTimer, { passive: true });
@@ -1863,6 +3937,9 @@ function defineVideoController() {
});
video.removeEventListener("play", 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);
@@ -1873,10 +3950,10 @@ function defineVideoController() {
const speed = this.video.playbackRate.toFixed(2);
var wrapper = doc.createElement("div");
wrapper.classList.add("vsc-controller");
if (!this.video.src && !this.video.currentSrc)
if (!hasUsableMediaSource(this.video))
wrapper.classList.add("vsc-nosource");
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
// makes it topmost within the local stacking context — it won't overlay
// page-level modals or dialogs.
@@ -1910,11 +3987,14 @@ function defineVideoController() {
buttonConfig.forEach(function(btnId) {
if (btnId === "nudge") {
subtitleNudgeIndicator = doc.createElement("span");
subtitleNudgeIndicator = createControllerButton(
doc,
btnId,
controllerButtonDefs.nudge.label,
controllerButtonDefs.nudge.className
);
subtitleNudgeIndicator.id = "nudge-indicator";
subtitleNudgeIndicator.setAttribute("role", "button");
subtitleNudgeIndicator.setAttribute("aria-live", "polite");
subtitleNudgeIndicator.setAttribute("tabindex", "0");
controls.appendChild(subtitleNudgeIndicator);
} else {
var def = controllerButtonDefs[btnId];
@@ -1936,6 +4016,27 @@ function defineVideoController() {
controller.appendChild(nudgeFlashIndicator);
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.subtitleNudgeIndicator = subtitleNudgeIndicator;
this.nudgeFlashIndicator = nudgeFlashIndicator;
@@ -1977,92 +4078,68 @@ function defineVideoController() {
true
);
});
if (subtitleNudgeIndicator) {
subtitleNudgeIndicator.addEventListener(
"click",
(e) => {
var video = this.video;
if (video) {
var newState = !isSubtitleNudgeEnabledForVideo(video);
setSubtitleNudgeEnabledForVideo(video, newState);
}
blurAfterPointerTap(subtitleNudgeIndicator, e);
e.stopPropagation();
},
true
);
}
controller.addEventListener("click", (e) => e.stopPropagation(), false);
controller.addEventListener("mousedown", (e) => e.stopPropagation(), false);
// Setup auto-hide observers if enabled
if (tc.settings.hideWithControls) {
if (isOnYouTube()) {
this.setupYouTubeAutoHide(wrapper);
} else {
this.setupGenericAutoHide(wrapper);
}
}
const parentEl =
this.parent || this.video.parentElement || this.video.parentNode;
var mountEl = getControllerMount(this.video) || parentEl;
var fragment = doc.createDocumentFragment();
fragment.appendChild(wrapper);
const parentEl = this.parent || this.video.parentElement;
log(`Inserting controller: parentEl=${!!parentEl}, hostname=${location.hostname}`, 4);
log(`Inserting controller: parentEl=${!!parentEl}, parentNode=${!!parentEl?.parentNode}, 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);
doc.body.appendChild(fragment);
doc.body.appendChild(wrapper);
this.normalControllerMount = doc.body;
setupControllerHostTracking(this, wrapper, doc.body);
return wrapper;
}
try {
switch (true) {
case location.hostname == "www.amazon.com":
case location.hostname == "www.reddit.com":
case location.hostname === "www.amazon.com":
case location.hostname === "www.reddit.com":
case /hbogo\./.test(location.hostname):
log("Using parentElement.parentElement insertion", 5);
parentEl.parentElement.insertBefore(fragment, parentEl);
mountEl = parentEl.parentElement || mountEl;
break;
case location.hostname == "www.facebook.com":
log("Using Facebook-specific insertion", 5);
let p =
parentEl.parentElement.parentElement.parentElement.parentElement
.parentElement.parentElement.parentElement;
if (p && p.firstChild) p.insertBefore(fragment, p.firstChild);
else parentEl.insertBefore(fragment, parentEl.firstChild);
break;
case location.hostname == "tv.apple.com":
log("Using Apple TV-specific insertion", 5);
const r = parentEl.getRootNode();
const s = r && r.querySelector ? r.querySelector(".scrim") : null;
if (s) s.prepend(fragment);
else parentEl.insertBefore(fragment, parentEl.firstChild);
break;
case location.hostname == "www.youtube.com":
case location.hostname == "m.youtube.com":
case location.hostname == "music.youtube.com":
// YouTube's player DOM has .html5-video-container (video's parent) as a
// low layer with overlay siblings (.ytp-player-content, etc.) on top that
// intercept mouse events. Insert into .html5-video-player (the player
// root) so the controller sits above all overlay layers.
log("Using YouTube-specific insertion", 5);
var ytPlayer = parentEl.closest(".html5-video-player");
if (ytPlayer) {
ytPlayer.insertBefore(fragment, ytPlayer.firstChild);
} else {
parentEl.insertBefore(fragment, parentEl.firstChild);
case location.hostname === "www.facebook.com":
var facebookMount = parentEl;
for (var facebookDepth = 0; facebookDepth < 7; facebookDepth += 1) {
if (!facebookMount.parentElement) break;
facebookMount = facebookMount.parentElement;
}
mountEl = facebookMount || mountEl;
break;
case location.hostname === "tv.apple.com":
var appleRoot = parentEl.getRootNode();
var appleScrim =
appleRoot && appleRoot.querySelector
? appleRoot.querySelector(".scrim")
: null;
mountEl = appleScrim || mountEl;
break;
case location.hostname === "www.youtube.com":
case location.hostname === "m.youtube.com":
case location.hostname === "music.youtube.com":
mountEl =
(parentEl.closest && parentEl.closest(".html5-video-player")) ||
mountEl;
break;
default:
log("Using default insertion method", 5);
parentEl.insertBefore(fragment, parentEl.firstChild);
}
mountEl.appendChild(wrapper);
this.normalControllerMount = mountEl;
setupControllerHostTracking(this, wrapper, mountEl);
log("Controller successfully inserted into DOM", 4);
} catch (error) {
log(`Error inserting controller: ${error.message}`, 2);
// Fallback to body insertion
doc.body.appendChild(fragment);
doc.body.appendChild(wrapper);
this.normalControllerMount = doc.body;
setupControllerHostTracking(this, wrapper, doc.body);
}
return wrapper;
@@ -2070,18 +4147,31 @@ function defineVideoController() {
}
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();
tc.activeSiteRule = null;
if (!Array.isArray(tc.settings.siteRules) || tc.settings.siteRules.length === 0) {
return false;
return finishSiteRuleApplication(false);
}
var currentUrl = location.href;
var matchedRule = siteRuleUtils.matchSiteRule(currentUrl, tc.settings.siteRules);
if (!matchedRule) {
return false;
return finishSiteRuleApplication(false);
}
tc.activeSiteRule = matchedRule;
@@ -2090,7 +4180,7 @@ function applySiteRuleOverrides() {
// Check if extension should be enabled/disabled on this site
if (siteRuleUtils.isSiteRuleDisabled(matchedRule)) {
log(`Extension disabled for site: ${currentUrl}`, 4);
return true;
return finishSiteRuleApplication(true);
}
// Override general settings with site-specific overrides
@@ -2102,9 +4192,11 @@ function applySiteRuleOverrides() {
"rememberSpeed",
"forceLastSavedSpeed",
"audioBoolean",
"showAmbientLoopControls",
"controllerOpacity",
"controllerMarginTop",
"controllerMarginBottom",
"shortcutTargetMode",
"enableSubtitleNudge",
"subtitleNudgeEnabledByDefault",
"subtitleNudgeInterval"
@@ -2146,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). */
@@ -2158,16 +4262,40 @@ function refreshAllControllerGeometry() {
if (controllerEl) {
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. */
function reapplySiteRulesAndControllerGeometry() {
applySiteRuleOverrides();
if (!siteRuleUtils.isSpeederActiveForSite(tc.settings.enabled, tc.activeSiteRule)) {
return;
if (!isSpeederActiveForCurrentPage()) {
tc.mediaElements.slice().forEach(function(video) {
removeController(video);
});
return false;
}
removeIneligibleMediaControllers();
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) {
@@ -2177,8 +4305,13 @@ function shouldPreserveDesiredSpeed(video, speed) {
return false;
}
var preservesPausedTarget =
video.paused === true &&
(video.vsc.targetSpeedOrigin === "user" ||
video.vsc.targetSpeedOrigin === "policy");
return (
video.paused === true ||
preservesPausedTarget ||
(typeof video.vsc.speedRestoreUntil === "number" &&
video.vsc.speedRestoreUntil > Date.now())
);
@@ -2188,21 +4321,30 @@ function setupListener(root) {
root = root || document;
if (root.vscRateListenerAttached) return;
function updateSpeedFromEvent(video, skipResetDisarm) {
function updateSpeedFromEvent(video, options) {
if (!video.vsc || !video.vsc.speedIndicator) return;
if (!skipResetDisarm) {
var config = options || {};
if (config.skipResetDisarm !== true) {
video.vsc.resetToggleArmed = false;
}
var speed = video.playbackRate; // Preserve full precision (e.g. 0.0625)
var speed = video.playbackRate; // Preserve full precision (e.g. 0.01)
video.vsc.speedIndicator.textContent = speed.toFixed(2);
video.vsc.targetSpeed = speed;
video.vsc.targetSpeedSourceKey = getVideoSourceKey(video);
var sourceKey = getVideoSourceKey(video);
if (sourceKey !== "unknown_src") {
tc.settings.speeds[sourceKey] = speed;
// A native ratechange has no trustworthy user-origin signal; sites emit the
// 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;
schedulePersistLastSpeed(speed);
if (shouldPersist) {
rememberSourceSpeed(video, speed);
tc.settings.lastSpeed = speed;
schedulePersistLastSpeed(speed);
}
if (video.vsc) {
if (speed === 1.0 || video.paused) video.vsc.stopSubtitleNudge();
else video.vsc.startSubtitleNudge();
@@ -2217,21 +4359,36 @@ function setupListener(root) {
if (!video || typeof video.playbackRate === "undefined" || !video.vsc)
return;
if (shouldIgnoreSuppressedRateChange(video)) return;
var currentSpeed = video.playbackRate; // Preserve full precision (e.g. 0.01)
var pendingRateChange = takePendingRateChange(video, currentSpeed);
if (tc.settings.forceLastSavedSpeed) {
if (event.detail && event.detail.origin === "videoSpeed") {
video.playbackRate = event.detail.speed;
updateSpeedFromEvent(video, true);
if (pendingRateChange) {
updateSpeedFromEvent(video, {
skipResetDisarm: true,
shouldPersist: pendingRateChange.shouldPersist === true,
preserveTargetOrigin: true
});
} 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();
} else {
var currentSpeed = video.playbackRate; // Preserve full precision (e.g. 0.0625)
var desiredSpeed = getDesiredSpeed(video);
var pendingRateChange = takePendingRateChange(video, currentSpeed);
if (pendingRateChange) {
updateSpeedFromEvent(video, true);
updateSpeedFromEvent(video, {
skipResetDisarm: true,
shouldPersist: pendingRateChange.shouldPersist === true,
preserveTargetOrigin: true
});
return;
}
@@ -2246,7 +4403,7 @@ function setupListener(root) {
return;
}
updateSpeedFromEvent(video);
updateSpeedFromEvent(video, { shouldPersist: false });
}
},
true
@@ -2271,7 +4428,11 @@ function clearPendingInitialization(doc) {
function tryInitializeDocument(doc, forceReinit) {
if (!doc) return false;
if ((!forceReinit && vscInitializedDocuments.has(doc)) || !doc.body) {
if (
!tc.runtimeSettingsHydrated ||
(!forceReinit && vscInitializedDocuments.has(doc)) ||
!doc.body
) {
return false;
}
@@ -2314,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) {
// 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];
try {
if (inIframe() && window.top.document !== doc) docs.push(window.top.document);
} catch (e) {
}
docs.forEach(function(keyDoc) {
if (keyDoc.vscKeydownListenerAttached) return;
keyDoc.addEventListener(
var keyTarget = keyDoc.defaultView || keyDoc;
if (keyTarget.vscKeydownListenerAttached) return;
keyTarget.addEventListener(
"keydown",
function(event) {
if (
@@ -2338,10 +4524,10 @@ function attachKeydownListeners(doc) {
return;
}
if (isEditableShortcutTarget(event)) return;
if (
event.target.nodeName === "INPUT" ||
event.target.nodeName === "TEXTAREA" ||
event.target.isContentEditable
!isSpeederActiveForCurrentPage()
) {
return;
}
@@ -2355,18 +4541,40 @@ function attachKeydownListeners(doc) {
if (item) {
runAction(item.action, item.value, event);
// Always prevent default and stop propagation for Speeder shortcuts
// to prevent the website (e.g. YouTube) from reacting to these keys.
event.preventDefault();
event.stopPropagation();
// The per-site "Block site from capturing keypress" option owns
// propagation. Speeder still runs unblocked shortcuts, but the page
// can receive them too when the user left that option off.
if (item.force === true || item.force === "true") {
event.preventDefault();
event.stopImmediatePropagation();
}
}
},
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) {
if (root.vscMutationObserverAttached) return;
@@ -2381,9 +4589,11 @@ function attachMutationObserver(root) {
function() {
var mutationsToProcess = pendingMutations.splice(0);
mutationProcessingScheduled = false;
var controllerMountTargets = new Set();
mutationsToProcess.forEach(function(mutation) {
if (mutation.type === "childList") {
controllerMountTargets.add(mutation.target);
mutation.addedNodes.forEach(function(node) {
// Skip text nodes, comments, etc. — only elements can contain media
if (node.nodeType !== Node.ELEMENT_NODE) return;
@@ -2393,6 +4603,7 @@ function attachMutationObserver(root) {
if (node.nodeType !== Node.ELEMENT_NODE) return;
scanNodeForMedia(node, node.parentNode || mutation.target, false);
});
return;
}
@@ -2408,6 +4619,15 @@ function attachMutationObserver(root) {
return;
}
if (
target.nodeName === "SOURCE" &&
mutation.attributeName === "src"
) {
ensureControllerForMediaChild(target);
return;
}
if (
mutation.attributeName === "aria-hidden" &&
target.attributes["aria-hidden"] &&
@@ -2420,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 }
);
@@ -2457,54 +4697,49 @@ function attachMediaDetectionListeners(root) {
root.vscMediaEventListenersAttached = true;
}
function attachIframeListeners(doc) {
Array.from(doc.getElementsByTagName("iframe")).forEach(function(frame) {
if (!frame.vscLoadListenerAttached) {
frame.addEventListener("load", function() {
try {
if (frame.contentDocument) {
initializeWhenReady(frame.contentDocument, true);
}
} catch (e) {
}
});
frame.vscLoadListenerAttached = true;
}
try {
if (frame.contentDocument) {
initializeWhenReady(frame.contentDocument);
}
} catch (e) {
}
});
function scheduleNavigationRescan() {
var nextHref = location.href;
if (window.vscLastObservedHref !== nextHref) {
// Pointer and controller interaction state belongs to the previous route.
// YouTube keeps hover-preview and Shorts players connected across SPA
// navigations, so retaining either target can send shortcuts to an
// off-screen/stale player even though the new watch controller works.
tc.lastPointerPosition = null;
tc.lastInteractedMedia = null;
}
window.vscLastObservedHref = nextHref;
clearTimeout(window.vscNavigationRescanTimer);
window.vscNavigationRescanTimer = setTimeout(function() {
initializeWhenReady(document, true);
}, 300);
}
function attachNavigationListeners() {
if (window.vscNavigationListenersAttached) return;
var scheduleRescan = function() {
clearTimeout(window.vscNavigationRescanTimer);
window.vscNavigationRescanTimer = setTimeout(function() {
initializeWhenReady(document, true);
}, 300);
};
["pushState", "replaceState"].forEach(function(method) {
if (typeof history[method] !== "function") return;
var original = history[method];
history[method] = function() {
var result = original.apply(this, arguments);
scheduleRescan();
scheduleNavigationRescan();
return result;
};
});
window.addEventListener("popstate", scheduleRescan);
window.addEventListener("hashchange", scheduleRescan);
window.addEventListener("popstate", scheduleNavigationRescan);
window.addEventListener("hashchange", scheduleNavigationRescan);
/* YouTube often navigates without a history API call the extension can see first */
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;
}
@@ -2512,43 +4747,46 @@ function attachNavigationListeners() {
function initializeNow(doc, forceReinit = false) {
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();
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;
}
removeIneligibleMediaControllers();
if (!doc.body.classList.contains("vsc-initialized")) {
doc.body.classList.add("vsc-initialized");
}
if (typeof tc.videoController === "undefined") defineVideoController();
attachKeydownListeners(doc);
attachNavigationListeners();
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);
}
attachMediaTargetTracking(doc);
if (forceReinit) {
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();
if (tc.settings.rememberSpeed || tc.settings.forceLastSavedSpeed) {
tc.mediaElements.slice().forEach(applyRememberedSpeedPolicy);
}
}
vscInitializedDocuments.add(doc);
flushPendingMediaCandidates();
}
function setSpeed(
@@ -2578,12 +4816,20 @@ function setSpeed(
`setSpeed: Target ${numericSpeed.toFixed(2)}. Initial: ${isInitialCall}. UserKeyPress: ${isUserKeyPress}`,
4
);
tc.settings.lastSpeed = numericSpeed;
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
video.vsc.targetSpeed = numericSpeed;
video.vsc.targetSpeedSourceKey = getVideoSourceKey(video);
if (isUserKeyPress) {
video.vsc.targetSpeedOrigin = "user";
}
if (isUserKeyPress && !isInitialCall && video.vsc && video.vsc.div) {
runAction("blink", 1000, null, video); // Pass video to blink
@@ -2592,25 +4838,35 @@ function setSpeed(
// Try YouTube's native speed API first — keeps subtitles in sync without nudge
var usedNativeSpeed = false;
if (tc.settings.forceLastSavedSpeed) {
video.dispatchEvent(
new CustomEvent("ratechange", {
detail: {
origin: "videoSpeed",
speed: numericSpeed.toFixed(2),
fromUserInput: isUserKeyPress
}
})
);
} else {
if (Math.abs(video.playbackRate - numericSpeed) > 0.001) {
rememberPendingRateChange(video, numericSpeed);
if (Math.abs(video.playbackRate - numericSpeed) > 0.001) {
rememberPendingRateChange(video, numericSpeed, isUserKeyPress);
if (!tc.settings.forceLastSavedSpeed) {
usedNativeSpeed = tryYouTubeNativeSpeed(video, numericSpeed);
if (!usedNativeSpeed) {
}
if (!usedNativeSpeed) {
try {
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 (numericSpeed === 1.0 || video.paused) {
video.vsc.stopSubtitleNudge();
@@ -2621,33 +4877,119 @@ function setSpeed(
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) {
log("runAction Begin", 5);
var mediaTagsToProcess;
const specificVideo = arguments[3] || null;
var subtitleNudgeToggleValue = null;
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;
}
var mediaTagsToProcess = resolveActionMediaTargets(e, specificVideo);
if (action === "settings") {
chrome.runtime.sendMessage({ action: "openOptions" });
return;
@@ -2693,7 +5035,11 @@ function runAction(action, value, e) {
showController(controller, 2000, true);
}
if (v.classList.contains("vsc-cancelled")) return;
const numValue = parseFloat(value);
const numValue = keyBindingUtils.sanitizeActionValue(
action,
value,
getKeyBindings(action, "value")
);
switch (action) {
case "rewind":
isUserSeek = true;
@@ -2729,8 +5075,12 @@ function runAction(action, value, e) {
case "fast":
var preferredSpeed = numValue;
// Apply site-specific preferred speed override if available
if (tc.activeSiteRule && typeof tc.activeSiteRule.preferredSpeed === "number") {
preferredSpeed = tc.activeSiteRule.preferredSpeed;
if (tc.activeSiteRule && tc.activeSiteRule.preferredSpeed !== undefined) {
preferredSpeed = keyBindingUtils.sanitizeActionValue(
"fast",
tc.activeSiteRule.preferredSpeed,
preferredSpeed
);
}
resetSpeed(v, preferredSpeed, true);
break;
@@ -2922,13 +5272,20 @@ function handleDrag(video, e) {
const c = video.vsc.div;
const sC = convertControllerToManualPosition(video.vsc);
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 (
pE.parentNode &&
pE.parentNode.offsetHeight === pE.offsetHeight &&
pE.parentNode.offsetWidth === pE.offsetWidth
)
pE = pE.parentNode;
nextParent &&
nextParent.offsetHeight === pE.offsetHeight &&
nextParent.offsetWidth === pE.offsetWidth
) {
pE = nextParent;
nextParent = pE.parentElement;
}
video.classList.add("vcs-dragging");
sC.classList.add("dragging");
const iXY = [e.clientX, e.clientY],
@@ -2985,11 +5342,22 @@ function showController(controller, duration = 2000, forced = false) {
}, duration);
}
// Add global listener to handle fullscreen transitions and adjust controller positions
document.addEventListener("fullscreenchange", () => {
// Keep each controller inside the subtree promoted to the fullscreen top layer,
// then restore its normal player-local mount when fullscreen exits.
function handleFullscreenControllerTransition() {
tc.mediaElements.forEach((video) => {
if (video.vsc) {
syncControllerFullscreenMount(video.vsc);
applyControllerLocation(video.vsc, video.vsc.controllerLocation);
}
});
}
[
"fullscreenchange",
"webkitfullscreenchange",
"mozfullscreenchange",
"MSFullscreenChange"
].forEach(function(eventName) {
document.addEventListener(eventName, handleFullscreenControllerTransition);
});
+49
View File
@@ -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;
};
});
})();
+59 -1
View File
@@ -1,3 +1,51 @@
:host {
position: absolute !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
pointer-events: none !important;
z-index: 2147483647 !important;
white-space: normal;
overflow: visible !important;
}
/* Important declarations inside a shadow tree outrank important page styles.
Repeat the top-layer positioning here so fullscreen cannot fall back to the
base absolute host rule. */
:host(.vsc-fullscreen-popover) {
position: fixed !important;
inset: auto !important;
margin: 0 !important;
padding: 0 !important;
border: 0 !important;
background: transparent !important;
overflow: visible !important;
}
:host(.vsc-fullscreen-popover)::backdrop {
pointer-events: none !important;
background: transparent !important;
}
:host(.vsc-nosource),
:host(.vsc-hidden) {
display: none !important;
}
:host(.ytp-autohide:not(.vsc-hidden)),
:host(.vsc-idle-hidden:not(.vsc-hidden)) {
visibility: hidden;
opacity: 0;
}
:host(.ytp-autohide.vsc-show:not(.vsc-hidden)),
:host(.vsc-idle-hidden.vsc-show:not(.vsc-hidden)),
:host(.vsc-forced-show:not(.vsc-hidden)) {
visibility: visible !important;
opacity: 1 !important;
}
* {
line-height: 1.9em;
font-family: sans-serif;
@@ -14,6 +62,7 @@
after a mouse click kept #controls visible while hover-only rules (e.g. draggable
margin) turned off when the pointer left the bar. */
#controller:hover #controls,
#controller.vsc-controls-hovered #controls,
#controller:focus-within:has(:focus-visible) #controls,
:host(:hover) #controls {
display: inline-flex;
@@ -55,6 +104,10 @@
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. */
#controller[data-location="top-center"]:not([data-position-mode="manual"]) {
transform: translate(-50%, 0) !important;
@@ -281,10 +334,15 @@ button .vsc-btn-icon svg {
transform: translateY(0.5px);
}
button:focus {
button:focus:not(:focus-visible) {
outline: 0;
}
button:focus-visible {
outline: 2px solid #fff;
outline-offset: 2px;
}
button:hover {
opacity: 1;
}
+5 -1
View File
@@ -1,7 +1,7 @@
{
"name": "Speeder",
"short_name": "Speeder",
"version": "5.3.2.0",
"version": "6.0.8.0",
"manifest_version": 2,
"description": "Speed up, slow down, advance and rewind HTML5 audio/video with shortcuts (New and improved version of \"Video Speed Controller\")",
"homepage_url": "https://github.com/SoPat712/speeder",
@@ -26,6 +26,7 @@
]
},
"permissions": [
"activeTab",
"storage",
"https://cdn.jsdelivr.net/*"
],
@@ -50,6 +51,7 @@
"file:///*"
],
"match_about_blank": true,
"run_at": "document_start",
"exclude_matches": [
"https://plus.google.com/hangouts/*",
"https://hangouts.google.com/*",
@@ -61,6 +63,7 @@
"js": [
"shared/controller-utils.js",
"shared/key-bindings.js",
"shared/settings-core.js",
"shared/site-rules.js",
"shared/ui-icons.js",
"content/inject.js"
@@ -69,6 +72,7 @@
],
"web_accessible_resources": [
"content/inject.css",
"content/shadow-bridge.js",
"content/shadow.css"
]
}
+171 -50
View File
@@ -7,11 +7,83 @@ function generateBackupFilename() {
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() {
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) {
if (chrome.runtime.lastError) {
showStatus(
"Error: Failed to read local extension data - " +
chrome.runtime.lastError.message,
true
);
return;
}
const backup = importExportUtils.buildBackupPayload(
storage,
normalizedSettingsForExport(storage),
importExportUtils.filterLocalSettingsForExport(localStorage),
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() {
const input = document.createElement("input");
input.type = "file";
@@ -55,65 +187,54 @@ function importSettings() {
var settingsToImport = parsedBackup.settings;
var localToImport = parsedBackup.localSettings;
function importLocalSettings(callback) {
if (parsedBackup.isWrappedBackup !== true) {
callback();
chrome.storage.sync.get(null, function (currentRaw) {
if (chrome.runtime.lastError) {
showStatus(
"Error: Failed to read current settings - " +
chrome.runtime.lastError.message,
true
);
return;
}
chrome.storage.local.clear(function () {
if (chrome.runtime.lastError) {
showStatus(
"Error: Failed to clear local extension data - " +
chrome.runtime.lastError.message,
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) {
persistImportedSyncSettings(
currentRaw || {},
settingsToImport,
function (syncError) {
if (syncError) {
showStatus(
"Error: Failed to save imported settings - " +
chrome.runtime.lastError.message,
syncError.message,
true
);
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) {
showStatus("Error: Failed to parse backup file - " + err.message, true);
}
+48 -9
View File
@@ -7,6 +7,7 @@
--text: #17191c;
--muted: #626b76;
--accent: #111827;
--focus-ring: #2563eb;
--switch-track-off: #c1cad6;
--switch-track-off-border: #aeb8c5;
--switch-track-on: #111827;
@@ -177,9 +178,14 @@ a:visited {
text-underline-offset: 0.14em;
}
a:hover,
a:focus {
color: #000;
a:hover {
color: var(--text);
text-decoration-thickness: 2px;
}
a:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
code {
@@ -225,7 +231,7 @@ input[type="checkbox"]:focus-visible,
input[type="text"]:focus,
select:focus,
textarea:focus {
outline: 2px solid rgba(17, 24, 39, 0.14);
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
@@ -322,7 +328,7 @@ label em {
.shortcut-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 120px 120px;
grid-template-columns: minmax(0, 1fr) 120px 120px 38px;
gap: 12px;
align-items: center;
padding: 10px 0;
@@ -334,10 +340,6 @@ label em {
border-top: 0;
}
.shortcut-row.customs {
grid-template-columns: minmax(0, 1fr) 120px 120px 38px;
}
.shortcut-label {
color: var(--text);
font-weight: 500;
@@ -628,6 +630,9 @@ label em {
cursor: grab;
user-select: none;
transition: box-shadow 150ms ease, opacity 150ms ease;
color: var(--text);
font-weight: 500;
text-align: left;
}
.cb-block:hover {
@@ -905,8 +910,19 @@ button.lucide-result-tile.lucide-picked {
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 {
min-width: 0;
}
.site-pattern {
font-family: "SFMono-Regular", Menlo, Consolas, monospace;
}
@@ -1050,6 +1066,20 @@ button.lucide-result-tile.lucide-picked {
font-weight: 500;
}
#status:not(:empty) {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 1000;
min-height: 0;
margin: 0;
padding: 10px 14px;
border: 1px solid var(--border-strong);
border-radius: 999px;
background: var(--panel);
box-shadow: 0 8px 24px rgba(17, 24, 39, 0.16);
}
#status:empty {
display: none;
}
@@ -1173,6 +1203,10 @@ button.lucide-result-tile.lucide-picked {
grid-template-columns: 36px minmax(0, 1fr);
}
.site-rule-identity {
grid-template-columns: 1fr;
}
.remove-site-rule {
grid-column: 1 / -1;
}
@@ -1198,6 +1232,10 @@ button.lucide-result-tile.lucide-picked {
.site-rule-header {
grid-template-columns: 1fr;
}
.site-rule-identity {
grid-template-columns: 1fr;
}
}
@media (prefers-color-scheme: dark) {
@@ -1210,6 +1248,7 @@ button.lucide-result-tile.lucide-picked {
--text: #f2f4f6;
--muted: #a0a8b2;
--accent: #f2f4f6;
--focus-ring: #93c5fd;
--switch-track-off: #374151;
--switch-track-off-border: #4b5563;
--switch-track-on: #aab7c6;
+120 -37
View File
@@ -7,6 +7,7 @@
<link rel="stylesheet" href="options.css" />
<script src="../shared/controller-utils.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/ui-icons.js"></script>
<script src="lucide-client.js"></script>
@@ -249,7 +250,7 @@
</div>
</div>
<select id="addShortcutSelector">
<select id="addShortcutSelector" aria-label="Add another shortcut">
<option value="">Add shortcut&hellip;</option>
</select>
</section>
@@ -281,6 +282,17 @@
</label>
<input id="audioBoolean" type="checkbox" />
</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>
<h4 class="defaults-sub-heading">Playback</h4>
@@ -289,9 +301,9 @@
<label for="rememberSpeed"
>Remember playback speed<br />
<em
>Stores speed per source so revisiting the same media can restore
it. Separate from &ldquo;Force last saved speed,&rdquo; which
fights players that reset rate.</em
>Stores speeds you set with Speeder per source so revisiting the
same media can restore them. Separate from &ldquo;Force last
saved speed,&rdquo; which fights players that reset rate.</em
>
</label>
<input id="rememberSpeed" type="checkbox" />
@@ -306,6 +318,20 @@
</label>
<input id="forceLastSavedSpeed" type="checkbox" />
</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>
<h4 class="defaults-sub-heading">Controller</h4>
@@ -412,15 +438,15 @@
<div class="row">
<label for="subtitleNudgeInterval"
>Nudge interval (milliseconds)<br /><em
>How often to nudge: 10&ndash;1000. Smaller values are more
frequent. Default: 50.</em
>How often to nudge: 250&ndash;1000. Smaller values are more
frequent. Default: 250.</em
>
</label>
<input
id="subtitleNudgeInterval"
type="text"
value=""
placeholder="50"
placeholder="250"
/>
</div>
</section>
@@ -442,8 +468,8 @@
<div class="section-heading">
<h3>Hover control bar</h3>
<p class="section-intro">
Drag blocks to reorder. Move between Active and Available to
show or hide buttons.
Drag blocks or use arrow keys to reorder. Press a block to
move it between Active and Available.
</p>
</div>
<div class="cb-editor">
@@ -565,7 +591,7 @@
<div
id="lucideIconResults"
class="lucide-icon-results"
role="listbox"
role="group"
aria-label="Matching Lucide icons"
></div>
<p id="lucideIconStatus" class="lucide-icon-status" aria-live="polite"></p>
@@ -607,7 +633,9 @@
>
patterns like <code>/(.+)youtube\.com(\/*)$/gi</code>. Turn on a
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>
</div>
<div id="siteRulesContainer"></div>
@@ -626,11 +654,20 @@
>
<span class="site-rule-toggle-icon" aria-hidden="true">&hellip;</span>
</button>
<input
type="text"
class="site-pattern"
placeholder="e.g., youtube.com or /regex/gi"
/>
<div class="site-rule-identity">
<input
type="text"
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>
</div>
<div class="site-rule-body">
@@ -697,6 +734,26 @@
</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">
<label class="site-override-lead">
<span
@@ -757,8 +814,8 @@
<label class="site-override-lead">
<span
>Override playback for this site<br /><em
>When on, remember speed / force / audio below replace
general defaults for matching URLs.</em
>When on, remember speed / force / media eligibility
below replace general defaults for matching URLs.</em
></span
>
<input type="checkbox" class="override-playback" />
@@ -767,10 +824,10 @@
<div class="site-rule-option site-rule-option-checkbox">
<label
>Remember playback speed:<br /><em
>Stores speed per source so revisiting the same media
can restore it. Separate from &ldquo;Force last saved
speed,&rdquo; which fights players that reset
rate.</em
>Stores speeds you set with Speeder per source so
revisiting the same media can restore them. Separate
from &ldquo;Force last saved speed,&rdquo; which fights
players that reset rate.</em
></label
>
<input type="checkbox" class="site-rememberSpeed" />
@@ -784,6 +841,21 @@
>
<input type="checkbox" class="site-forceLastSavedSpeed" />
</div>
<div class="site-rule-option site-rule-option-field">
<label
>Preferred speed (0.1&ndash;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">
<label
>Work on audio:<br /><em
@@ -793,6 +865,16 @@
>
<input type="checkbox" class="site-audioBoolean" />
</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 class="site-rule-override-section">
@@ -848,12 +930,12 @@
</div>
<div class="site-rule-option site-rule-option-field">
<label
>Nudge interval (10&ndash;1000ms):<br /><em
>How often to nudge: 10&ndash;1000. Smaller values are
more frequent. Default: 50.</em
>Nudge interval (250&ndash;1000ms):<br /><em
>How often to nudge: 250&ndash;1000. Smaller values are
more frequent. Default: 250.</em
></label
>
<input type="text" class="site-subtitleNudgeInterval" placeholder="50" />
<input type="text" class="site-subtitleNudgeInterval" placeholder="250" />
</div>
</div>
</div>
@@ -861,8 +943,8 @@
<label class="site-override-lead">
<span
>Override in-player control bar for this site<br /><em
>Same idea as Hover control bar: drag blocks between
Active and Available for matching URLs only.</em
>Drag blocks or use the keyboard to arrange Active and
Available buttons for matching URLs only.</em
></span
>
<input type="checkbox" class="override-controlbar" />
@@ -916,8 +998,8 @@
<label class="site-override-lead">
<span
>Override shortcuts for this site<br /><em
>Add shortcuts from the menu; none by default. Leave off
to use global Shortcuts.</em
>A site shortcut replaces global bindings for the same
action; other global shortcuts remain active.</em
></span
>
<input type="checkbox" class="override-shortcuts" />
@@ -940,13 +1022,14 @@
<section class="settings-card action-card">
<div class="section-heading">
<h3>Actions</h3>
<p class="section-intro">Save, restore, export, or import settings.</p>
<p class="section-intro">Changes save automatically.</p>
</div>
<div class="action-row">
<button id="save">Save Changes</button>
<button id="save">Save Now</button>
<button id="restore">Restore Defaults</button>
<button id="exportSettings">Export Settings</button>
<button id="importSettings">Import Settings</button>
<button id="copyDiagnostics">Copy Diagnostics</button>
</div>
<div id="status" role="status" aria-live="polite"></div>
@@ -955,11 +1038,11 @@
<section id="faq" class="settings-card info-card">
<h4>Extension controls not appearing?</h4>
<p>
This extension only works with HTML5 audio and video. If the
controls never appear, you may be looking at Flash content instead.
Right-click the player to check: if the menu mentions Flash, that
is the issue. Most sites will fall back to HTML5 when Flash is not
available, so disabling Flash in the browser can help.
Speeder works with HTML5 video and, when enabled above, HTML5 audio.
Check that the current site is enabled, then use the popup&rsquo;s
&ldquo;Rescan page for videos&rdquo; action after a player loads
dynamically. Browser-internal pages and players that do not expose
HTML5 media to extensions cannot be controlled.
</p>
</section>
+561 -177
View File
@@ -162,58 +162,96 @@ function createDefaultBinding(action, code, value) {
};
}
var tcDefaults = {
speed: 1.0,
lastSpeed: 1.0,
rememberSpeed: false,
audioBoolean: false,
startHidden: false,
hideWithYouTubeControls: false,
hideWithControls: false,
hideWithControlsTimer: 2.0,
controllerLocation: "top-left",
forceLastSavedSpeed: false,
enabled: true,
controllerOpacity: 0.3,
controllerMarginTop: 0,
controllerMarginRight: 0,
controllerMarginBottom: 65,
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
var tcDefaults = vscGetSettingsDefaults();
var optionsSyncSettingsLoaded = false;
var autoSaveTimer = null;
var saveStatusTimer = null;
function scheduleAutoSave() {
if (!optionsSyncSettingsLoaded) return;
clearTimeout(autoSaveTimer);
var sourceSaveButton = document.getElementById("save");
var scheduledTimer = setTimeout(function () {
if (
autoSaveTimer !== scheduledTimer ||
!sourceSaveButton ||
!sourceSaveButton.isConnected
) {
return;
}
],
controllerButtons: ["rewind", "slower", "faster", "advance", "display"],
showPopupControlBar: true,
popupMatchHoverControls: true,
popupControllerButtons: ["rewind", "slower", "faster", "advance", "display"],
enableSubtitleNudge: false,
subtitleNudgeEnabledByDefault: true,
subtitleNudgeInterval: 50,
subtitleNudgeAmount: 0.001
};
autoSaveTimer = null;
save_options(true);
}, 300);
autoSaveTimer = scheduledTimer;
}
function setOptionsSyncSettingsLoaded(loaded) {
optionsSyncSettingsLoaded = loaded === true;
if (!optionsSyncSettingsLoaded) {
clearTimeout(autoSaveTimer);
autoSaveTimer = null;
}
var saveButton = document.getElementById("save");
if (saveButton) {
saveButton.disabled = !optionsSyncSettingsLoaded;
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 = {
display: "Show/hide controller",
@@ -261,14 +299,11 @@ function getDefaultShortcutValue(action) {
}
function resolveShortcutValue(action, value) {
if (value === undefined || value === null) {
return getDefaultShortcutValue(action);
}
var numericValue = Number(value);
if (Number.isFinite(numericValue)) {
return numericValue;
}
return 0;
return keyBindingUtils.sanitizeActionValue(
action,
value,
getDefaultShortcutValue(action)
);
}
const customActionsNoValues = [
@@ -291,33 +326,15 @@ function refreshAddShortcutSelector() {
selector.remove(1);
}
// Find all currently used actions
const usedActions = new Set();
document.querySelectorAll(".shortcut-row").forEach((row) => {
const action = row.dataset.action;
if (action) {
usedActions.add(action);
}
});
// Add all unused actions
Object.keys(actionLabels).forEach((action) => {
if (!usedActions.has(action)) {
const option = document.createElement("option");
option.value = action;
option.text = actionLabels[action];
selector.appendChild(option);
}
const option = document.createElement("option");
option.value = action;
option.text = actionLabels[action];
selector.appendChild(option);
});
// If no available actions, hide or disable the selector
if (selector.options.length === 1) {
selector.disabled = true;
selector.options[0].text = "All shortcuts added";
} else {
selector.disabled = false;
selector.options[0].text = "Add shortcut\u2026";
}
selector.disabled = false;
selector.options[0].text = "Add shortcut\u2026";
}
function refreshSiteRuleAddShortcutSelector(ruleEl) {
@@ -329,32 +346,19 @@ function refreshSiteRuleAddShortcutSelector(ruleEl) {
selector.remove(1);
}
var usedActions = new Set();
ruleEl.querySelectorAll(".site-shortcuts-rows .shortcut-row.customs").forEach(function (row) {
var action = row.dataset.action;
if (action) usedActions.add(action);
});
Object.keys(actionLabels).forEach(function (action) {
if (!usedActions.has(action)) {
var option = document.createElement("option");
option.value = action;
option.textContent = actionLabels[action];
selector.appendChild(option);
}
var option = document.createElement("option");
option.value = action;
option.textContent = actionLabels[action];
selector.appendChild(option);
});
var overrideShortcutsOn =
ruleEl.querySelector(".override-shortcuts") &&
ruleEl.querySelector(".override-shortcuts").checked;
if (selector.options.length === 1) {
selector.disabled = true;
selector.options[0].text = "All shortcuts added";
} else {
selector.disabled = !overrideShortcutsOn;
selector.options[0].text = "Add shortcut\u2026";
}
selector.disabled = !overrideShortcutsOn;
selector.options[0].text = "Add shortcut\u2026";
}
function getGlobalBindingSnapshotForSiteShortcut(action) {
@@ -406,6 +410,19 @@ function parseFiniteNumberOrFallback(value, 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) {
if (!toggleButton) return;
var iconEl = toggleButton.querySelector(".site-rule-toggle-icon");
@@ -539,6 +556,7 @@ function normalizeStoredBinding(binding, fallbackCode) {
var normalized = {
code: normalizedCode,
shiftKey: binding.shiftKey === true,
disabled: false
};
@@ -557,7 +575,7 @@ function formatBindingCode(code) {
function getBindingLabel(binding) {
if (!binding) return "";
if (binding.disabled) return "";
return formatBindingCode(binding.code);
return (binding.shiftKey ? "Shift+" : "") + formatBindingCode(binding.code);
}
function setShortcutInputBinding(input, binding) {
@@ -570,6 +588,7 @@ function captureBindingFromEvent(event) {
if (typeof event.code !== "string" || event.code.length === 0) return null;
return {
code: event.code,
shiftKey: event.shiftKey === true,
disabled: false
};
}
@@ -579,6 +598,7 @@ function recordKeyPress(event) {
if (event.key === "Backspace") {
setShortcutInputBinding(event.target, null);
scheduleAutoSave();
event.preventDefault();
event.stopPropagation();
return;
@@ -586,6 +606,7 @@ function recordKeyPress(event) {
if (event.key === "Escape") {
setShortcutInputBinding(event.target, createDisabledBinding());
scheduleAutoSave();
event.preventDefault();
event.stopPropagation();
return;
@@ -595,6 +616,7 @@ function recordKeyPress(event) {
if (!binding) return;
setShortcutInputBinding(event.target, binding);
scheduleAutoSave();
event.preventDefault();
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) {
if (!action) return;
@@ -689,6 +725,7 @@ function add_shortcut(action, value) {
div.appendChild(keyInput);
div.appendChild(valueInput);
div.appendChild(removeButton);
labelShortcutRow(div);
var customsElement = document.querySelector(".shortcuts-grid");
customsElement.appendChild(div);
@@ -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({
action: action,
code: binding.code,
shiftKey: binding.shiftKey === true,
disabled: binding.disabled === true,
value: customActionsNoValues.includes(action)
? 0
: Number(valueInput.value),
value: bindingValue,
force: false,
predefined: predefined
});
@@ -752,9 +808,8 @@ function validate() {
if (pattern.startsWith("/")) {
try {
var lastSlash = pattern.lastIndexOf("/");
if (lastSlash > 0) {
new RegExp(pattern.substring(1, lastSlash), pattern.substring(lastSlash + 1));
}
if (lastSlash === 0) throw new Error("Missing closing slash");
new RegExp(pattern.substring(1, lastSlash), pattern.substring(lastSlash + 1));
} catch (err) {
status.textContent =
"Error: Invalid site rule regex: " + pattern + ". Unable to save";
@@ -767,11 +822,19 @@ function validate() {
return valid;
}
function save_options() {
function save_options(isAutoSave) {
clearTimeout(autoSaveTimer);
clearTimeout(saveStatusTimer);
autoSaveTimer = null;
var status = document.getElementById("status");
if (!optionsSyncSettingsLoaded) {
status.textContent =
"Error: Settings have not loaded. Retry before saving.";
return;
}
if (validate() === false) return;
keyBindings = [];
var status = document.getElementById("status");
var saveError = null;
// Collect shortcuts from the main shortcuts section (both default and custom)
@@ -791,8 +854,14 @@ function save_options() {
settings.forceLastSavedSpeed =
document.getElementById("forceLastSavedSpeed").checked;
settings.audioBoolean = document.getElementById("audioBoolean").checked;
settings.showAmbientLoopControls =
document.getElementById("showAmbientLoopControls").checked;
settings.enabled = document.getElementById("enabled").checked;
settings.startHidden = document.getElementById("startHidden").checked;
settings.shortcutTargetMode =
document.getElementById("shortcutTargetMode").value === "all"
? "all"
: "closest";
settings.hideWithControls = document.getElementById("hideWithControls").checked;
settings.hideWithControlsTimer =
Math.min(15, Math.max(0.1, parseFloat(document.getElementById("hideWithControlsTimer").value) || tcDefaults.hideWithControlsTimer));
@@ -829,10 +898,9 @@ function save_options() {
settings.subtitleNudgeInterval =
parseInt(document.getElementById("subtitleNudgeInterval").value, 10) ||
tcDefaults.subtitleNudgeInterval;
settings.subtitleNudgeAmount = tcDefaults.subtitleNudgeAmount;
if (settings.subtitleNudgeInterval < 10) {
settings.subtitleNudgeInterval = 10;
if (settings.subtitleNudgeInterval < 250) {
settings.subtitleNudgeInterval = 250;
}
if (settings.subtitleNudgeInterval > 1000) {
settings.subtitleNudgeInterval = 1000;
@@ -851,11 +919,25 @@ function save_options() {
var pattern = ruleEl.querySelector(".site-pattern").value.trim();
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
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) {
rule.controllerLocation = normalizeControllerLocation(
ruleEl.querySelector(".site-controllerLocation").value
@@ -874,10 +956,16 @@ function save_options() {
tcDefaults.controllerMarginBottom
)
);
} else {
delete rule.controllerLocation;
delete rule.controllerMarginTop;
delete rule.controllerMarginBottom;
}
if (ruleEl.querySelector(".override-visibility").checked) {
rule.startHidden = ruleEl.querySelector(".site-startHidden").checked;
} else {
delete rule.startHidden;
}
if (ruleEl.querySelector(".override-autohide").checked) {
@@ -889,13 +977,33 @@ function save_options() {
15,
Math.max(0.1, Number.isFinite(st) ? st : settings.hideWithControlsTimer)
);
} else {
delete rule.hideWithControls;
delete rule.hideWithControlsTimer;
}
if (ruleEl.querySelector(".override-playback").checked) {
rule.rememberSpeed = ruleEl.querySelector(".site-rememberSpeed").checked;
rule.forceLastSavedSpeed =
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.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) {
@@ -904,6 +1012,8 @@ function save_options() {
ruleEl.querySelector(".site-controllerOpacity").value,
settings.controllerOpacity
);
} else {
delete rule.controllerOpacity;
}
if (ruleEl.querySelector(".override-subtitleNudge").checked) {
@@ -918,10 +1028,14 @@ function save_options() {
rule.subtitleNudgeInterval = Math.min(
1000,
Math.max(
10,
250,
Number.isFinite(nudgeIv) ? nudgeIv : settings.subtitleNudgeInterval
)
);
} else {
delete rule.enableSubtitleNudge;
delete rule.subtitleNudgeEnabledByDefault;
delete rule.subtitleNudgeInterval;
}
if (ruleEl.querySelector(".override-controlbar").checked) {
@@ -929,6 +1043,8 @@ function save_options() {
if (activeZone) {
rule.controllerButtons = readControlBarOrder(activeZone);
}
} else {
delete rule.controllerButtons;
}
if (ruleEl.querySelector(".override-popup-controlbar").checked) {
@@ -940,6 +1056,9 @@ function save_options() {
readControlBarOrder(popupActiveZone)
);
}
} else {
delete rule.showPopupControlBar;
delete rule.popupControllerButtons;
}
if (ruleEl.querySelector(".override-shortcuts").checked) {
@@ -971,37 +1090,57 @@ function save_options() {
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({
action: action,
code: binding.code,
shiftKey: binding.shiftKey === true,
disabled: binding.disabled === true,
value: customActionsNoValues.includes(action)
? 0
: Number(valueInput.value),
value: shortcutValue,
force: forceCheckbox ? forceCheckbox.checked : false
});
});
if (saveError) return;
if (shortcuts.length > 0) rule.shortcuts = shortcuts;
else delete rule.shortcuts;
} else {
delete rule.shortcuts;
}
settings.siteRules.push(rule);
});
// Legacy keys to remove
const legacyKeys = [
"resetSpeed", "speedStep", "fastSpeed", "rewindTime", "advanceTime",
"resetKeyCode", "slowerKeyCode", "fasterKeyCode", "rewindKeyCode",
"advanceKeyCode", "fastKeyCode", "blacklist"
];
if (saveError) {
status.textContent = saveError;
return;
}
chrome.storage.sync.remove(legacyKeys, function () {
chrome.storage.sync.set(settings, function () {
status.textContent = "Options saved";
setTimeout(function () {
status.textContent = "";
}, 1000);
});
persistManagedSyncSettings(settings, function (error) {
if (error) {
status.textContent = "Error: Unable to save options - " + error.message;
return;
}
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");
actionLabel.className = "shortcut-label";
var actionLabelText = actionLabels[action] || action;
if (action === "toggleSubtitleNudge") {
var ruleEl = rowsEl.closest(".site-rule");
var pattern = ruleEl ? ruleEl.querySelector(".site-pattern").value : "";
if (!pattern.toLowerCase().includes("youtube.com")) {
actionLabelText += " (only for YouTube embeds)";
}
}
actionLabel.textContent = actionLabelText;
actionLabel.textContent = actionLabels[action] || action;
var keyInput = document.createElement("input");
keyInput.className = "customKey";
@@ -1076,17 +1207,50 @@ function addSiteRuleShortcut(rowsEl, action, binding, value, force) {
div.appendChild(valueInput);
div.appendChild(forceLabel);
div.appendChild(removeButton);
labelShortcutRow(div);
rowsEl.appendChild(div);
}
var siteRuleControlId = 0;
function associateSiteRuleLabels(ruleEl) {
ruleEl.querySelectorAll(".site-rule-option").forEach(function(option) {
var label = Array.from(option.children).find(function(child) {
return child.tagName === "LABEL";
});
var controls = option.querySelectorAll("input, select, textarea");
if (!label || controls.length !== 1 || label.contains(controls[0])) return;
controls[0].id = "site-rule-control-" + ++siteRuleControlId;
label.htmlFor = controls[0].id;
});
ruleEl.querySelectorAll(".margin-pad-cell").forEach(function(cell) {
var input = cell.querySelector("input");
var miniLabel = cell.querySelector(".margin-pad-mini");
if (input && miniLabel) {
input.setAttribute(
"aria-label",
"Controller margin " +
(miniLabel.textContent === "T" ? "top" : "bottom")
);
}
});
var removeRule = ruleEl.querySelector(".remove-site-rule");
if (removeRule) removeRule.setAttribute("aria-label", "Remove site rule");
}
function createSiteRule(rule) {
var template = document.getElementById("siteRuleTemplate");
var clone = template.content.cloneNode(true);
var ruleEl = clone.querySelector(".site-rule");
ruleEl.vscOriginalRule = vscClonePlainData(rule) || {};
var pattern = rule && rule.pattern ? rule.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
setSiteRuleExpandedState(ruleEl, false);
@@ -1117,6 +1281,17 @@ function createSiteRule(rule) {
}
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 = [
"controllerLocation",
"controllerMarginTop",
@@ -1150,12 +1325,19 @@ function createSiteRule(rule) {
rule &&
(rule.rememberSpeed !== undefined ||
rule.forceLastSavedSpeed !== undefined ||
rule.audioBoolean !== undefined)
rule.preferredSpeed !== undefined ||
rule.audioBoolean !== undefined ||
rule.showAmbientLoopControls !== undefined)
);
ruleEl.querySelector(".override-playback").checked = hasPlaybackOverride;
syncSiteRuleField(ruleEl, rule, "rememberSpeed", 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, "showAmbientLoopControls", true);
applySiteRuleOverrideState(ruleEl, "override-playback", "site-playback-container");
ruleEl.querySelector(".override-opacity").checked = Boolean(
@@ -1231,6 +1413,7 @@ function createSiteRule(rule) {
}
applySiteRuleOverrideState(ruleEl, "override-shortcuts", "site-shortcuts-container");
refreshSiteRuleAddShortcutSelector(ruleEl);
associateSiteRuleLabels(ruleEl);
document.getElementById("siteRulesContainer").appendChild(ruleEl);
}
@@ -1239,7 +1422,8 @@ function createControlBarBlock(buttonId) {
var def = controllerButtonDefs[buttonId];
if (!def) return null;
var block = document.createElement("div");
var block = document.createElement("button");
block.type = "button";
block.className = "cb-block";
block.dataset.buttonId = buttonId;
block.draggable = true;
@@ -1262,6 +1446,25 @@ function createControlBarBlock(buttonId) {
return block;
}
function updateControlBarBlockLabels(editor) {
if (!editor) return;
editor.querySelectorAll(".cb-dropzone").forEach(function(zone) {
var state = zone.classList.contains("cb-active-zone")
? "active"
: "available";
zone.querySelectorAll(".cb-block").forEach(function(block) {
var def = controllerButtonDefs[block.dataset.buttonId];
block.setAttribute(
"aria-label",
(def ? def.name : block.dataset.buttonId) +
", " +
state +
". Press Enter to move; use arrow keys to reorder."
);
});
});
}
function populateControlBarZones(activeZone, availableZone, activeIds, allowButtonId) {
vscClearElement(activeZone);
vscClearElement(availableZone);
@@ -1284,6 +1487,8 @@ function populateControlBarZones(activeZone, availableZone, activeIds, allowButt
if (block) availableZone.appendChild(block);
}
});
updateControlBarBlockLabels(activeZone.closest(".cb-editor"));
}
function readControlBarOrder(activeZone) {
@@ -1327,11 +1532,11 @@ function updatePopupEditorDisabledState() {
var checkbox = document.getElementById("popupMatchHoverControls");
var wrap = document.getElementById("popupCbEditorWrap");
if (!checkbox || !wrap) return;
if (checkbox.checked) {
wrap.classList.add("cb-editor-disabled");
} else {
wrap.classList.remove("cb-editor-disabled");
}
wrap.classList.toggle("cb-editor-disabled", checkbox.checked);
wrap.querySelectorAll(".cb-block").forEach(function(block) {
block.disabled = checkbox.checked;
block.draggable = !checkbox.checked;
});
}
function getDragAfterElement(container, x, y) {
@@ -1354,6 +1559,8 @@ function getDragAfterElement(container, x, y) {
}
function initControlBarEditor() {
if (document.vscControlBarEditorInitialized) return;
document.vscControlBarEditorInitialized = true;
var draggedBlock = null;
function clearControlBarDropTargets(activeZone) {
@@ -1366,7 +1573,7 @@ function initControlBarEditor() {
document.addEventListener("dragstart", function (e) {
var block = e.target.closest(".cb-block");
if (!block) return;
if (!block || block.disabled) return;
draggedBlock = block;
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", block.dataset.buttonId);
@@ -1411,10 +1618,49 @@ function initControlBarEditor() {
var zone = e.target.closest(".cb-dropzone");
if (zone) {
e.preventDefault();
updateControlBarBlockLabels(zone.closest(".cb-editor"));
scheduleAutoSave();
}
clearControlBarDropTargets(null);
});
document.addEventListener("click", function(e) {
var block = e.target.closest ? e.target.closest(".cb-block") : null;
if (!block || block.disabled) return;
var editor = block.closest(".cb-editor");
var currentZone = block.closest(".cb-dropzone");
if (!editor || !currentZone) return;
var otherZone = editor.querySelector(
currentZone.classList.contains("cb-active-zone")
? ".cb-available-zone"
: ".cb-active-zone"
);
if (!otherZone) return;
otherZone.appendChild(block);
updateControlBarBlockLabels(editor);
block.focus();
scheduleAutoSave();
});
document.addEventListener("keydown", function(e) {
var block = e.target.closest ? e.target.closest(".cb-block") : null;
if (!block || block.disabled) return;
var previous = e.key === "ArrowLeft" || e.key === "ArrowUp";
var next = e.key === "ArrowRight" || e.key === "ArrowDown";
if (!previous && !next) return;
var sibling = previous ? block.previousElementSibling : block.nextElementSibling;
if (!sibling) return;
e.preventDefault();
if (previous) {
block.parentNode.insertBefore(block, sibling);
} else {
block.parentNode.insertBefore(block, sibling.nextElementSibling);
}
updateControlBarBlockLabels(block.closest(".cb-editor"));
block.focus();
scheduleAutoSave();
});
}
var lucidePickerSelectedSlug = null;
@@ -1486,6 +1732,10 @@ function initLucideButtonIconsUI() {
b.dataset.slug = slug;
b.title = slug;
b.setAttribute("aria-label", slug);
b.setAttribute(
"aria-pressed",
slug === lucidePickerSelectedSlug ? "true" : "false"
);
if (slug === lucidePickerSelectedSlug) {
b.classList.add("lucide-picked");
}
@@ -1507,6 +1757,10 @@ function initLucideButtonIconsUI() {
resultsEl.querySelectorAll("button"),
function (x) {
x.classList.toggle("lucide-picked", x.dataset.slug === slug);
x.setAttribute(
"aria-pressed",
x.dataset.slug === slug ? "true" : "false"
);
}
);
fetchLucideSvg(slug)
@@ -1572,7 +1826,7 @@ function initLucideButtonIconsUI() {
slug +
" for " +
action +
". Reload pages for the hover bar."
". Open pages update automatically."
);
});
})
@@ -1621,11 +1875,26 @@ function initLucideButtonIconsUI() {
}
}
function restore_options() {
chrome.storage.sync.get(tcDefaults, function (storage) {
function restore_options(callback) {
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) {
var localError = chrome.runtime.lastError;
customButtonIconsLive =
loc && loc.customButtonIcons && typeof loc.customButtonIcons === "object"
!localError &&
loc &&
loc.customButtonIcons &&
typeof loc.customButtonIcons === "object"
? loc.customButtonIcons
: {};
@@ -1633,15 +1902,15 @@ function restore_options() {
document.getElementById("forceLastSavedSpeed").checked =
storage.forceLastSavedSpeed;
document.getElementById("audioBoolean").checked = storage.audioBoolean;
document.getElementById("showAmbientLoopControls").checked =
storage.showAmbientLoopControls;
document.getElementById("enabled").checked = storage.enabled;
document.getElementById("startHidden").checked = storage.startHidden;
document.getElementById("shortcutTargetMode").value =
storage.shortcutTargetMode === "all" ? "all" : "closest";
// Migration/Normalization for hideWithControls
const hideWithControls = typeof storage.hideWithControls !== "undefined"
? storage.hideWithControls
: storage.hideWithYouTubeControls;
document.getElementById("hideWithControls").checked = hideWithControls;
document.getElementById("hideWithControls").checked =
storage.hideWithControls;
document.getElementById("hideWithControlsTimer").value =
storage.hideWithControlsTimer || tcDefaults.hideWithControlsTimer;
@@ -1670,13 +1939,16 @@ function restore_options() {
document.querySelectorAll(".customs:not([id])").forEach((row) => row.remove());
var usedDefaultShortcutRows = new Set();
storage.keyBindings.forEach((item) => {
var row = document.getElementById(item.action);
var normalizedBinding = normalizeStoredBinding(item);
if (!row) {
if (!row || usedDefaultShortcutRows.has(item.action)) {
add_shortcut(item.action, item.value);
row = document.querySelector(".shortcut-row.customs:last-of-type");
} else {
usedDefaultShortcutRows.add(item.action);
}
if (!row) return;
@@ -1699,11 +1971,7 @@ function restore_options() {
refreshAddShortcutSelector();
// Load site rules (use defaults if none in storage or empty array)
var siteRules =
Array.isArray(storage.siteRules) && storage.siteRules.length > 0
? storage.siteRules
: tcDefaults.siteRules || [];
var siteRules = Array.isArray(storage.siteRules) ? storage.siteRules : [];
vscClearElement(document.getElementById("siteRulesContainer"));
if (siteRules.length > 0) {
@@ -1729,25 +1997,94 @@ function restore_options() {
updatePopupEditorDisabledState();
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() {
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(
["customButtonIcons", "lucideTagsCacheV1", "lucideTagsCacheV1At"],
function () {}
);
function finishRestore(error) {
// Leave the storage callback before rereading. Besides avoiding nested API
// 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 () {
restore_options();
var status = document.getElementById("status");
status.textContent = "Default options restored";
setTimeout(function () {
status.textContent = "";
}, 1000);
persistManagedSyncSettings(tcDefaults, function (error) {
if (error) {
finishRestore(error);
return;
}
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;
}
document.querySelectorAll("#customs .shortcut-row").forEach(labelShortcutRow);
restore_options();
initControlBarEditor();
@@ -1765,6 +2104,41 @@ document.addEventListener("DOMContentLoaded", function () {
.addEventListener("change", updatePopupEditorDisabledState);
document.getElementById("save").addEventListener("click", save_options);
document.getElementById("copyDiagnostics").addEventListener("click", function () {
var status = document.getElementById("status");
if (
!navigator.clipboard ||
typeof navigator.clipboard.writeText !== "function"
) {
status.textContent = "Clipboard access is unavailable.";
return;
}
var report = popupControlUtils.buildDiagnosticReport({
speederVersion: manifest.version,
browser: navigator.userAgent,
platform: navigator.platform || null,
storage: {
enabled: document.getElementById("enabled").checked,
rememberSpeed: document.getElementById("rememberSpeed").checked,
forceLastSavedSpeed:
document.getElementById("forceLastSavedSpeed").checked,
audioBoolean: document.getElementById("audioBoolean").checked,
startHidden: document.getElementById("startHidden").checked,
hideWithControls: document.getElementById("hideWithControls").checked,
controllerLocation: document.getElementById("controllerLocation").value,
shortcutTargetMode:
document.getElementById("shortcutTargetMode").value
}
});
navigator.clipboard.writeText(report).then(
function() {
status.textContent = "Diagnostics copied. Review before sharing.";
},
function() {
status.textContent = "Could not copy diagnostics.";
}
);
});
const addSelector = document.getElementById("addShortcutSelector");
if (addSelector) {
@@ -1817,11 +2191,13 @@ document.addEventListener("DOMContentLoaded", function () {
if (siteRuleForShortcut) {
refreshSiteRuleAddShortcutSelector(siteRuleForShortcut);
}
scheduleAutoSave();
return;
}
var removeSiteRuleButton = targetEl.closest(".remove-site-rule");
if (removeSiteRuleButton) {
removeSiteRuleButton.closest(".site-rule").remove();
scheduleAutoSave();
return;
}
var toggleButton = targetEl.closest(".toggle-site-rule");
@@ -1891,4 +2267,12 @@ document.addEventListener("DOMContentLoaded", function () {
}
}
});
document.addEventListener("change", (event) => {
if (
event.target.id !== "addShortcutSelector" &&
!event.target.closest("#lucideIconSettings")
) {
scheduleAutoSave();
}
});
});
+47 -14
View File
@@ -4,8 +4,9 @@
--border: #e2e5e9;
--border-strong: #d4d9e0;
--text: #17191c;
--muted: #626b76;
--accent: #111827;
--muted: #626b76;
--accent: #111827;
--focus-ring: #2563eb;
}
* {
@@ -83,10 +84,15 @@ button:active {
background: #f1f3f5;
}
button:focus-visible {
outline: 2px solid rgba(17, 24, 39, 0.14);
outline-offset: 2px;
}
button:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
#refresh {
background: var(--accent);
@@ -94,10 +100,30 @@ button:focus-visible {
color: #fff;
}
#refresh:hover {
#refresh:hover {
background: #1f2937;
border-color: #1f2937;
}
}
.popup-compact-action {
min-height: 24px;
padding: 0 9px;
border-radius: 6px;
color: var(--muted);
font-size: 11px;
}
.popup-compact-action[aria-pressed="true"] {
background: #e8f3e5;
border-color: #9fbd98;
color: #285d21;
}
#pauseTab[aria-pressed="true"] {
background: #fff3d6;
border-color: #d8b66b;
color: #704f0d;
}
.popup-divider {
height: 1px;
@@ -236,8 +262,8 @@ button:focus-visible {
background: #f1f3f5;
}
.donate-icon-btn:focus-visible {
outline: 2px solid rgba(17, 24, 39, 0.14);
.donate-icon-btn:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
position: relative;
z-index: 1;
@@ -281,8 +307,9 @@ button:focus-visible {
--border: #2b3138;
--border-strong: #3a414a;
--text: #f2f4f6;
--muted: #a0a8b2;
--accent: #f2f4f6;
--muted: #a0a8b2;
--accent: #f2f4f6;
--focus-ring: #93c5fd;
}
body {
@@ -304,10 +331,16 @@ button:focus-visible {
color: #111315;
}
#refresh:hover {
#refresh:hover {
background: #dfe3e8;
border-color: #dfe3e8;
}
}
.popup-compact-action[aria-pressed="true"] {
background: #21351f;
border-color: #52724d;
color: #b8ddb1;
}
.donate-icon-btn:hover {
background: #1f2226;
+21 -1
View File
@@ -4,6 +4,7 @@
<meta charset="utf-8" />
<title>Speeder</title>
<link rel="stylesheet" href="popup.css" />
<script src="../shared/settings-core.js"></script>
<script src="../shared/site-rules.js"></script>
<script src="../shared/popup-controls.js"></script>
<script src="../shared/ui-icons.js"></script>
@@ -17,6 +18,18 @@
</div>
<div class="popup-actions">
<button id="refresh">Rescan page for videos</button>
<button
id="forceLastSavedSpeed"
class="popup-compact-action"
type="button"
aria-pressed="false"
>Force last saved speed</button>
<button
id="pauseTab"
class="popup-compact-action"
type="button"
aria-pressed="false"
>Pause on this tab</button>
<div class="popup-divider"></div>
<div id="popupControlBar" class="popup-control-bar">
<span id="popupSpeed" class="popup-speed">1.00</span>
@@ -25,10 +38,17 @@
<button id="enable" class="hide">Enable</button>
<button id="disable">Disable</button>
</div>
<div id="status" class="popup-status hide"></div>
<div
id="status"
class="popup-status hide"
role="status"
aria-live="polite"
></div>
<div class="popup-links">
<button id="config">Settings</button>
<div class="popup-secondary">
<button id="addSiteRule" class="secondary">Add current site</button>
<button id="copyDiagnostics" class="secondary">Copy diagnostics</button>
<button id="feedback" class="secondary">Feedback</button>
<button id="about" class="secondary">About</button>
</div>
+414 -61
View File
@@ -1,4 +1,6 @@
document.addEventListener("DOMContentLoaded", function () {
if (window.vscPopupInitialized) return;
window.vscPopupInitialized = true;
var speederShared =
typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {};
var siteRuleUtils = speederShared.siteRules || {};
@@ -25,15 +27,120 @@ document.addEventListener("DOMContentLoaded", function () {
var defaultButtons = ["rewind", "slower", "faster", "advance", "display"];
var popupExcludedButtonIds = new Set(["settings"]);
var storageDefaults = {
enabled: true,
showPopupControlBar: true,
controllerButtons: defaultButtons,
popupMatchHoverControls: true,
popupControllerButtons: defaultButtons,
siteRules: []
};
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) {
var button = document.getElementById("forceLastSavedSpeed");
if (!button) return;
button.setAttribute("aria-pressed", enabled ? "true" : "false");
button.title = enabled
? "Stop forcing the saved speed"
: "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) {
return siteRuleUtils.matchSiteRule(url, siteRules);
@@ -75,16 +182,23 @@ document.addEventListener("DOMContentLoaded", function () {
}
function getActiveTabContext(callback) {
function finish(context) {
getTabPauseState(context.tab, function(paused) {
context.tabPaused = paused;
if (callback) callback(context);
});
}
chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
var activeTab = tabs && tabs[0] ? tabs[0] : null;
if (!activeTab || !activeTab.id) {
if (callback) callback({ tab: null, url: "" });
finish({ tab: null, url: "" });
return;
}
var tabUrl = typeof activeTab.url === "string" ? activeTab.url : "";
if (tabUrl.length > 0) {
if (callback) callback({ tab: activeTab, url: tabUrl });
finish({ tab: activeTab, url: tabUrl });
return;
}
@@ -93,13 +207,13 @@ document.addEventListener("DOMContentLoaded", function () {
{ action: "get_page_context" },
function (response) {
if (chrome.runtime.lastError) {
if (callback) callback({ tab: activeTab, url: "" });
finish({ tab: activeTab, url: "" });
return;
}
var pageUrl =
response && typeof response.url === "string" ? response.url : "";
if (callback) callback({ tab: activeTab, url: pageUrl });
finish({ tab: activeTab, url: pageUrl });
}
);
});
@@ -114,15 +228,20 @@ document.addEventListener("DOMContentLoaded", function () {
if (response && response.speed != null) {
updateSpeedDisplay(response.speed);
}
selectedFrameToken =
response && typeof response.frameToken === "string"
? response.frameToken
: null;
}
function pickBestFrameSpeedResult(results) {
return popupControlUtils.pickBestFrameSpeedResult(results);
}
function querySpeed() {
function querySpeed(callback) {
chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
if (!tabs[0] || tabs[0].id == null) {
if (callback) callback(null);
return;
}
var tabId = tabs[0].id;
@@ -133,15 +252,18 @@ document.addEventListener("DOMContentLoaded", function () {
if (chrome.runtime.lastError) {
sendToActiveTab({ action: "get_speed" }, function (response) {
applySpeedAndResetFromResponse(response || { speed: 1 });
if (callback) callback(response || null);
});
return;
}
var best = pickBestFrameSpeedResult(results);
if (best) {
applySpeedAndResetFromResponse(best);
if (callback) callback(best);
} else {
sendToActiveTab({ action: "get_speed" }, function (response) {
applySpeedAndResetFromResponse(response || { speed: 1 });
if (callback) callback(response || null);
});
}
}
@@ -189,14 +311,19 @@ document.addEventListener("DOMContentLoaded", function () {
}
if (def.className) btn.className = def.className;
btn.title = btnId.charAt(0).toUpperCase() + btnId.slice(1);
btn.setAttribute("aria-label", btn.title);
btn.addEventListener("click", function () {
if (btnId === "settings") {
window.open(chrome.runtime.getURL("options/options.html"));
return;
}
var message = { action: "run_action", actionName: btnId };
if (shortcutTargetMode !== "all" && selectedFrameToken) {
message.targetFrameToken = selectedFrameToken;
}
sendToActiveTab(
{ action: "run_action", actionName: btnId },
message,
function () {
querySpeed();
}
@@ -225,6 +352,104 @@ document.addEventListener("DOMContentLoaded", function () {
window.open("https://github.com/SoPat712/Speeder/issues");
});
document.querySelector("#copyDiagnostics").addEventListener("click", function () {
var report = buildDiagnosticReport();
if (!report) {
setStatusMessage("Diagnostics are still loading.");
return;
}
if (!navigator.clipboard || typeof navigator.clipboard.writeText !== "function") {
setStatusMessage("Clipboard access is unavailable.");
return;
}
navigator.clipboard.writeText(report).then(
function () {
setStatusMessage("Diagnostics copied. Review before sharing.");
},
function () {
setStatusMessage("Could not copy diagnostics.");
}
);
});
document.querySelector("#addSiteRule").addEventListener("click", function () {
var details = diagnosticContext
? getCurrentSiteRuleDetails(diagnosticContext.url)
: null;
if (!details) {
setStatusMessage("A site rule cannot be created for this page.");
return;
}
var added = false;
updateStoredSettings(
function (settings) {
var rules = Array.isArray(settings.siteRules) ? settings.siteRules : [];
if (
rules.some(function(rule) {
return rule && rule.pattern === details.pattern;
})
) {
return;
}
settings.siteRules = rules.concat([
{
title: details.title,
pattern: details.pattern,
enabled: true
}
]);
added = true;
},
function (error) {
if (error) {
setStatusMessage("Could not add this site: " + error.message);
return;
}
setStatusMessage(
added
? "Site rule added. Opening settings..."
: "Site rule already exists. Opening settings..."
);
window.open(chrome.runtime.getURL("options/options.html"));
}
);
});
document.querySelector("#pauseTab").addEventListener("click", function () {
var tab = diagnosticContext && diagnosticContext.tab;
if (!tab || !Number.isInteger(tab.id)) {
setStatusMessage("This page cannot be paused.");
return;
}
var button = this;
var paused = button.getAttribute("aria-pressed") !== "true";
button.disabled = true;
chrome.runtime.sendMessage(
{ action: "set_tab_paused", tabId: tab.id, paused: paused },
function(response) {
button.disabled = false;
if (chrome.runtime.lastError || !response) {
setStatusMessage("Could not update this tab.");
return;
}
diagnosticContext.tabPaused = response.paused === true;
updateTabPauseButton(diagnosticContext.tabPaused);
setControlBarVisible(
!diagnosticContext.tabPaused &&
diagnosticContext.siteAvailable &&
diagnosticContext.showBar
);
setForceButtonLoading(diagnosticContext.tabPaused);
setStatusMessage(
diagnosticContext.tabPaused
? "Speeder is paused for this tab."
: "Speeder resumed for this tab."
);
}
);
});
document.querySelector("#donate").addEventListener("click", function () {
this.classList.add("hide");
document.querySelector("#donateOptions").classList.remove("hide");
@@ -252,8 +477,76 @@ document.addEventListener("DOMContentLoaded", function () {
});
});
var forceLastSavedSpeedButton = document.querySelector(
"#forceLastSavedSpeed"
);
if (!forceLastSavedSpeedButton.dataset.listenerAttached) {
forceLastSavedSpeedButton.dataset.listenerAttached = "true";
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 enabled = button.getAttribute("aria-pressed") !== "true";
updateStoredSettings(
function (settings) {
settings.forceLastSavedSpeed = enabled;
},
function (error) {
if (error) {
setStatusMessage("Could not update speed forcing: " + error.message);
return;
}
sendToActiveTab(
{
action: "set_force_last_saved_speed",
enabled: enabled
},
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) {
updateSpeedDisplay(response.speed);
setStatusMessage(
effectiveEnabled
? "Saved speed is now forced."
: "Speed forcing is off."
);
} else {
setStatusMessage(
effectiveEnabled
? "Force enabled. No video found on this page."
: "Speed forcing is off."
);
}
}
);
}
);
});
}
function renderForActiveTab() {
var currentRenderToken = ++renderToken;
forceLastSavedSpeedControlledBySiteRule = null;
selectedFrameToken = null;
setForceButtonLoading(true);
chrome.storage.local.get(["customButtonIcons"], function (loc) {
if (currentRenderToken !== renderToken) return;
@@ -262,45 +555,102 @@ document.addEventListener("DOMContentLoaded", function () {
? loc.customButtonIcons
: {};
chrome.storage.sync.get(storageDefaults, function (storage) {
chrome.storage.sync.get(null, function (rawStorage) {
if (currentRenderToken !== renderToken) return;
var storage = vscExpandStoredSettings(rawStorage || {});
getActiveTabContext(function (context) {
if (currentRenderToken !== renderToken) return;
getActiveTabContext(function (context) {
if (currentRenderToken !== renderToken) return;
var url = context && context.url ? context.url : "";
var siteRule = matchSiteRule(url, storage.siteRules);
var siteDisabled = isSiteRuleDisabled(siteRule);
var siteAvailable = siteRuleUtils.isSpeederActiveForSite(
storage.enabled,
siteRule
);
var showBar = storage.showPopupControlBar !== false;
var tabPaused = context && context.tabPaused === true;
updateTabPauseButton(tabPaused);
if (siteRule && siteRule.showPopupControlBar !== undefined) {
showBar = siteRule.showPopupControlBar;
}
var url = context && context.url ? context.url : "";
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);
buildControlBar(
resolvePopupButtons(storage, siteRule),
customIconsMap
);
setControlBarVisible(siteAvailable && showBar);
if (siteRule && siteRule.showPopupControlBar !== undefined) {
showBar = siteRule.showPopupControlBar;
diagnosticContext.showBar = showBar;
}
if (siteDisabled) {
setStatusMessage("Speeder is disabled for this site.");
updateSpeedDisplay(1);
return;
}
toggleEnabledUI(storage.enabled !== false);
updateForceButton(effectiveForceLastSavedSpeed);
buildControlBar(
resolvePopupButtons(storage, siteRule),
customIconsMap
);
setControlBarVisible(!tabPaused && siteAvailable && showBar);
clearStatusMessage();
if (siteAvailable) {
querySpeed();
} else {
updateSpeedDisplay(1);
}
});
if (tabPaused) {
setForceButtonLoading(true);
setStatusMessage("Speeder is paused for this tab.");
return;
}
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);
}
});
});
});
}
@@ -324,11 +674,14 @@ document.addEventListener("DOMContentLoaded", function () {
if (areaName !== "sync") return;
if (
changes.enabled ||
changes.forceLastSavedSpeed ||
changes.showPopupControlBar ||
changes.controllerButtons ||
changes.popupMatchHoverControls ||
changes.popupControllerButtons ||
changes.siteRules
changes.siteRules ||
changes.siteRulesMeta ||
changes.siteRulesFormat
) {
renderForActiveTab();
}
@@ -337,29 +690,29 @@ document.addEventListener("DOMContentLoaded", function () {
renderForActiveTab();
function toggleEnabled(enabled, callback) {
chrome.storage.sync.set({ enabled: enabled }, function () {
toggleEnabledUI(enabled);
if (callback) callback(enabled);
});
updateStoredSettings(
function (settings) {
settings.enabled = enabled;
},
function (error) {
if (error) {
setStatusMessage("Could not update Speeder: " + error.message);
return;
}
toggleEnabledUI(enabled);
if (callback) callback(enabled);
}
);
}
function toggleEnabledUI(enabled) {
document.querySelector("#enable").classList.toggle("hide", enabled);
document.querySelector("#disable").classList.toggle("hide", !enabled);
const suffix = `${enabled ? "" : "_disabled"}.png`;
chrome.browserAction.setIcon({
path: {
19: "assets/icons/icon19" + suffix,
38: "assets/icons/icon38" + suffix,
48: "assets/icons/icon48" + suffix
}
});
}
function settingsSavedReloadMessage(enabled) {
setStatusMessage(
`${enabled ? "Enabled" : "Disabled"}. Reload page to see changes`
`${enabled ? "Enabled" : "Disabled"}. Open pages update automatically.`
);
}
+68 -20
View File
@@ -30,11 +30,26 @@
"rememberSpeed",
"showPopupControlBar",
"siteRules",
"siteRulesFormat",
"siteRulesMeta",
"speed",
"startHidden",
"subtitleNudgeAmount",
"subtitleNudgeEnabledByDefault",
"subtitleNudgeInterval"
"subtitleNudgeInterval",
"resetSpeed",
"speedStep",
"fastSpeed",
"rewindTime",
"advanceTime",
"resetKeyCode",
"slowerKeyCode",
"fasterKeyCode",
"rewindKeyCode",
"advanceKeyCode",
"fastKeyCode",
"displayKeyCode",
"blacklist"
]);
function isRecognizedRawSettingsObject(backup) {
@@ -48,24 +63,20 @@
}
/**
* Local-only keys excluded from backup JSON. These are disposable caches
* (e.g. Lucide tags.json) that bloat exports and are refetched when needed.
* Keep in sync with options/lucide-client.js (LUCIDE_TAGS_CACHE_KEY + "At").
* Only user-authored local settings belong in backups. Disposable caches,
* per-source speed history, and future runtime data stay installation-local.
*/
var localSettingsKeysOmittedFromExport = [
"lucideTagsCacheV1",
"lucideTagsCacheV1At"
];
function filterLocalSettingsForExport(local) {
if (!local || typeof local !== "object" || Array.isArray(local)) {
return {};
}
var out = {};
for (var key in local) {
if (!Object.prototype.hasOwnProperty.call(local, key)) continue;
if (localSettingsKeysOmittedFromExport.indexOf(key) !== -1) continue;
out[key] = local[key];
if (
local.customButtonIcons &&
typeof local.customButtonIcons === "object" &&
!Array.isArray(local.customButtonIcons)
) {
out.customButtonIcons = local.customButtonIcons;
}
return out;
}
@@ -108,27 +119,64 @@
function extractImportSettings(backup) {
var settingsToImport = null;
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;
isWrappedBackup = true;
} else if (
backup &&
typeof backup === "object" &&
isRecognizedRawSettingsObject(backup)
) {
} else if (backupIsObject && isRecognizedRawSettingsObject(backup)) {
settingsToImport = backup;
}
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 {
isWrappedBackup: isWrappedBackup,
settings: settingsToImport,
localSettings:
backup &&
backup.localSettings &&
typeof backup.localSettings === "object"
typeof backup.localSettings === "object" &&
!Array.isArray(backup.localSettings)
? backup.localSettings
: null
};
+57 -1
View File
@@ -8,6 +8,9 @@
root.SpeederShared = root.SpeederShared || {};
root.SpeederShared.keyBindings = exports;
})(typeof globalThis !== "undefined" ? globalThis : this, function() {
var MIN_SPEED = 0.1;
var MAX_SPEED = 16;
function normalizeBindingKey(key) {
if (typeof key !== "string" || key.length === 0) return null;
if (key === "Spacebar") return " ";
@@ -112,11 +115,64 @@
: 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 {
MAX_SPEED: MAX_SPEED,
MIN_SPEED: MIN_SPEED,
getActionValueError: getActionValueError,
getLegacyKeyCode: getLegacyKeyCode,
inferBindingCode: inferBindingCode,
legacyBindingKeyToCode: legacyBindingKeyToCode,
legacyKeyCodeToCode: legacyKeyCodeToCode,
normalizeBindingKey: normalizeBindingKey
normalizeBindingKey: normalizeBindingKey,
sanitizeActionValue: sanitizeActionValue
};
});
+76 -2
View File
@@ -66,18 +66,92 @@
function pickBestFrameSpeedResult(results) {
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;
for (var i = 0; i < results.length; i++) {
var result = results[i];
if (!result || typeof result.speed !== "number") continue;
if (result.preferred) return { speed: result.speed };
if (!fallback) fallback = { speed: result.speed };
if (result.preferred) return normalizeResult(result);
if (!fallback) fallback = normalizeResult(result);
}
return fallback;
}
function getSafePageDetails(url) {
try {
var parsed = new URL(url);
return {
protocol: parsed.protocol,
hostname: parsed.hostname || null
};
} catch (_error) {
return { protocol: null, hostname: null };
}
}
function buildDiagnosticReport(context) {
var config = context || {};
var storage = config.storage || {};
var siteRule = config.siteRule || null;
var frame = config.frame || null;
return JSON.stringify(
{
speederVersion: config.speederVersion || null,
browser: config.browser || null,
platform: config.platform || null,
page: getSafePageDetails(config.url),
globalSettings: {
enabled: storage.enabled !== false,
rememberSpeed: storage.rememberSpeed === true,
forceLastSavedSpeed: storage.forceLastSavedSpeed === true,
audioEnabled: storage.audioBoolean === true,
startHidden: storage.startHidden === true,
hideWithControls: storage.hideWithControls === true,
controllerLocation: storage.controllerLocation,
shortcutTargetMode: storage.shortcutTargetMode
},
matchedSiteRule: {
matched: Boolean(siteRule),
disabled: config.siteRuleDisabled === true,
overrideKeys: siteRule
? Object.keys(siteRule)
.filter(function(key) {
return key !== "pattern" && key !== "title";
})
.sort()
: []
},
tabPaused: config.tabPaused === true,
frame: frame && frame.diagnostics ? frame.diagnostics : null
},
null,
2
);
}
return {
buildDiagnosticReport: buildDiagnosticReport,
pickBestFrameSpeedResult: pickBestFrameSpeedResult,
resolvePopupButtons: resolvePopupButtons,
sanitizeButtonOrder: sanitizeButtonOrder
+574 -132
View File
@@ -1,32 +1,32 @@
(function (global) {
"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 SITE_RULE_OVERRIDE_KEYS = [
"controllerLocation",
"controllerMarginTop",
"controllerMarginBottom",
"startHidden",
"hideWithControls",
"hideWithControlsTimer",
"rememberSpeed",
"forceLastSavedSpeed",
"audioBoolean",
"controllerOpacity",
"enableSubtitleNudge",
"subtitleNudgeEnabledByDefault",
"subtitleNudgeInterval",
"controllerButtons",
"showPopupControlBar",
"popupControllerButtons",
"shortcuts",
"preferredSpeed"
var LEGACY_SYNC_KEYS = [
"resetSpeed",
"speedStep",
"fastSpeed",
"rewindTime",
"advanceTime",
"resetKeyCode",
"slowerKeyCode",
"fasterKeyCode",
"rewindKeyCode",
"advanceKeyCode",
"fastKeyCode",
"displayKeyCode",
"blacklist"
];
var DIFFABLE_OPTION_KEYS = [
"rememberSpeed",
"forceLastSavedSpeed",
"audioBoolean",
"showAmbientLoopControls",
"enabled",
"startHidden",
"hideWithControls",
@@ -34,7 +34,10 @@
"controllerLocation",
"controllerOpacity",
"controllerMarginTop",
"controllerMarginRight",
"controllerMarginBottom",
"controllerMarginLeft",
"shortcutTargetMode",
"keyBindings",
"siteRules",
"siteRulesMeta",
@@ -48,16 +51,17 @@
"subtitleNudgeInterval",
"subtitleNudgeAmount"
];
var MANAGED_SYNC_KEYS = DIFFABLE_OPTION_KEYS.concat([
"hideWithYouTubeControls"
]);
var MANAGED_SYNC_KEYS = DIFFABLE_OPTION_KEYS.concat(
["hideWithYouTubeControls"],
LEGACY_SYNC_KEYS
);
var DEFAULT_SETTINGS = {
speed: 1.0,
lastSpeed: 1.0,
displayKeyCode: 86,
rememberSpeed: false,
audioBoolean: false,
showAmbientLoopControls: false,
startHidden: false,
hideWithYouTubeControls: false,
hideWithControls: false,
@@ -70,12 +74,11 @@
controllerMarginRight: 0,
controllerMarginBottom: 65,
controllerMarginLeft: 0,
shortcutTargetMode: "closest",
keyBindings: [
{
action: "display",
key: "V",
keyCode: 86,
code: null,
code: "KeyV",
disabled: false,
value: 0,
force: false,
@@ -83,9 +86,7 @@
},
{
action: "move",
key: "P",
keyCode: 80,
code: null,
code: "KeyP",
disabled: false,
value: 0,
force: false,
@@ -93,9 +94,7 @@
},
{
action: "slower",
key: "S",
keyCode: 83,
code: null,
code: "KeyS",
disabled: false,
value: 0.1,
force: false,
@@ -103,9 +102,7 @@
},
{
action: "faster",
key: "D",
keyCode: 68,
code: null,
code: "KeyD",
disabled: false,
value: 0.1,
force: false,
@@ -113,9 +110,7 @@
},
{
action: "rewind",
key: "Z",
keyCode: 90,
code: null,
code: "KeyZ",
disabled: false,
value: 10,
force: false,
@@ -123,9 +118,7 @@
},
{
action: "advance",
key: "X",
keyCode: 88,
code: null,
code: "KeyX",
disabled: false,
value: 10,
force: false,
@@ -133,9 +126,7 @@
},
{
action: "reset",
key: "R",
keyCode: 82,
code: null,
code: "KeyR",
disabled: false,
value: 0,
force: false,
@@ -143,9 +134,7 @@
},
{
action: "fast",
key: "G",
keyCode: 71,
code: null,
code: "KeyG",
disabled: false,
value: 1.8,
force: false,
@@ -153,9 +142,7 @@
},
{
action: "toggleSubtitleNudge",
key: "N",
keyCode: 78,
code: null,
code: "KeyN",
disabled: false,
value: 0,
force: false,
@@ -164,17 +151,25 @@
],
siteRules: [
{
title: "YouTube videos",
pattern: "/^https:\\/\\/(www\\.)?youtube\\.com\\/(?!shorts\\/).*/",
enabled: true,
enableSubtitleNudge: true,
subtitleNudgeInterval: 50
enabled: true
},
{
title: "YouTube Shorts",
pattern: "/^https:\\/\\/(www\\.)?youtube\\.com\\/shorts\\/.*/",
enabled: true,
rememberSpeed: true,
controllerMarginTop: 60,
controllerMarginBottom: 85
},
{
title: "YouTube Shorts (mobile)",
pattern: "/^https:\\/\\/m\\.youtube\\.com\\/shorts\\/.*/",
enabled: true,
rememberSpeed: true,
controllerMarginTop: 60,
controllerMarginBottom: 85
}
],
controllerButtons: DEFAULT_BUTTONS.slice(),
@@ -183,7 +178,7 @@
popupControllerButtons: DEFAULT_BUTTONS.slice(),
enableSubtitleNudge: false,
subtitleNudgeEnabledByDefault: true,
subtitleNudgeInterval: 50,
subtitleNudgeInterval: 250,
subtitleNudgeAmount: 0.001
};
@@ -305,7 +300,84 @@
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)) {
return null;
}
@@ -316,89 +388,83 @@
}
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) {
if (
key === "pattern" ||
key === "enabled" ||
key === "disableExtension" ||
SITE_RULE_OVERRIDE_KEYS.indexOf(key) !== -1 ||
rule[key] === undefined
) {
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;
}
function compressSiteRules(siteRules, baseSettings) {
function compressSiteRules(siteRules) {
if (!Array.isArray(siteRules)) {
return {};
}
var defaultRules = getDefaultSiteRules();
var defaultRulesByPattern = getDefaultSiteRulesByPattern();
var currentPatterns = new Set();
var claimedDefaultPatterns = new Set();
var exportRules = [];
var removedDefaultRuleKeys = [];
siteRules.forEach(function (rule) {
if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
return;
}
var normalizedRule = normalizeSiteRule(rule);
if (!normalizedRule) return;
var pattern = typeof rule.pattern === "string" ? rule.pattern.trim() : "";
if (pattern) {
currentPatterns.add(pattern);
}
var pattern = normalizedRule.pattern;
var defaultRule = defaultRulesByPattern[pattern];
if (defaultRule && !claimedDefaultPatterns.has(pattern)) {
claimedDefaultPatterns.add(pattern);
var normalizedDefaultRule = normalizeSiteRule(defaultRule);
var patch = { pattern: pattern };
var removedKeys = [];
var normalizedRule = normalizeSiteRuleForDiff(rule, baseSettings);
if (!normalizedRule || Object.keys(normalizedRule).length === 1) {
return;
}
Object.keys(normalizedRule).forEach(function (key) {
if (key === "pattern") return;
if (
!hasOwn(normalizedDefaultRule, key) ||
!areComparableValuesEqual(
normalizedRule[key],
normalizedDefaultRule[key]
)
) {
patch[key] = clonePlainData(normalizedRule[key]);
}
});
var defaultRule = pattern ? defaultRulesByPattern[pattern] : null;
var normalizedDefaultRule = defaultRule
? normalizeSiteRuleForDiff(defaultRule, baseSettings)
: null;
if (normalizedDefaultRule) {
if (areComparableValuesEqual(normalizedRule, normalizedDefaultRule)) {
return;
Object.keys(normalizedDefaultRule).forEach(function (key) {
if (key === "pattern" || hasOwn(normalizedRule, key)) return;
removedKeys.push(key);
});
if (Object.keys(patch).length > 1) {
exportRules.push(patch);
}
var defaultRuleDiff = deepDiff(normalizedRule, normalizedDefaultRule);
if (defaultRuleDiff && Object.keys(defaultRuleDiff).length > 0) {
defaultRuleDiff.pattern = pattern;
exportRules.push(defaultRuleDiff);
if (removedKeys.length > 0) {
removedDefaultRuleKeys.push({
pattern: pattern,
keys: removedKeys.sort()
});
}
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);
});
@@ -407,7 +473,7 @@
return rule && typeof rule.pattern === "string" ? rule.pattern : "";
})
.filter(function (pattern) {
return pattern && !currentPatterns.has(pattern);
return pattern && !claimedDefaultPatterns.has(pattern);
});
var result = {};
@@ -415,17 +481,48 @@
result.siteRules = exportRules;
result.siteRulesFormat = SITE_RULES_DIFF_FORMAT;
}
if (removedDefaultPatterns.length > 0) {
result.siteRulesMeta = {
removedDefaultPatterns: removedDefaultPatterns
};
if (
removedDefaultPatterns.length > 0 ||
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;
}
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 defaultRulesByPattern = getDefaultSiteRulesByPattern();
if (defaultRules.length === 0) {
@@ -437,7 +534,11 @@
? siteRulesMeta.removedDefaultPatterns
: []
);
var removedKeysByPattern = supportsRemovedKeys
? getRemovedDefaultRuleKeysByPattern(siteRulesMeta)
: Object.create(null);
var modifiedDefaultRules = Object.create(null);
var claimedDefaultPatterns = new Set();
var customRules = [];
if (Array.isArray(siteRules)) {
@@ -449,13 +550,16 @@
var pattern = typeof rule.pattern === "string" ? rule.pattern.trim() : "";
if (
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;
}
customRules.push(clonePlainData(rule));
var customRule = normalizeSiteRule(rule);
if (customRule) customRules.push(customRule);
});
}
@@ -468,17 +572,23 @@
}
if (modifiedDefaultRules[pattern]) {
mergedRules.push(
Object.assign(
{},
clonePlainData(rule),
clonePlainData(modifiedDefaultRules[pattern])
)
var mergedRule = Object.assign(
{},
clonePlainData(rule),
clonePlainData(modifiedDefaultRules[pattern])
);
(removedKeysByPattern[pattern] || []).forEach(function (key) {
delete mergedRule[key];
});
mergedRules.push(mergedRule);
return;
}
mergedRules.push(clonePlainData(rule));
var unmodifiedRule = clonePlainData(rule);
(removedKeysByPattern[pattern] || []).forEach(function (key) {
delete unmodifiedRule[key];
});
mergedRules.push(unmodifiedRule);
});
customRules.forEach(function (rule) {
@@ -488,6 +598,49 @@
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) {
var defaults = clonePlainData(DEFAULT_SETTINGS);
var normalized = deepMergeDefaults(defaults, currentSettings || {});
@@ -526,25 +679,292 @@
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) {
var raw = clonePlainData(storage) || {};
var raw = isPlainObject(storage) ? clonePlainData(storage) : {};
var expanded = deepMergeDefaults(DEFAULT_SETTINGS, raw);
if (
!hasOwn(raw, "hideWithControls") &&
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.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) {
expanded.siteRules = expandSiteRules(raw.siteRules, raw.siteRulesMeta);
} else if (Array.isArray(raw.siteRules)) {
expanded.siteRules = clonePlainData(raw.siteRules);
expanded.siteRules = expandSiteRules(
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 {
expanded.siteRules = getDefaultSiteRules();
}
expanded.siteRules = expanded.siteRules
.map(normalizeSiteRule)
.filter(Boolean);
return expanded;
}
@@ -565,6 +985,9 @@
}
var regex;
if (pattern.startsWith("/") && pattern.lastIndexOf("/") === 0) {
return false;
}
if (pattern.startsWith("/") && pattern.lastIndexOf("/") > 0) {
try {
var lastSlash = pattern.lastIndexOf("/");
@@ -631,6 +1054,7 @@
global.vscAreComparableValuesEqual = areComparableValuesEqual;
global.vscDeepMergeDefaults = deepMergeDefaults;
global.vscBuildStoredSettingsDiff = buildStoredSettingsDiff;
global.vscBuildManagedStorageMutation = buildManagedStorageMutation;
global.vscExpandStoredSettings = expandStoredSettings;
global.vscGetSettingsDefaults = function () {
return clonePlainData(DEFAULT_SETTINGS);
@@ -641,7 +1065,25 @@
global.vscGetSiteRulesDiffFormat = function () {
return SITE_RULES_DIFF_FORMAT;
};
global.vscMatchSiteRule = mergeMatchingSiteRules;
global.vscSiteRuleMatchesUrl = siteRuleMatchesUrl;
global.vscIsSiteRuleDisabled = isSiteRuleDisabled;
global.vscGetLegacySiteRulesDiffFormat = function () {
return LEGACY_SITE_RULES_DIFF_FORMAT;
};
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);
+248 -14
View File
@@ -14,49 +14,280 @@
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) {
if (typeof pattern !== "string") return null;
var normalizedPattern = pattern.replace(regStrip, "");
if (normalizedPattern.length === 0) return null;
if (
normalizedPattern.startsWith("/") &&
normalizedPattern.lastIndexOf("/") === 0
) {
return null;
}
if (
normalizedPattern.startsWith("/") &&
normalizedPattern.lastIndexOf("/") > 0
) {
var lastSlash = normalizedPattern.lastIndexOf("/");
return new RegExp(
var regularExpression = new RegExp(
normalizedPattern.substring(1, lastSlash),
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) {
if (!url || !Array.isArray(siteRules)) return null;
var matches = [];
for (var i = 0; i < siteRules.length; i++) {
var rule = siteRules[i];
if (!rule || !rule.pattern) continue;
try {
var re = compileSiteRulePattern(rule.pattern);
if (re && re.test(url)) {
return rule;
}
} catch (e) {
}
if (siteRuleMatchesUrl(rule, url)) matches.push(rule);
}
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) {
return Boolean(
rule &&
(rule.enabled === false || rule.disableExtension === true)
(
rule.enabled === false ||
(typeof rule.enabled === "undefined" && rule.disableExtension === true)
)
);
}
@@ -82,6 +313,9 @@
escapeStringRegExp: escapeStringRegExp,
isSiteRuleDisabled: isSiteRuleDisabled,
isSpeederActiveForSite: isSpeederActiveForSite,
matchSiteRule: matchSiteRule
matchSiteRule: matchSiteRule,
plainSitePatternMatchesUrl: plainSitePatternMatchesUrl,
safelyDecodeUrlText: safelyDecodeUrlText,
siteRuleMatchesUrl: siteRuleMatchesUrl
};
});
+66
View File
@@ -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 });
});
});
+9 -3
View File
@@ -17,9 +17,9 @@ function readRepoFile(relPath) {
* Parse HTML into a fresh JSDOM document so tests can reload scripts without
* top-level `const` redeclaration errors (avoids document.write).
*/
export function loadHtmlString(html) {
export function loadHtmlString(html, options = {}) {
const dom = new JSDOM(html, {
url: "https://example.org/",
url: options.url || "https://example.org/",
pretendToBeVisual: true,
runScripts: "dangerously"
});
@@ -173,7 +173,13 @@ export function createChromeMock(options = {}) {
getManifest: vi.fn(() => ({
version: options.manifestVersion || "9.9.9"
})),
getURL: vi.fn((url) => "moz-extension://speeder/" + url)
getURL: vi.fn((url) => "moz-extension://speeder/" + url),
sendMessage: vi.fn((message, callback) => {
if (options.runtimeSendMessageImpl) {
return options.runtimeSendMessageImpl(message, callback);
}
if (callback) callback({ paused: false });
})
},
storage: {
sync: syncArea,
+13 -3
View File
@@ -42,11 +42,13 @@ function applyJSDOMWindow(win) {
win.Date = globalThis.Date;
win.open = 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, {
url: "https://example.org/",
url: config.url || "https://example.org/",
pretendToBeVisual: true,
runScripts: "dangerously"
});
@@ -243,6 +245,7 @@ function createChromeMock(options) {
const storageOnChanged = createChromeEvent();
const tabsOnActivated = createChromeEvent();
const tabsOnUpdated = createChromeEvent();
const tabsOnRemoved = createChromeEvent();
const runtimeOnMessage = createChromeEvent();
const chrome = {
@@ -250,6 +253,12 @@ function createChromeMock(options) {
lastError: null,
getManifest: vi.fn(() => clone(config.manifest) || { version: "0.0.0-test" }),
getURL: vi.fn((relPath) => `moz-extension://${relPath}`),
sendMessage: vi.fn((message, callback) => {
if (config.runtimeSendMessageImpl) {
return config.runtimeSendMessageImpl(message, callback);
}
if (callback) callback({ paused: false });
}),
onMessage: runtimeOnMessage
},
browserAction: {
@@ -270,7 +279,8 @@ function createChromeMock(options) {
}),
create: vi.fn(),
onActivated: tabsOnActivated,
onUpdated: tabsOnUpdated
onUpdated: tabsOnUpdated,
onRemoved: tabsOnRemoved
},
storage: {
onChanged: storageOnChanged,
+1
View File
@@ -27,4 +27,5 @@ export function applyJSDOMWindow(win) {
win.open = vi.fn();
win.close = vi.fn();
win.confirm = vi.fn(() => true);
}
+63 -13
View File
@@ -12,6 +12,7 @@ async function setupImportExport(overrides = {}) {
const restoreSpy = vi.fn();
globalThis.restore_options = restoreSpy;
window.restore_options = restoreSpy;
loadScript("extension/shared/settings-core.js");
loadScript("extension/shared/import-export.js");
loadScript("extension/options/import-export.js");
await flushAsyncWork();
@@ -19,11 +20,34 @@ async function setupImportExport(overrides = {}) {
}
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 () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 3, 4, 8, 9, 10));
const exportTime = new Date(2026, 3, 4, 8, 9, 10);
vi.setSystemTime(exportTime);
const chrome = await setupImportExport({
sync: { rememberSpeed: true },
sync: { rememberSpeed: true, shortcutTargetMode: "all" },
local: { customButtonIcons: { faster: { slug: "rocket" } } }
});
const OriginalBlob = window.Blob;
@@ -66,8 +90,17 @@ describe("import/export flows", () => {
const blobText = await capturedBlob.text();
expect(JSON.parse(blobText)).toEqual({
version: "1.1",
exportDate: "2026-04-04T12:09:10.000Z",
settings: { rememberSpeed: true },
exportDate: exportTime.toISOString(),
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" } } }
});
expect(document.getElementById("status").textContent).toBe(
@@ -172,7 +205,7 @@ describe("import/export flows", () => {
{ customButtonIcons: { faster: { slug: "rocket" } } },
expect.any(Function)
);
expect(chrome.storage.sync.clear).toHaveBeenCalled();
expect(chrome.storage.sync.clear).not.toHaveBeenCalled();
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
{ rememberSpeed: true },
expect.any(Function)
@@ -221,7 +254,13 @@ describe("import/export flows", () => {
{
__text: JSON.stringify({
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.set).not.toHaveBeenCalled();
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
{
enabled: false,
siteRules: [{ pattern: "example.com", enabled: false }]
},
expect.any(Function)
const imported = globalThis.vscExpandStoredSettings(
chrome.storage.sync.__state
);
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 () => {
@@ -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.__state.lucideTagsCacheV1).toEqual({
stale: true
});
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
{ rememberSpeed: true },
expect.any(Function)
+74 -3
View File
@@ -51,6 +51,7 @@ function bootImportExport(options) {
});
global.URL = window.URL;
evaluateScript("extension/shared/settings-core.js");
evaluateScript("extension/shared/import-export.js");
evaluateScript("extension/options/import-export.js");
fireDOMContentLoaded();
@@ -69,7 +70,7 @@ describe("options/import-export.js", () => {
});
it("generates timestamped backup filenames", () => {
vi.setSystemTime(new Date("2026-04-04T13:14:15Z"));
vi.setSystemTime(new Date(2026, 3, 4, 9, 14, 15));
bootImportExport();
expect(window.generateBackupFilename()).toBe(
@@ -136,9 +137,14 @@ describe("options/import-export.js", () => {
expect(backup.localSettings.lucideTagsCacheV1At).toBeUndefined();
});
it("imports wrapped backups, restores local data, and refreshes the options page", async () => {
it("reports partial success and refreshes when custom icons fail to import", async () => {
const { chrome } = bootImportExport();
window.restore_options = vi.fn();
chrome.storage.local.set.mockImplementationOnce(function(_items, callback) {
chrome.runtime.lastError = { message: "icon quota exceeded" };
callback();
chrome.runtime.lastError = null;
});
const realCreateElement = document.createElement.bind(document);
const fakeInput = realCreateElement("input");
@@ -191,11 +197,17 @@ describe("options/import-export.js", () => {
},
expect.any(Function)
);
expect(chrome.storage.sync.clear).toHaveBeenCalled();
expect(chrome.storage.sync.clear).not.toHaveBeenCalled();
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
{ rememberSpeed: true, enabled: false },
expect.any(Function)
);
expect(document.querySelector("#status").textContent).toContain(
"Settings imported, but custom icons could not be updated"
);
expect(document.querySelector("#status").textContent).toContain(
"icon quota exceeded"
);
vi.advanceTimersByTime(500);
expect(window.restore_options).toHaveBeenCalled();
@@ -237,4 +249,63 @@ describe("options/import-export.js", () => {
"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>" }
});
});
});
+1263
View File
@@ -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();
});
});
+245
View File
@@ -139,5 +139,250 @@ describe("inject.js helper logic", () => {
expect(window.tc.settings.controllerMarginTop).toBe(200);
expect(window.tc.settings.controllerMarginBottom).toBe(0);
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 () => {
bootInject();
await flushAsyncWork(3);
const mount = document.createElement("div");
const video = document.createElement("video");
const wrapper = document.createElement("div");
wrapper.className = "vsc-controller";
mount.append(video, wrapper);
document.body.appendChild(mount);
Object.defineProperties(mount, {
offsetWidth: { value: 400 },
offsetHeight: { value: 240 },
clientLeft: { value: 2 },
clientTop: { value: 2 }
});
mount.getBoundingClientRect = () => ({
left: 100,
top: 50,
right: 500,
bottom: 290,
width: 400,
height: 240
});
video.getBoundingClientRect = () => ({
left: 140,
top: 70,
right: 460,
bottom: 250,
width: 320,
height: 180
});
window.positionControllerHost(wrapper, video, mount);
expect(wrapper.style.getPropertyValue("left")).toBe("38px");
expect(wrapper.style.getPropertyValue("top")).toBe("18px");
expect(wrapper.style.getPropertyValue("width")).toBe("320px");
expect(wrapper.style.getPropertyValue("height")).toBe("180px");
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
View File
@@ -190,6 +190,34 @@ describe("inject runtime", () => {
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 () => {
vi.useFakeTimers();
loadHtmlString("<!doctype html><html><body></body></html>");
@@ -222,7 +250,9 @@ describe("inject runtime", () => {
loadScript("extension/content/inject.js");
// 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) {
await flushAsyncWork();
+178 -8
View File
@@ -13,6 +13,7 @@ async function setupOptions(overrides = {}) {
globalThis.fetch = vi.fn();
loadScript("extension/shared/controller-utils.js");
loadScript("extension/shared/key-bindings.js");
loadScript("extension/shared/settings-core.js");
loadScript("extension/shared/popup-controls.js");
loadScript("extension/shared/ui-icons.js");
loadScript("extension/options/lucide-client.js");
@@ -28,7 +29,9 @@ describe("options page", () => {
manifestVersion: "5.1.7.0",
sync: {
rememberSpeed: true,
showAmbientLoopControls: true,
enabled: false,
shortcutTargetMode: "all",
subtitleNudgeEnabledByDefault: false,
popupMatchHoverControls: false,
popupControllerButtons: ["rewind", "settings", "advance", "advance"],
@@ -38,8 +41,12 @@ describe("options page", () => {
],
siteRules: [
{
title: "YouTube testing",
pattern: "youtube.com",
enabled: true,
shortcutTargetMode: "all",
preferredSpeed: 2.4,
showAmbientLoopControls: false,
subtitleNudgeEnabledByDefault: false,
showPopupControlBar: false,
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("rememberSpeed").checked).toBe(true);
expect(document.getElementById("showAmbientLoopControls").checked).toBe(true);
expect(document.getElementById("enabled").checked).toBe(false);
expect(document.getElementById("shortcutTargetMode").value).toBe("all");
expect(document.getElementById("subtitleNudgeEnabledByDefault").checked).toBe(
false
);
@@ -63,6 +72,18 @@ describe("options page", () => {
expect(document.querySelector(".site-rule .override-subtitleNudge").checked).toBe(
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(
document.querySelector(".site-rule .site-subtitleNudgeEnabledByDefault")
.checked
@@ -70,12 +91,58 @@ describe("options page", () => {
expect(globalThis.getPopupControlBarOrder()).toEqual(["rewind", "advance"]);
});
it("reorders and toggles control-bar buttons from the keyboard", async () => {
await setupOptions({
sync: { controllerButtons: ["rewind", "faster"] }
});
const active = document.getElementById("controlBarActive");
const rewind = active.querySelector('[data-button-id="rewind"]');
rewind.dispatchEvent(
new window.KeyboardEvent("keydown", {
key: "ArrowRight",
bubbles: true,
cancelable: true
})
);
expect(globalThis.getControlBarOrder()).toEqual(["faster", "rewind"]);
expect(rewind.disabled).toBe(false);
rewind.click();
expect(globalThis.getControlBarOrder()).toEqual(["faster"]);
expect(rewind.closest(".cb-available-zone")).not.toBeNull();
expect(rewind.getAttribute("aria-label")).toContain("available");
});
it("labels shortcut and generated site-rule controls", async () => {
await setupOptions();
expect(document.getElementById("lucideIconResults").getAttribute("role")).toBe(
"group"
);
expect(
document.querySelector('#display .customKey').getAttribute("aria-label")
).toBe("Show/hide controller key");
globalThis.createSiteRule(null);
const rule = document.querySelector(".site-rule");
const location = rule.querySelector(".site-controllerLocation");
const locationLabel = location.closest(".site-rule-option").querySelector("label");
expect(location.id).not.toBe("");
expect(locationLabel.htmlFor).toBe(location.id);
expect(
rule.querySelector(".site-controllerMarginTop").getAttribute("aria-label")
).toBe("Controller margin top");
expect(
rule.querySelector(".remove-site-rule").getAttribute("aria-label")
).toBe("Remove site rule");
});
it("validates site rule regexes before saving", async () => {
const chrome = await setupOptions();
chrome.storage.sync.set.mockClear();
globalThis.createSiteRule(null);
const rule = document.querySelector(".site-rule");
rule.querySelector(".site-pattern").value = "/(/";
rule.querySelector(".site-pattern").value = "/youtube";
globalThis.save_options();
@@ -85,6 +152,48 @@ describe("options page", () => {
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
});
it("adds and restores duplicate actions with Shift bindings", async () => {
const chrome = await setupOptions();
const selector = document.getElementById("addShortcutSelector");
expect(selector.querySelector('option[value="rewind"]')).not.toBeNull();
selector.value = "rewind";
selector.dispatchEvent(new window.Event("change", { bubbles: true }));
const duplicate = document.querySelector(
'.shortcut-row.customs[data-action="rewind"]'
);
duplicate.querySelector(".customKey").dispatchEvent(
new window.KeyboardEvent("keydown", {
key: "Z",
code: "KeyZ",
shiftKey: true,
bubbles: true
})
);
duplicate.querySelector(".customValue").value = "3";
globalThis.save_options();
expect(
chrome.storage.sync.__state.keyBindings.filter(
(binding) => binding.action === "rewind"
)
).toEqual([
expect.objectContaining({ value: 10, shiftKey: false }),
expect.objectContaining({ value: 3, shiftKey: true })
]);
globalThis.restore_options();
await flushAsyncWork();
expect(
document.querySelectorAll('.shortcut-row[data-action="rewind"]')
).toHaveLength(2);
expect(
document.querySelector(
'.shortcut-row.customs[data-action="rewind"] .customKey'
).value
).toBe("Shift+Z");
});
it("shows a more-menu trigger for collapsed site rules and a collapse trigger when open", async () => {
await setupOptions({ sync: { siteRules: [] } });
@@ -166,6 +275,7 @@ describe("options page", () => {
const chrome = await setupOptions();
document.getElementById("rememberSpeed").checked = true;
document.getElementById("showAmbientLoopControls").checked = true;
document.getElementById("hideWithControlsTimer").value = "20";
document.getElementById("controllerOpacity").value = "0";
document.getElementById("controllerMarginTop").value = "250";
@@ -185,9 +295,14 @@ describe("options page", () => {
globalThis.createSiteRule(null);
const rule = document.querySelector(".site-rule");
rule.querySelector(".site-title").value = "My YouTube rule";
rule.querySelector(".site-pattern").value = "youtube.com";
rule.querySelector(".override-playback").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(".site-controllerOpacity").value = "0";
rule.querySelector(".override-subtitleNudge").checked = true;
@@ -207,35 +322,90 @@ describe("options page", () => {
globalThis.save_options();
expect(chrome.storage.sync.remove).toHaveBeenCalled();
const savedSettings =
chrome.storage.sync.set.mock.calls[
chrome.storage.sync.set.mock.calls.length - 1
][0];
const savedSettings = globalThis.vscExpandStoredSettings(
chrome.storage.sync.__state
);
expect(savedSettings.rememberSpeed).toBe(true);
expect(savedSettings.showAmbientLoopControls).toBe(true);
expect(savedSettings.hideWithControlsTimer).toBe(15);
expect(savedSettings.controllerOpacity).toBe(0);
expect(savedSettings.controllerMarginTop).toBe(200);
expect(savedSettings.controllerMarginBottom).toBe(0);
expect(savedSettings.subtitleNudgeEnabledByDefault).toBe(false);
expect(savedSettings.subtitleNudgeInterval).toBe(10);
expect(savedSettings.subtitleNudgeInterval).toBe(250);
expect(savedSettings.showPopupControlBar).toBe(false);
expect(savedSettings.popupMatchHoverControls).toBe(false);
expect(savedSettings.popupControllerButtons).toEqual(["rewind", "faster"]);
expect(savedSettings.siteRules).toEqual(
expect.arrayContaining([
expect.objectContaining({
title: "My YouTube rule",
pattern: "youtube.com",
rememberSpeed: true,
showAmbientLoopControls: false,
shortcutTargetMode: "all",
preferredSpeed: 2.4,
controllerOpacity: 0,
enableSubtitleNudge: true,
subtitleNudgeEnabledByDefault: false,
subtitleNudgeInterval: 75,
subtitleNudgeInterval: 250,
showPopupControlBar: false,
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
View File
@@ -32,6 +32,7 @@ function bootOptions(options) {
evaluateScript("extension/shared/controller-utils.js");
evaluateScript("extension/shared/key-bindings.js");
evaluateScript("extension/shared/settings-core.js");
evaluateScript("extension/shared/popup-controls.js");
evaluateScript("extension/shared/ui-icons.js");
evaluateScript("extension/options/lucide-client.js");
@@ -49,6 +50,270 @@ describe("options.js", () => {
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 () => {
bootOptions({
syncData: {
@@ -145,32 +410,16 @@ describe("options.js", () => {
window.save_options();
expect(chrome.storage.sync.remove).toHaveBeenCalledWith(
[
"resetSpeed",
"speedStep",
"fastSpeed",
"rewindTime",
"advanceTime",
"resetKeyCode",
"slowerKeyCode",
"fasterKeyCode",
"rewindKeyCode",
"advanceKeyCode",
"fastKeyCode",
"blacklist"
],
expect.any(Function)
const savedSettings = window.vscExpandStoredSettings(
chrome.storage.sync._dump()
);
const savedSettings = chrome.storage.sync.set.mock.calls.at(-1)[0];
expect(savedSettings.rememberSpeed).toBe(true);
expect(savedSettings.hideWithControlsTimer).toBe(15);
expect(savedSettings.controllerLocation).toBe("bottom-left");
expect(savedSettings.controllerMarginTop).toBe(200);
expect(savedSettings.controllerMarginBottom).toBe(0);
expect(savedSettings.popupControllerButtons).toEqual(["advance", "rewind"]);
expect(savedSettings.siteRules).toHaveLength(3);
expect(savedSettings.siteRules).toHaveLength(4);
expect(savedSettings.siteRules).toEqual(
expect.arrayContaining([
expect.objectContaining({
+70 -8
View File
@@ -10,6 +10,7 @@ async function setupPopup(overrides = {}) {
loadHtml("extension/popup/popup.html");
globalThis.chrome = createChromeMock(overrides);
window.chrome = globalThis.chrome;
loadScript("extension/shared/settings-core.js");
loadScript("extension/shared/site-rules.js");
loadScript("extension/shared/popup-controls.js");
loadScript("extension/shared/ui-icons.js");
@@ -34,6 +35,12 @@ describe("popup UI", () => {
expect(
document.querySelectorAll("#popupControlBar button").length
).toBeGreaterThan(0);
expect(document.getElementById("status").getAttribute("role")).toBe(
"status"
);
expect(
document.querySelector("#popupControlBar button").getAttribute("aria-label")
).not.toBe("");
});
it("shows controls when globally disabled but a whitelist site rule matches", async () => {
@@ -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();
chrome.storage.sync.set.mockClear();
chrome.browserAction.setIcon.mockClear();
@@ -83,13 +90,10 @@ describe("popup UI", () => {
expect(document.getElementById("enable").classList.contains("hide")).toBe(
false
);
expect(chrome.browserAction.setIcon).toHaveBeenCalledWith({
path: {
19: "assets/icons/icon19_disabled.png",
38: "assets/icons/icon38_disabled.png",
48: "assets/icons/icon48_disabled.png"
}
});
expect(document.getElementById("status").textContent).toBe(
"Disabled. Open pages update automatically."
);
expect(chrome.browserAction.setIcon).not.toHaveBeenCalled();
});
it("handles refresh responses for unsupported and successful pages", async () => {
@@ -134,4 +138,62 @@ describe("popup UI", () => {
);
expect(chrome.tabs.executeScript).toHaveBeenCalled();
});
it("toggles force last saved speed and applies it to the active page", async () => {
const chrome = await setupPopup({
sync: { lastSpeed: 1.8, forceLastSavedSpeed: false }
});
chrome.tabs.sendMessage.mockClear();
document.getElementById("forceLastSavedSpeed").click();
await flushAsyncWork();
expect(chrome.storage.sync.__state.forceLastSavedSpeed).toBe(true);
expect(
document.getElementById("forceLastSavedSpeed").getAttribute("aria-pressed")
).toBe("true");
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(
1,
{
action: "set_force_last_saved_speed",
enabled: true
},
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
View File
@@ -6,6 +6,7 @@ const {
installCommonWindowMocks,
loadHtmlFile
} = require("./helpers/extension-test-utils");
const manifest = require("../extension/manifest.json");
function bootPopup(options) {
const config = options || {};
@@ -17,6 +18,7 @@ function bootPopup(options) {
manifest: { version: "9.9.9-test" },
syncData: config.syncData,
localData: config.localData,
runtimeSendMessageImpl: config.runtimeSendMessageImpl,
tabsQueryResult: [
config.activeTab || { id: 99, active: true, url: "https://example.com/" }
]
@@ -46,6 +48,7 @@ function bootPopup(options) {
global.chrome = chrome;
window.chrome = chrome;
evaluateScript("extension/shared/settings-core.js");
evaluateScript("extension/shared/site-rules.js");
evaluateScript("extension/shared/popup-controls.js");
evaluateScript("extension/shared/ui-icons.js");
@@ -62,6 +65,10 @@ describe("popup.js", () => {
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 () => {
bootPopup({
syncData: {
@@ -101,10 +108,10 @@ describe("popup.js", () => {
executeScriptImpl: (tabId, details, callback) => {
speedQueryCount += 1;
callback(
speedQueryCount <= 2
speedQueryCount === 1
? [
{ speed: 1.25, preferred: false },
{ speed: 1.5, preferred: true }
{ speed: 1.5, frameToken: "playing-frame", preferred: true }
]
: [{ speed: 1.75, preferred: true }]
);
@@ -132,12 +139,136 @@ describe("popup.js", () => {
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(
99,
{ action: "run_action", actionName: "faster" },
{
action: "run_action",
actionName: "faster",
targetFrameToken: "playing-frame"
},
expect.any(Function)
);
expect(document.querySelector("#popupSpeed").textContent).toBe("1.75");
});
it("keeps all-video popup actions intentionally untargeted", async () => {
const chrome = bootPopup({
syncData: { shortcutTargetMode: "all" },
executeScriptImpl: (tabId, details, callback) => {
callback([{ speed: 1.5, frameToken: "playing-frame", preferred: true }]);
}
});
await flushAsyncWork();
chrome.tabs.sendMessage.mockClear();
document.querySelector("#popupControlBar button").click();
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(
99,
{ action: "run_action", actionName: "rewind" },
expect.any(Function)
);
});
it("copies redacted diagnostics for the active media frame", async () => {
bootPopup({
activeTab: {
id: 12,
active: true,
url: "https://video.example/watch?private_token=secret"
},
executeScriptImpl: (tabId, details, callback) => {
callback([
{
speed: 1.5,
preferred: true,
diagnostics: {
mediaType: "video",
fullscreen: { active: true, element: "div", ownsMedia: true },
controller: { present: true, hidden: false }
}
}
]);
}
});
const writeText = vi.fn(() => Promise.resolve());
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText }
});
await flushAsyncWork();
document.querySelector("#copyDiagnostics").click();
await flushAsyncWork();
expect(writeText).toHaveBeenCalled();
const reportText = writeText.mock.calls.at(-1)[0];
const report = JSON.parse(reportText);
expect(report.page).toEqual({
protocol: "https:",
hostname: "video.example"
});
expect(report.frame.mediaType).toBe("video");
expect(reportText).not.toContain("private_token");
expect(reportText).not.toContain("secret");
expect(document.querySelector("#status").textContent).toContain("copied");
});
it("adds one safe origin rule for the current site", async () => {
const chrome = bootPopup({
activeTab: {
id: 18,
active: true,
url: "https://courses.example:8443/watch/lesson?token=private"
}
});
await flushAsyncWork();
document.querySelector("#addSiteRule").click();
await flushAsyncWork();
document.querySelector("#addSiteRule").click();
await flushAsyncWork();
const rules = window.vscExpandStoredSettings(
chrome.storage.sync._dump()
).siteRules.filter(function(rule) {
return rule.pattern === "https://courses.example:8443";
});
expect(rules).toEqual([
{
title: "courses.example:8443",
pattern: "https://courses.example:8443",
enabled: true
}
]);
expect(window.open).toHaveBeenCalledWith(
"moz-extension://options/options.html"
);
});
it("pauses only the active tab for the browser session", async () => {
let paused = false;
const chrome = bootPopup({
runtimeSendMessageImpl: (message, callback) => {
if (message.action === "set_tab_paused") paused = message.paused === true;
callback({ paused });
}
});
await flushAsyncWork();
document.querySelector("#pauseTab").click();
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith(
{ action: "set_tab_paused", tabId: 99, paused: true },
expect.any(Function)
);
expect(document.querySelector("#pauseTab").textContent).toBe(
"Resume on this tab"
);
expect(document.querySelector("#popupControlBar").style.display).toBe(
"none"
);
expect(document.querySelector("#status").textContent).toContain("paused");
});
it("toggles enablement and closes after a successful refresh", async () => {
const chrome = bootPopup({
syncData: {
@@ -152,17 +283,11 @@ describe("popup.js", () => {
expect(document.querySelector("#disable").classList.contains("hide")).toBe(true);
document.querySelector("#enable").click();
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
{ enabled: true },
expect.any(Function)
);
expect(chrome.browserAction.setIcon).toHaveBeenCalledWith({
path: {
19: "assets/icons/icon19.png",
38: "assets/icons/icon38.png",
48: "assets/icons/icon48.png"
}
});
expect(chrome.storage.sync._dump()).not.toHaveProperty("enabled");
expect(
window.vscExpandStoredSettings(chrome.storage.sync._dump()).enabled
).toBe(true);
expect(chrome.browserAction.setIcon).not.toHaveBeenCalled();
document.querySelector("#refresh").click();
expect(document.querySelector("#status").textContent).toContain("Closing");
+398
View File
@@ -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
View File
@@ -22,6 +22,60 @@ describe("shared helpers", () => {
])
).toBeNull();
expect(siteRules.isSiteRuleDisabled({ enabled: false })).toBe(true);
expect(siteRules.compileSiteRulePattern("/youtube")).toBeNull();
expect(
siteRules.siteRuleMatchesUrl(
{ pattern: "/youtube" },
"https://youtube.com/watch"
)
).toBe(false);
});
it("matches plain and regex rules against safely decoded URL text", () => {
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)", () => {
@@ -87,6 +141,15 @@ describe("shared helpers", () => {
).toEqual(["advance"]);
});
it("keeps the selected frame token with the displayed popup speed", () => {
expect(
popupControls.pickBestFrameSpeedResult([
{ speed: 1.25, frameToken: "background", preferred: false },
{ speed: 1.75, frameToken: "playing", preferred: true }
])
).toEqual({ speed: 1.75, frameToken: "playing" });
});
it("normalizes controller locations and margins", () => {
expect(controllerUtils.normalizeControllerLocation("top-right")).toBe(
"top-right"
@@ -111,6 +174,14 @@ describe("shared helpers", () => {
"NumpadAdd"
);
expect(keyBindingUtils.getLegacyKeyCode({ key: 65 })).toBe(65);
expect(keyBindingUtils.getActionValueError("fast", 0.1)).toBeNull();
expect(keyBindingUtils.getActionValueError("fast", 16)).toBeNull();
expect(keyBindingUtils.getActionValueError("fast", 0.099)).toContain(
"between 0.1 and 16"
);
expect(keyBindingUtils.getActionValueError("fast", 16.001)).toContain(
"between 0.1 and 16"
);
});
it("builds and parses import/export payloads", () => {
@@ -173,12 +244,36 @@ describe("shared helpers", () => {
expect(importExportUtils.isRecognizedRawSettingsObject({ wat: true })).toBe(
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(
importExportUtils.filterLocalSettingsForExport({
customButtonIcons: { faster: { slug: "zap" } },
lucideTagsCacheV1: { "a-arrow-down": ["letter"] },
lucideTagsCacheV1At: 123
lucideTagsCacheV1At: 123,
rememberedSpeeds: { "https://example.com/video.mp4": 1.75 },
futureRuntimeData: true
})
).toEqual({
customButtonIcons: { faster: { slug: "zap" } }
+528
View File
@@ -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);
});
});