mirror of
https://github.com/SoPat712/Speeder.git
synced 2026-08-19 11:52:31 -04:00
v5.3.6.0-beta.1
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
});
|
||||
})();
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 “Force last saved speed,” 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 “Force last
|
||||
saved speed,” 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–1000. Smaller values are more
|
||||
frequent. Default: 50.</em
|
||||
>How often to nudge: 250–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">…</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 “Force last saved
|
||||
speed,” 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 “Force last saved speed,” 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–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–1000ms):<br /><em
|
||||
>How often to nudge: 10–1000. Smaller values are
|
||||
more frequent. Default: 50.</em
|
||||
>Nudge interval (250–1000ms):<br /><em
|
||||
>How often to nudge: 250–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
@@ -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);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>" }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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" } }
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user