mirror of
https://github.com/SoPat712/Speeder.git
synced 2026-10-08 03:03:14 -04:00
fix(extension): harden controls, rules, and speed persistence
This commit is contained in:
29 files changed
+6154
-842
No files matched your search
+2075
-401
@@ -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", 50),
|
||||
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;
|
||||
@@ -60,7 +162,9 @@ var YT_NATIVE_MAX = 2.0;
|
||||
var YT_NATIVE_STEP = 0.05;
|
||||
var vscObservedRoots = new WeakSet();
|
||||
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 +490,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 +518,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 +627,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 +646,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 +668,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 +716,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 +903,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 +1161,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 +1201,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 +1310,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 +1363,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 +1400,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 +1482,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 +1534,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 +1574,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
|
||||
@@ -1099,9 +1652,35 @@ 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 observeRoot(root) {
|
||||
@@ -1124,7 +1703,10 @@ function observeRoot(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 +1738,100 @@ 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 startPersistentShadowFallback() {
|
||||
if (window.vscPersistentShadowFallbackTimer) return;
|
||||
window.vscPersistentShadowFallbackTimer = setInterval(function() {
|
||||
requestIdle(
|
||||
function() {
|
||||
if (!document.body) return;
|
||||
rescanOpenShadowRoots(document, undefined, false);
|
||||
},
|
||||
{ timeout: 2000 }
|
||||
);
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
function handleBridgeFailure() {
|
||||
window.vscPageShadowBridgeRequested = false;
|
||||
window.vscPageShadowBridgeRetries =
|
||||
(Number(window.vscPageShadowBridgeRetries) || 0) + 1;
|
||||
startPersistentShadowFallback();
|
||||
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);
|
||||
if (window.vscPersistentShadowFallbackTimer) {
|
||||
clearInterval(window.vscPersistentShadowFallbackTimer);
|
||||
window.vscPersistentShadowFallbackTimer = null;
|
||||
}
|
||||
});
|
||||
bridge.addEventListener("error", function() {
|
||||
bridge.remove();
|
||||
handleBridgeFailure();
|
||||
});
|
||||
window.vscPageShadowBridgeRequested = true;
|
||||
parent.appendChild(bridge);
|
||||
} 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 +1849,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)
|
||||
);
|
||||
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 +2239,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 +2253,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 +2303,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 +2482,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 +2546,99 @@ function createsControllerStackingContext(element) {
|
||||
);
|
||||
}
|
||||
|
||||
function getControllerMount(video, boundary) {
|
||||
if (!video || !video.parentElement) return null;
|
||||
function isShadowRootNode(node) {
|
||||
return Boolean(
|
||||
node &&
|
||||
node.nodeType === Node.DOCUMENT_FRAGMENT_NODE &&
|
||||
node.host &&
|
||||
node.host.nodeType === Node.ELEMENT_NODE
|
||||
);
|
||||
}
|
||||
|
||||
var mountBoundary =
|
||||
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;
|
||||
|
||||
// Do not escape the player's immediate local stacking boundary. This is the
|
||||
// line that keeps a max-z-index overlay below page-level sticky headers.
|
||||
if (createsControllerStackingContext(mount)) return 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 +2701,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 +2766,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 = 40;
|
||||
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 +2896,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 +2955,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 +3055,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 +3087,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 +3099,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 +3132,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 +3216,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 +3290,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 +3321,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 +3391,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 +3462,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 +3600,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 });
|
||||
@@ -2318,21 +3759,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 +3803,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 +3825,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 +3858,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 +3873,7 @@ function applySiteRuleOverrides() {
|
||||
"controllerOpacity",
|
||||
"controllerMarginTop",
|
||||
"controllerMarginBottom",
|
||||
"shortcutTargetMode",
|
||||
"enableSubtitleNudge",
|
||||
"subtitleNudgeEnabledByDefault",
|
||||
"subtitleNudgeInterval"
|
||||
@@ -2462,7 +3915,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 +3936,7 @@ function refreshAllControllerGeometry() {
|
||||
if (controllerEl) {
|
||||
controllerEl.style.opacity = String(tc.settings.controllerOpacity);
|
||||
}
|
||||
configureControllerAutoHide(video.vsc, video.vsc.div, false);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2481,9 +3944,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 +3979,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 +3995,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 +4033,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 +4077,7 @@ function setupListener(root) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateSpeedFromEvent(video);
|
||||
updateSpeedFromEvent(video, { shouldPersist: false });
|
||||
}
|
||||
},
|
||||
true
|
||||
@@ -2631,11 +4146,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 +4175,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 +4193,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 +4208,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;
|
||||
|
||||
@@ -2709,6 +4253,15 @@ function attachMutationObserver(root) {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||
scanNodeForMedia(node, node.parentNode || mutation.target, false);
|
||||
});
|
||||
|
||||
// Player gesture/click panes are frequently inserted after the
|
||||
// video. Keep equal-z-index controller hosts last, and repair a
|
||||
// host that the site removed independently of its media element.
|
||||
tc.mediaElements.slice().forEach(function(video) {
|
||||
if (!video || !video.vsc || !video.vsc.div) return;
|
||||
if (video.vsc.controllerHostMount !== mutation.target) return;
|
||||
remountControllerHost(video.vsc, mutation.target);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2745,6 +4298,15 @@ function attachMutationObserver(root) {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 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 +4344,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,35 +4385,47 @@ 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);
|
||||
attachMediaTargetTracking(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).
|
||||
// Bounded fallback scans catch open page-world roots if a site's CSP blocked
|
||||
// the bridge script. The event bridge handles the normal ongoing path.
|
||||
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);
|
||||
[3000, 10000, 30000].forEach(function(delay) {
|
||||
setTimeout(function() {
|
||||
if (!doc.body) return;
|
||||
try {
|
||||
rescanOpenShadowRoots(doc);
|
||||
} catch (e) {}
|
||||
}, delay);
|
||||
});
|
||||
}
|
||||
|
||||
if (forceReinit) {
|
||||
@@ -2873,10 +4433,15 @@ function initializeNow(doc, forceReinit = false) {
|
||||
// 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);
|
||||
rescanOpenShadowRoots(doc);
|
||||
refreshAllControllerGeometry();
|
||||
if (tc.settings.rememberSpeed || tc.settings.forceLastSavedSpeed) {
|
||||
tc.mediaElements.slice().forEach(applyRememberedSpeedPolicy);
|
||||
}
|
||||
}
|
||||
|
||||
vscInitializedDocuments.add(doc);
|
||||
flushPendingMediaCandidates();
|
||||
}
|
||||
|
||||
function setSpeed(
|
||||
@@ -2906,12 +4471,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 +4493,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 +4532,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 +4680,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 +4720,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 +4917,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],
|
||||
|
||||
Reference in new issue
Block a user