v5.3.6.0-beta.1

This commit is contained in:
2026-07-16 01:59:48 -04:00
30 changed files with 6293 additions and 881 deletions
+9 -1
View File
@@ -8,7 +8,15 @@
if (!v) return null;
return {
speed: v.playbackRate,
preferred: !v.paused
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;
+18
View File
@@ -12,6 +12,24 @@
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;
}
+2145 -422
View File
@@ -5,52 +5,154 @@ 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;
}
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),
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
siteRuleBase: null,
runtimeSettingsHydrated: false,
pendingMediaCandidates: [],
settingsReloadRetries: 0,
lastPointerPosition: null,
lastInteractedMedia: null
};
var MIN_SPEED = 0.0625;
@@ -59,8 +161,11 @@ 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)
@@ -386,6 +491,7 @@ function captureSiteRuleBase() {
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,
@@ -413,6 +519,7 @@ function resetSettingsFromSiteRuleBase() {
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 +628,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)
};
@@ -536,7 +647,11 @@ function normalizeStoredBinding(binding, fallbackCode) {
action: binding.action,
code: normalizedCode,
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 +669,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 +717,23 @@ 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)) {
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 +904,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 +1162,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 +1202,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 +1311,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
};
}
@@ -940,6 +1364,18 @@ function isMediaElement(node) {
);
}
function isAmbientLoopMedia(node) {
return Boolean(
node &&
node.nodeName === "VIDEO" &&
node.autoplay === true &&
(node.muted === true || node.defaultMuted === true) &&
node.loop === true &&
node.playsInline === true &&
node.controls !== true
);
}
function hasUsableMediaSource(node) {
if (!isMediaElement(node) || !node.isConnected) return false;
if (node.currentSrc || node.src || node.srcObject) return true;
@@ -965,8 +1401,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 (isAmbientLoopMedia(node)) {
if (node.vsc) removeController(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 (!siteRuleUtils.isSpeederActiveForSite(tc.settings.enabled, tc.activeSiteRule)) {
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,24 +1483,48 @@ 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;
@@ -1003,7 +1535,22 @@ function ensureControllerForMediaChild(node) {
}
function removeController(node) {
if (node && node.vsc) node.vsc.remove();
if (!node || !node.vsc) return;
var controller = node.vsc;
if (typeof controller.remove === "function") {
try {
controller.remove();
return;
} catch (error) {
log(`Partial controller cleanup failed: ${error.message}`, 3);
}
}
if (controller.div && typeof controller.div.remove === "function") {
controller.div.remove();
}
var index = tc.mediaElements.indexOf(node);
if (index !== -1) tc.mediaElements.splice(index, 1);
delete node.vsc;
}
function scanNodeForMedia(node, parent, added) {
@@ -1028,10 +1575,17 @@ function scanNodeForMedia(node, parent, added) {
if (isMediaElement(node)) {
if (added) ensureController(node, parent);
else removeController(node);
} else if (added) {
} 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.
ensureControllerForMediaChild(node);
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
@@ -1055,21 +1609,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) {
@@ -1099,9 +1638,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) {
@@ -1119,12 +1702,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);
@@ -1156,6 +1743,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;
@@ -1173,117 +1863,378 @@ 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.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 || {});
// 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.
@@ -1302,7 +2253,12 @@ chrome.storage.sync.get(tc.settings, function(storage) {
var videoGs = getPrimaryVideoElement();
if (!videoGs) return false;
sendResponse({
speed: videoGs.playbackRate
speed: videoGs.playbackRate,
forceLastSavedSpeed: tc.settings.forceLastSavedSpeed === true,
forceLastSavedSpeedControlledBySiteRule: Boolean(
tc.activeSiteRule &&
tc.activeSiteRule.forceLastSavedSpeed !== undefined
)
});
return false;
}
@@ -1311,26 +2267,37 @@ chrome.storage.sync.get(tc.settings, function(storage) {
return false;
}
if (request.action === "set_force_last_saved_speed") {
tc.settings.forceLastSavedSpeed = Boolean(request.enabled);
if (isValidSpeed(Number(request.speed))) {
tc.settings.lastSpeed = Number(request.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 (!forceVideo) return false;
if (tc.settings.forceLastSavedSpeed) {
tc.mediaElements.forEach(function(video) {
if (!video || !video.vsc) return;
setSpeed(video, tc.settings.lastSpeed, false, true);
setSpeed(video, tc.settings.lastSpeed, false, false);
video.vsc.targetSpeedOrigin = "policy";
extendSpeedRestoreWindow(video);
});
}
sendResponse({
enabled: tc.settings.forceLastSavedSpeed,
speed: forceVideo.playbackRate
speed: forceVideo ? forceVideo.playbackRate : null
});
return false;
}
if (request.action === "run_action") {
if (
!siteRuleUtils.isSpeederActiveForSite(
tc.settings.enabled,
tc.activeSiteRule
)
) {
return false;
}
var value = request.value;
if (value === undefined || value === null) {
value = getKeyBindings(request.actionName, "value");
@@ -1350,64 +2317,175 @@ 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);
});
});
});
}
loadInitialRuntimeSettings(0);
function getKeyBindings(action, what = "value") {
try {
@@ -1418,8 +2496,12 @@ 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) {
@@ -1478,22 +2560,143 @@ function createsControllerStackingContext(element) {
);
}
function getControllerMount(video, boundary) {
if (!video || !video.parentElement) return null;
function getSharedOverlayMount(mount, videoRect) {
if (!mount || !mount.parentElement || !videoRect) return null;
if (videoRect.width <= 0 || videoRect.height <= 0) return null;
var mountBoundary =
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 &&
typeof boundary.contains === "function" &&
boundary.contains(video)
? boundary
: null;
(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.
@@ -1556,24 +2759,56 @@ function getFullscreenElement(doc) {
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();
var mountRect = mount.getBoundingClientRect();
if (videoRect.width <= 0 || videoRect.height <= 0) return;
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 = mount.offsetWidth || mountRect.width || 1;
var mountHeight = mount.offsetHeight || mountRect.height || 1;
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 -
(mount.clientLeft || 0) +
(mount.scrollLeft || 0);
(geometryMount.clientLeft || 0) +
(geometryMount.scrollLeft || 0);
var top =
(videoRect.top - mountRect.top) / scaleY -
(mount.clientTop || 0) +
(mount.scrollTop || 0);
(geometryMount.clientTop || 0) +
(geometryMount.scrollTop || 0);
wrapper.style.setProperty("left", left + "px", "important");
wrapper.style.setProperty("top", top + "px", "important");
@@ -1589,57 +2824,127 @@ function positionControllerHost(wrapper, video, mount) {
);
}
function configureControllerAutoHide(videoController, wrapper, force) {
if (!videoController || !wrapper) return;
var signature = JSON.stringify([
tc.settings.hideWithControls === true,
Number(tc.settings.hideWithControlsTimer) || 2
]);
if (force !== true && videoController.autoHideSettingsSignature === signature) {
return;
}
if (videoController.youTubeAutoHideObserver) {
videoController.youTubeAutoHideObserver.disconnect();
videoController.youTubeAutoHideObserver = null;
}
if (videoController.youTubeAutoHideCleanup) {
videoController.youTubeAutoHideCleanup();
videoController.youTubeAutoHideCleanup = null;
}
if (videoController.genericAutoHideCleanup) {
videoController.genericAutoHideCleanup();
videoController.genericAutoHideCleanup = null;
}
wrapper.classList.remove("ytp-autohide", "vsc-idle-hidden");
videoController.autoHideSettingsSignature = signature;
if (!tc.settings.hideWithControls) return;
if (isOnYouTube()) {
videoController.setupYouTubeAutoHide(wrapper);
} else {
videoController.setupGenericAutoHide(wrapper);
}
}
function setupControllerHostTracking(videoController, wrapper, mount) {
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(mount).position === "static") {
mount.dataset.vscPositionOwner = "true";
mount.dataset.vscOriginalPosition = mount.style.getPropertyValue("position");
mount.dataset.vscOriginalPositionPriority =
mount.style.getPropertyPriority("position");
mount.style.setProperty("position", "relative");
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(mount)) {
mount.dataset.vscIsolationOwner = "true";
mount.dataset.vscOriginalIsolation =
mount.style.getPropertyValue("isolation");
mount.dataset.vscOriginalIsolationPriority =
mount.style.getPropertyPriority("isolation");
mount.style.setProperty("isolation", "isolate");
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(mount);
resizeObserver.observe(geometryMount);
}
win.addEventListener("resize", schedule, { passive: true });
doc.addEventListener("fullscreenchange", schedule, { passive: true });
mount.addEventListener("scroll", 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);
mount.removeEventListener("scroll", schedule);
geometryMount.removeEventListener("scroll", schedule);
var hasOtherController = false;
try {
hasOtherController = Array.from(
@@ -1649,42 +2954,58 @@ function setupControllerHostTracking(videoController, wrapper, mount) {
});
} catch (e) {}
if (
mount.dataset.vscPositionOwner === "true" &&
geometryMount.dataset.vscPositionOwner === "true" &&
!hasOtherController &&
(forceRestore === true || !wrapper.isConnected)
) {
if (mount.dataset.vscOriginalPosition) {
mount.style.setProperty(
"position",
mount.dataset.vscOriginalPosition,
mount.dataset.vscOriginalPositionPriority || ""
);
} else {
mount.style.removeProperty("position");
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 mount.dataset.vscPositionOwner;
delete mount.dataset.vscOriginalPosition;
delete mount.dataset.vscOriginalPositionPriority;
delete geometryMount.dataset.vscPositionOwner;
delete geometryMount.dataset.vscOriginalPosition;
delete geometryMount.dataset.vscOriginalPositionPriority;
delete geometryMount.dataset.vscAppliedPosition;
}
if (
mount.dataset.vscIsolationOwner === "true" &&
geometryMount.dataset.vscIsolationOwner === "true" &&
!hasOtherController &&
(forceRestore === true || !wrapper.isConnected)
) {
if (mount.dataset.vscOriginalIsolation) {
mount.style.setProperty(
"isolation",
mount.dataset.vscOriginalIsolation,
mount.dataset.vscOriginalIsolationPriority || ""
);
} else {
mount.style.removeProperty("isolation");
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 mount.dataset.vscIsolationOwner;
delete mount.dataset.vscOriginalIsolation;
delete mount.dataset.vscOriginalIsolationPriority;
delete geometryMount.dataset.vscIsolationOwner;
delete geometryMount.dataset.vscOriginalIsolation;
delete geometryMount.dataset.vscOriginalIsolationPriority;
delete geometryMount.dataset.vscAppliedIsolation;
}
};
configureControllerAutoHide(videoController, wrapper, true);
}
function remountControllerHost(videoController, mount) {
@@ -1692,22 +3013,96 @@ function remountControllerHost(videoController, mount) {
!videoController ||
!videoController.div ||
!mount ||
!mount.isConnected ||
mount === videoController.controllerHostMount
!(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.insertBefore(videoController.div, mount.firstChild);
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;
@@ -1718,11 +3113,16 @@ function syncControllerFullscreenMount(videoController) {
var fullscreenElement = getFullscreenElement(doc);
var targetMount = videoController.normalControllerMount;
if (fullscreenElement === video) {
if (enableDirectFullscreenPopover(videoController)) return true;
} else {
disableDirectFullscreenPopover(videoController);
}
if (
fullscreenElement &&
fullscreenElement !== video &&
typeof fullscreenElement.contains === "function" &&
fullscreenElement.contains(video)
(fullscreenElement === video ||
isComposedDescendant(video, fullscreenElement))
) {
targetMount = getControllerMount(video, fullscreenElement);
} else if (!fullscreenElement && (!targetMount || !targetMount.isConnected)) {
@@ -1745,6 +3145,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;
@@ -1754,19 +3157,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();
@@ -1778,6 +3190,33 @@ 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" ||
@@ -1835,6 +3274,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))
@@ -1901,6 +3348,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;
@@ -1919,7 +3379,10 @@ function defineVideoController() {
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);
@@ -1986,17 +3449,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
@@ -2039,18 +3520,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);
@@ -2161,7 +3658,9 @@ function defineVideoController() {
// 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 });
@@ -2199,7 +3698,7 @@ function defineVideoController() {
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.
@@ -2318,21 +3817,17 @@ function defineVideoController() {
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;
const parentEl =
this.parent || this.video.parentElement || this.video.parentNode;
var mountEl = getControllerMount(this.video) || parentEl;
log(`Inserting controller: parentEl=${!!parentEl}, parentNode=${!!parentEl?.parentNode}, hostname=${location.hostname}`, 4);
log(`Inserting controller: parentEl=${!!parentEl}, hostname=${location.hostname}`, 4);
if (!parentEl || !parentEl.parentNode) {
if (
!mountEl ||
!(mountEl.isConnected ||
(isShadowRootNode(mountEl) && mountEl.host.isConnected))
) {
log("No suitable parent found, appending to body", 4);
doc.body.appendChild(wrapper);
this.normalControllerMount = doc.body;
@@ -2366,10 +3861,12 @@ function defineVideoController() {
case location.hostname === "www.youtube.com":
case location.hostname === "m.youtube.com":
case location.hostname === "music.youtube.com":
mountEl = parentEl.closest(".html5-video-player") || mountEl;
mountEl =
(parentEl.closest && parentEl.closest(".html5-video-player")) ||
mountEl;
break;
}
mountEl.insertBefore(wrapper, mountEl.firstChild);
mountEl.appendChild(wrapper);
this.normalControllerMount = mountEl;
setupControllerHostTracking(this, wrapper, mountEl);
log("Controller successfully inserted into DOM", 4);
@@ -2386,18 +3883,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;
@@ -2406,7 +3916,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
@@ -2421,6 +3931,7 @@ function applySiteRuleOverrides() {
"controllerOpacity",
"controllerMarginTop",
"controllerMarginBottom",
"shortcutTargetMode",
"enableSubtitleNudge",
"subtitleNudgeEnabledByDefault",
"subtitleNudgeInterval"
@@ -2462,7 +3973,16 @@ function applySiteRuleOverrides() {
);
}
return false;
return finishSiteRuleApplication(false);
}
function removeIneligibleMediaControllers() {
if (tc.settings.audioBoolean) return;
tc.mediaElements.slice().forEach(function(media) {
if (media && media.nodeName === "AUDIO") {
removeController(media);
}
});
}
/** Apply current tc.settings controller layout/opacity to every attached controller (after site rules). */
@@ -2474,6 +3994,7 @@ function refreshAllControllerGeometry() {
if (controllerEl) {
controllerEl.style.opacity = String(tc.settings.controllerOpacity);
}
configureControllerAutoHide(video.vsc, video.vsc.div, false);
});
}
@@ -2481,9 +4002,32 @@ function refreshAllControllerGeometry() {
function reapplySiteRulesAndControllerGeometry() {
applySiteRuleOverrides();
if (!siteRuleUtils.isSpeederActiveForSite(tc.settings.enabled, tc.activeSiteRule)) {
return;
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) {
@@ -2493,8 +4037,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())
);
@@ -2504,21 +4053,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)
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();
@@ -2533,21 +4091,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.0625)
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;
}
@@ -2562,7 +4135,7 @@ function setupListener(root) {
return;
}
updateSpeedFromEvent(video);
updateSpeedFromEvent(video, { shouldPersist: false });
}
},
true
@@ -2631,11 +4204,9 @@ function inIframe() {
}
function attachKeydownListeners(doc) {
// Content scripts already run in every frame. Keeping each listener scoped
// to its own document avoids duplicate shortcuts and stale iframe ownership.
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;
@@ -2662,6 +4233,15 @@ function attachKeydownListeners(doc) {
return;
}
if (
!siteRuleUtils.isSpeederActiveForSite(
tc.settings.enabled,
tc.activeSiteRule
)
) {
return;
}
if (!tc.mediaElements.length) return;
var item = tc.settings.keyBindings.find(function(binding) {
@@ -2671,10 +4251,13 @@ 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
@@ -2683,6 +4266,25 @@ function attachKeydownListeners(doc) {
});
}
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;
@@ -2697,9 +4299,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;
@@ -2709,6 +4313,7 @@ function attachMutationObserver(root) {
if (node.nodeType !== Node.ELEMENT_NODE) return;
scanNodeForMedia(node, node.parentNode || mutation.target, false);
});
return;
}
@@ -2745,6 +4350,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 }
);
@@ -2782,54 +4407,40 @@ 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() {
window.vscLastObservedHref = location.href;
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;
}
@@ -2837,46 +4448,49 @@ 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 = siteRuleUtils.isSpeederActiveForSite(
tc.settings.enabled,
tc.activeSiteRule
);
// 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(
@@ -2906,12 +4520,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
@@ -2920,25 +4542,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();
@@ -2949,33 +4581,109 @@ 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;
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;
@@ -3021,7 +4729,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;
@@ -3057,8 +4769,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;
@@ -3250,13 +4966,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],
+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;
};
});
})();
+5 -1
View File
@@ -1,7 +1,7 @@
{
"name": "Speeder",
"short_name": "Speeder",
"version": "5.3.5.0",
"version": "5.3.6.0",
"manifest_version": 2,
"description": "Speed up, slow down, advance and rewind HTML5 audio/video with shortcuts (New and improved version of \"Video Speed Controller\")",
"homepage_url": "https://github.com/SoPat712/speeder",
@@ -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"
]
}
+178 -50
View File
@@ -7,11 +7,87 @@ 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) &&
expanded.lastSpeed >= 0.0625 &&
expanded.lastSpeed <= 16
) {
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 +109,70 @@ 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) &&
expanded.lastSpeed >= 0.0625 &&
expanded.lastSpeed <= 16
) {
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 +195,53 @@ 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(
"Error: Failed to save local extension data - " +
localError.message,
true
);
return;
}
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);
}
+19
View File
@@ -905,8 +905,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;
}
@@ -1173,6 +1184,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 +1213,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) {
+81 -20
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>
@@ -289,9 +290,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 +307,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 +427,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>
@@ -607,7 +622,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 +643,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 +723,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
@@ -767,10 +813,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 +830,21 @@
>
<input type="checkbox" class="site-forceLastSavedSpeed" />
</div>
<div class="site-rule-option site-rule-option-field">
<label
>Preferred speed (0.0625&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
@@ -848,12 +909,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>
+313 -110
View File
@@ -162,58 +162,72 @@ 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;
function setOptionsSyncSettingsLoaded(loaded) {
optionsSyncSettingsLoaded = loaded === true;
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);
});
}
],
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
};
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 +275,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 = [
@@ -406,6 +417,16 @@ 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(16, Math.max(0.0625, parsed));
}
function updateSiteRuleToggleIcon(toggleButton, action) {
if (!toggleButton) return;
var iconEl = toggleButton.querySelector(".site-rule-toggle-icon");
@@ -726,13 +747,31 @@ 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,
disabled: binding.disabled === true,
value: customActionsNoValues.includes(action)
? 0
: Number(valueInput.value),
value: bindingValue,
force: false,
predefined: predefined
});
@@ -768,10 +807,15 @@ function validate() {
}
function save_options() {
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)
@@ -793,6 +837,10 @@ function save_options() {
settings.audioBoolean = document.getElementById("audioBoolean").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 +877,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 +898,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 +935,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 +956,29 @@ 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;
} else {
delete rule.rememberSpeed;
delete rule.forceLastSavedSpeed;
delete rule.preferredSpeed;
delete rule.audioBoolean;
}
if (ruleEl.querySelector(".override-opacity").checked) {
@@ -904,6 +987,8 @@ function save_options() {
ruleEl.querySelector(".site-controllerOpacity").value,
settings.controllerOpacity
);
} else {
delete rule.controllerOpacity;
}
if (ruleEl.querySelector(".override-subtitleNudge").checked) {
@@ -918,10 +1003,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 +1018,8 @@ function save_options() {
if (activeZone) {
rule.controllerButtons = readControlBarOrder(activeZone);
}
} else {
delete rule.controllerButtons;
}
if (ruleEl.querySelector(".override-popup-controlbar").checked) {
@@ -940,6 +1031,9 @@ function save_options() {
readControlBarOrder(popupActiveZone)
);
}
} else {
delete rule.showPopupControlBar;
delete rule.popupControllerButtons;
}
if (ruleEl.querySelector(".override-shortcuts").checked) {
@@ -971,37 +1065,56 @@ 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,
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 = "Options saved";
setTimeout(function () {
status.textContent = "";
}, 1000);
});
}
@@ -1084,9 +1197,12 @@ 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 +1233,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,11 +1277,16 @@ function createSiteRule(rule) {
rule &&
(rule.rememberSpeed !== undefined ||
rule.forceLastSavedSpeed !== undefined ||
rule.preferredSpeed !== undefined ||
rule.audioBoolean !== 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);
applySiteRuleOverrideState(ruleEl, "override-playback", "site-playback-container");
@@ -1621,11 +1753,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
: {};
@@ -1635,13 +1782,11 @@ function restore_options() {
document.getElementById("audioBoolean").checked = storage.audioBoolean;
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;
@@ -1699,11 +1844,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 +1870,87 @@ 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());
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);
}
);
}
);
});
});
}
+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>
+169 -58
View File
@@ -25,17 +25,47 @@ document.addEventListener("DOMContentLoaded", function () {
var defaultButtons = ["rewind", "slower", "faster", "advance", "display"];
var popupExcludedButtonIds = new Set(["settings"]);
var storageDefaults = {
enabled: true,
lastSpeed: 1.0,
forceLastSavedSpeed: false,
showPopupControlBar: true,
controllerButtons: defaultButtons,
popupMatchHoverControls: true,
popupControllerButtons: defaultButtons,
siteRules: []
};
var renderToken = 0;
var forceLastSavedSpeedControlledBySiteRule = null;
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");
@@ -46,6 +76,13 @@ document.addEventListener("DOMContentLoaded", function () {
: "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);
}
@@ -131,9 +168,10 @@ document.addEventListener("DOMContentLoaded", function () {
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;
@@ -144,15 +182,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);
});
}
}
@@ -269,39 +310,69 @@ document.addEventListener("DOMContentLoaded", function () {
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";
chrome.storage.sync.get({ lastSpeed: 1.0 }, function (storage) {
chrome.storage.sync.set({ forceLastSavedSpeed: enabled }, function () {
updateForceButton(enabled);
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,
speed: Number(storage.lastSpeed) || 1.0
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(
enabled ? "Saved speed is now forced." : "Speed forcing is off."
effectiveEnabled
? "Saved speed is now forced."
: "Speed forcing is off."
);
} else {
setStatusMessage(
enabled
effectiveEnabled
? "Force enabled. No video found on this page."
: "Speed forcing is off."
);
}
}
);
});
});
}
);
});
}
function renderForActiveTab() {
var currentRenderToken = ++renderToken;
forceLastSavedSpeedControlledBySiteRule = null;
setForceButtonLoading(true);
chrome.storage.local.get(["customButtonIcons"], function (loc) {
if (currentRenderToken !== renderToken) return;
@@ -310,46 +381,75 @@ 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 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;
forceLastSavedSpeedControlledBySiteRule = Boolean(
siteRule && siteRule.forceLastSavedSpeed !== undefined
);
var effectiveForceLastSavedSpeed =
forceLastSavedSpeedControlledBySiteRule
? siteRule.forceLastSavedSpeed === true
: storage.forceLastSavedSpeed === true;
if (siteRule && siteRule.showPopupControlBar !== undefined) {
showBar = siteRule.showPopupControlBar;
}
if (siteRule && siteRule.showPopupControlBar !== undefined) {
showBar = siteRule.showPopupControlBar;
}
toggleEnabledUI(storage.enabled !== false);
updateForceButton(storage.forceLastSavedSpeed === true);
buildControlBar(
resolvePopupButtons(storage, siteRule),
customIconsMap
);
setControlBarVisible(siteAvailable && showBar);
toggleEnabledUI(storage.enabled !== false);
updateForceButton(effectiveForceLastSavedSpeed);
buildControlBar(
resolvePopupButtons(storage, siteRule),
customIconsMap
);
setControlBarVisible(siteAvailable && showBar);
if (siteDisabled) {
setStatusMessage("Speeder is disabled for this site.");
updateSpeedDisplay(1);
return;
}
if (siteDisabled) {
setForceButtonLoading(false);
setStatusMessage("Speeder is disabled for this site.");
updateSpeedDisplay(1);
return;
}
clearStatusMessage();
if (siteAvailable) {
querySpeed();
} else {
updateSpeedDisplay(1);
}
});
clearStatusMessage();
if (siteAvailable) {
querySpeed(function(frameContext) {
if (currentRenderToken !== renderToken) return;
if (
frameContext &&
typeof frameContext.forceLastSavedSpeed === "boolean"
) {
effectiveForceLastSavedSpeed =
frameContext.forceLastSavedSpeed;
}
if (
frameContext &&
typeof frameContext.forceLastSavedSpeedControlledBySiteRule ===
"boolean"
) {
forceLastSavedSpeedControlledBySiteRule =
frameContext.forceLastSavedSpeedControlledBySiteRule;
}
updateForceButton(effectiveForceLastSavedSpeed);
setForceButtonLoading(false);
});
} else {
updateSpeedDisplay(1);
setForceButtonLoading(false);
}
});
});
});
}
@@ -378,7 +478,9 @@ document.addEventListener("DOMContentLoaded", function () {
changes.controllerButtons ||
changes.popupMatchHoverControls ||
changes.popupControllerButtons ||
changes.siteRules
changes.siteRules ||
changes.siteRulesMeta ||
changes.siteRulesFormat
) {
renderForActiveTab();
}
@@ -387,10 +489,19 @@ 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) {
+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
};
+52 -1
View File
@@ -112,11 +112,62 @@
: 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 > 16)
) {
return "must be greater than 0 and no more than 16";
}
if (
action === "fast" &&
(numericValue < 0.0625 || numericValue > 16)
) {
return "must be between 0.0625 and 16";
}
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 {
getActionValueError: getActionValueError,
getLegacyKeyCode: getLegacyKeyCode,
inferBindingCode: inferBindingCode,
legacyBindingKeyToCode: legacyBindingKeyToCode,
legacyKeyCodeToCode: legacyKeyCodeToCode,
normalizeBindingKey: normalizeBindingKey
normalizeBindingKey: normalizeBindingKey,
sanitizeActionValue: sanitizeActionValue
};
});
+16 -2
View File
@@ -66,12 +66,26 @@
function pickBestFrameSpeedResult(results) {
if (!results || !results.length) return null;
function normalizeResult(result) {
var normalized = { speed: result.speed };
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;
+565 -132
View File
@@ -1,27 +1,24 @@
(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 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",
@@ -34,7 +31,10 @@
"controllerLocation",
"controllerOpacity",
"controllerMarginTop",
"controllerMarginRight",
"controllerMarginBottom",
"controllerMarginLeft",
"shortcutTargetMode",
"keyBindings",
"siteRules",
"siteRulesMeta",
@@ -48,14 +48,14 @@
"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,
startHidden: false,
@@ -70,12 +70,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 +82,7 @@
},
{
action: "move",
key: "P",
keyCode: 80,
code: null,
code: "KeyP",
disabled: false,
value: 0,
force: false,
@@ -93,9 +90,7 @@
},
{
action: "slower",
key: "S",
keyCode: 83,
code: null,
code: "KeyS",
disabled: false,
value: 0.1,
force: false,
@@ -103,9 +98,7 @@
},
{
action: "faster",
key: "D",
keyCode: 68,
code: null,
code: "KeyD",
disabled: false,
value: 0.1,
force: false,
@@ -113,9 +106,7 @@
},
{
action: "rewind",
key: "Z",
keyCode: 90,
code: null,
code: "KeyZ",
disabled: false,
value: 10,
force: false,
@@ -123,9 +114,7 @@
},
{
action: "advance",
key: "X",
keyCode: 88,
code: null,
code: "KeyX",
disabled: false,
value: 10,
force: false,
@@ -133,9 +122,7 @@
},
{
action: "reset",
key: "R",
keyCode: 82,
code: null,
code: "KeyR",
disabled: false,
value: 0,
force: false,
@@ -143,9 +130,7 @@
},
{
action: "fast",
key: "G",
keyCode: 71,
code: null,
code: "KeyG",
disabled: false,
value: 1.8,
force: false,
@@ -153,9 +138,7 @@
},
{
action: "toggleSubtitleNudge",
key: "N",
keyCode: 78,
code: null,
code: "KeyN",
disabled: false,
value: 0,
force: false,
@@ -164,17 +147,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 +174,7 @@
popupControllerButtons: DEFAULT_BUTTONS.slice(),
enableSubtitleNudge: false,
subtitleNudgeEnabledByDefault: true,
subtitleNudgeInterval: 50,
subtitleNudgeInterval: 250,
subtitleNudgeAmount: 0.001
};
@@ -305,7 +296,83 @@
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",
"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, 0.0625, 16, 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 +383,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 +468,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 +476,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 +529,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 +545,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 +567,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 +593,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 +674,291 @@
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 > 16;
} else if (action === "fast") {
invalid = invalid || value < 0.0625 || value > 16;
} 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",
"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,
0.0625,
16,
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;
}
@@ -631,6 +1045,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 +1056,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);
+241 -14
View File
@@ -14,6 +14,183 @@
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;
@@ -25,38 +202,85 @@
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 +306,9 @@
escapeStringRegExp: escapeStringRegExp,
isSiteRuleDisabled: isSiteRuleDisabled,
isSpeederActiveForSite: isSpeederActiveForSite,
matchSiteRule: matchSiteRule
matchSiteRule: matchSiteRule,
plainSitePatternMatchesUrl: plainSitePatternMatchesUrl,
safelyDecodeUrlText: safelyDecodeUrlText,
siteRuleMatchesUrl: siteRuleMatchesUrl
};
});
+2 -2
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"
});
+3 -2
View File
@@ -44,9 +44,10 @@ function applyJSDOMWindow(win) {
win.close = vi.fn();
}
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"
});
+58 -11
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,31 @@ 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";
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(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 chrome = await setupImportExport({
sync: { rememberSpeed: true },
sync: { rememberSpeed: true, shortcutTargetMode: "all" },
local: { customButtonIcons: { faster: { slug: "rocket" } } }
});
const OriginalBlob = window.Blob;
@@ -67,7 +88,16 @@ describe("import/export flows", () => {
expect(JSON.parse(blobText)).toEqual({
version: "1.1",
exportDate: "2026-04-04T12:09:10.000Z",
settings: { rememberSpeed: true },
settings: {
rememberSpeed: true,
shortcutTargetMode: "all",
siteRulesFormat: "defaults-diff-v2",
siteRules: [
expect.objectContaining({ title: "YouTube videos" }),
expect.objectContaining({ title: "YouTube Shorts" }),
expect.objectContaining({ title: "YouTube Shorts (mobile)" })
]
},
localSettings: { customButtonIcons: { faster: { slug: "rocket" } } }
});
expect(document.getElementById("status").textContent).toBe(
@@ -172,7 +202,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 +251,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 +266,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 +325,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)
+61 -1
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();
@@ -191,7 +192,7 @@ 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)
@@ -237,4 +238,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>" }
});
});
});
+900
View File
@@ -0,0 +1,900 @@
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("skips GIF-like ambient loops but accepts them if they become real players", 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();
video.loop = false;
video.controls = true;
expect(window.ensureController(video, mount)).toBeTruthy();
expect(video.vsc.div.isConnected).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
};
window.setupControllerHostTracking(controller, wrapper, normalMount);
Object.defineProperty(document, "fullscreenElement", {
configurable: true,
value: fullscreenPlayer
});
window.syncControllerFullscreenMount(controller);
expect(fullscreenPlayer.contains(wrapper)).toBe(true);
Object.defineProperty(document, "fullscreenElement", {
configurable: true,
value: null
});
window.syncControllerFullscreenMount(controller);
expect(wrapper.parentElement).toBe(normalMount);
wrapper.remove();
controller.controllerHostCleanup();
});
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("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();
});
});
+69
View File
@@ -139,6 +139,15 @@ 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 () => {
@@ -316,4 +325,64 @@ describe("inject.js helper logic", () => {
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);
});
});
+3 -1
View File
@@ -250,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();
+61 -7
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");
@@ -29,6 +30,7 @@ describe("options page", () => {
sync: {
rememberSpeed: true,
enabled: false,
shortcutTargetMode: "all",
subtitleNudgeEnabledByDefault: false,
popupMatchHoverControls: false,
popupControllerButtons: ["rewind", "settings", "advance", "advance"],
@@ -38,8 +40,11 @@ describe("options page", () => {
],
siteRules: [
{
title: "YouTube testing",
pattern: "youtube.com",
enabled: true,
shortcutTargetMode: "all",
preferredSpeed: 2.4,
subtitleNudgeEnabledByDefault: false,
showPopupControlBar: false,
popupControllerButtons: ["advance", "settings", "advance"]
@@ -51,6 +56,7 @@ describe("options page", () => {
expect(document.getElementById("app-version").textContent).toBe("5.1.7.0");
expect(document.getElementById("rememberSpeed").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 +69,15 @@ 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-shortcutTargetMode").value
).toBe("all");
expect(document.querySelector(".site-rule .site-title").value).toBe(
"YouTube testing"
);
expect(
document.querySelector(".site-rule .site-subtitleNudgeEnabledByDefault")
.checked
@@ -185,9 +200,13 @@ 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-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,11 +226,9 @@ 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.hideWithControlsTimer).toBe(15);
@@ -219,23 +236,60 @@ describe("options page", () => {
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,
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");
});
});
+215 -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,217 @@ 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("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 +357,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({
+37 -2
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");
@@ -152,10 +153,44 @@ describe("popup UI", () => {
1,
{
action: "set_force_last_saved_speed",
enabled: true,
speed: 1.8
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."
);
});
});
+10 -4
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 || {};
@@ -46,6 +47,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 +64,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: {
@@ -152,10 +158,10 @@ 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.storage.sync._dump()).not.toHaveProperty("enabled");
expect(
window.vscExpandStoredSettings(chrome.storage.sync._dump()).enabled
).toBe(true);
expect(chrome.browserAction.setIcon).toHaveBeenCalledWith({
path: {
19: "assets/icons/icon19.png",
+372
View File
@@ -0,0 +1,372 @@
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.subtitleNudgeInterval).toBe(250);
expect(settings.siteRules[0].enableSubtitleNudge).toBeUndefined();
});
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: 50
}
]
});
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);
});
});
+72 -1
View File
@@ -24,6 +24,53 @@ describe("shared helpers", () => {
expect(siteRules.isSiteRuleDisabled({ enabled: false })).toBe(true);
});
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)", () => {
const allowSite = { pattern: "good.test", enabled: true };
const blockSite = { pattern: "bad.test", enabled: false };
@@ -173,12 +220,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" } }
+501
View File
@@ -0,0 +1,501 @@
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("does not seed a brand-new Short from unrelated global lastSpeed", 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)).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);
});
});