Control HTML5 video speed and AB loop actions.
// ==UserScript==
// @name Speed & Loop (userscript)
// @namespace https://github.com/grad13/Speed-and-Loop
// @version 2.1.48
// @description Control HTML5 video speed and AB loop actions.
// @author speed-and-loop
// @license MIT
// @homepageURL https://greasyfork.org/en/scripts/583175-speed-loop-userscript
// @match *://*/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @run-at document-idle
// ==/UserScript==
(function () {
function injectStyle(id, css) {
if (!document || !document.head || document.getElementById(id)) { return; }
var style = document.createElement("style");
style.id = id;
style.textContent = css;
document.head.appendChild(style);
}
injectStyle("psl-userscript-content-css", ".mpc-nosource {\n display: none !important;\n}\n.mpc-manual {\n visibility: visible !important;\n opacity: 1 !important;\n}\n\n.mpc-controller {\n /* In case of pages using `white-space: pre-line` (eg Discord), don't render vsc's whitespace */\n white-space: normal;\n}\n\n/* Origin specific overrides */\n/* YouTube player */\n.ytp-hide-info-bar .mpc-controller {\n position: relative;\n top: 10px;\n}\n\n.ytp-autohide .mpc-controller {\n visibility: visible;\n opacity: 1;\n}\n\n.ytp-autohide .mpc-show {\n visibility: visible;\n opacity: 1;\n}\n\n/* YouTube embedded player */\n/* e.g. https://www.igvita.com/2012/09/12/web-fonts-performance-making-pretty-fast/ */\n.html5-video-player:not(.ytp-hide-info-bar) .mpc-controller {\n position: relative;\n top: 60px;\n}\n\n/* Facebook player */\n#facebook .mpc-controller {\n position: relative;\n top: 40px;\n}\n\n/* Google Photos player */\n/* Inline preview doesn't have any additional hooks, relying on Aria label */\na[aria-label^=\"Video\"] .mpc-controller {\n position: relative;\n top: 35px;\n}\n/* Google Photos full-screen view */\n#player .house-brand .mpc-controller {\n position: relative;\n top: 50px;\n}\n\n/* Netflix player */\n#netflix-player:not(.player-cinema-mode) .mpc-controller {\n position: relative;\n top: 85px;\n}\n\n/* shift controller on vine.co */\n/* e.g. https://vine.co/v/OrJj39YlL57 */\n.video-container .vine-video-container .mpc-controller {\n margin-left: 40px;\n}\n\n/* shift YT 3D controller down */\n/* e.g. https://www.youtube.com/watch?v=erftYPflJzQ */\n.ytp-webgl-spherical-control {\n top: 60px !important;\n}\n\n.ytp-fullscreen .ytp-webgl-spherical-control {\n top: 100px !important;\n}\n\n/* disable Vimeo video overlay */\ndiv.video-wrapper + div.target {\n height: 0;\n}\n\n/* Fix black overlay on Kickstarter */\ndiv.video-player.has_played.vertically_center:before,\ndiv.legacy-video-player.has_played.vertically_center:before {\n content: none !important;\n}\n\n/* Fix black overlay on openai.com */\n.Shared-Video-player > .mpc-controller {\n height: 0;\n}\n");
// ---- code/extension/content/namespace.js ----
(function (global) {
var PSL = global.PlaySpeedLoop || {};
var pendingInjectionSessionId = global.__PSL_PENDING_INJECTION_SESSION_ID;
PSL.regStrip = /^[\r\t\f\v ]+|[\r\t\f\v ]+$/gm;
// S0 (documents/plan/2026-06-26-content-runtime-singleton.md): a second
// execution of this bundle in the SAME world (double injection) must not mint
// a new instanceId. Otherwise a controller attached before the overwrite and
// one attached after carry different _pslInstanceId and both survive on the
// same media while idle (no rescan fires) — the observed duplicate panels.
// Reuse the existing instanceId unless S0 is explicitly disabled for
// fix-attribution testing (localStorage PSL_FIX_S0 === "0").
//
// injectionSessionId is intentionally NOT made idempotent: it tracks the
// background injection session so that a genuinely new session (e.g. the
// add-on re-enabled) still re-initializes via the init.js guard. In a
// same-session double injection the pending session id is identical anyway, so
// leaving this as-is does not contribute to the duplicate.
var s0Disabled = false;
try {
s0Disabled = !!(
global.localStorage && global.localStorage.getItem("PSL_FIX_S0") === "0"
);
} catch (e) {}
PSL.instanceId =
(!s0Disabled && PSL.instanceId) ||
"psl-" + Date.now() + "-" + Math.random().toString(36).slice(2);
PSL.injectionSessionId =
typeof pendingInjectionSessionId === "string"
? pendingInjectionSessionId
: PSL.instanceId;
// Stamp every diagnostic line with the extension version so captured logs are
// self-identifying: no need to cross-check manifest/version after the fact to
// know which build produced a capture (avoids the version-mismatch confusion
// between plan docs). Read from the manifest; null if unavailable.
PSL.version = (function () {
try {
var rt =
(global.chrome && global.chrome.runtime) ||
(global.browser && global.browser.runtime);
if (rt && typeof rt.getManifest === "function") {
return rt.getManifest().version;
}
} catch (e) {}
return null;
})();
PSL.__injectionSessionId = PSL.injectionSessionId;
// A re-evaluation of this bundle in the SAME world (double injection within one
// background session) rebuilds PSL.state from scratch. The tab-scope maintained
// speed lives ONLY here (PSL.state.tabSpeed) — no storage, no frame sync — so an
// unconditional rebuild silently drops it: video.playbackRate (a DOM property)
// survives the re-eval but tabSpeed becomes undefined, and the next relative
// adjustment resolves its base from global=1 instead of the maintained speed.
// Carry tabSpeed across the rebuild, the same way instanceId is preserved in S0
// above. tabSpeed can be negative (reverse, kept across navigation per SC-28)
// but is never 0 (speed-actions.js guards speedNumber !== 0), so accept any
// finite number. isFiniteNumber is local to speed-state.js and out of scope
// here, so use a plain typeof/isFinite check.
var prevState = PSL.state;
PSL.state = {
mediaElements: [],
startTimes: {},
endTimes: {},
pendingLoopPoints: {},
loopsEnabled: {},
controllerTimer: null,
nextMediaId: 1,
instanceId: PSL.instanceId
};
if (
prevState &&
typeof prevState.tabSpeed === "number" &&
isFinite(prevState.tabSpeed)
) {
PSL.state.tabSpeed = prevState.tabSpeed;
}
PSL.requestIdle = function (callback, options) {
if (typeof global.requestIdleCallback === "function") {
return global.requestIdleCallback.call(global, callback, options);
}
return global.setTimeout(function () {
callback({ didTimeout: false, timeRemaining: function () { return 0; } });
}, 0);
};
global.PlaySpeedLoop = PSL;
try {
delete global.__PSL_PENDING_INJECTION_SESSION_ID;
} catch (e) {
global.__PSL_PENDING_INJECTION_SESSION_ID = undefined;
}
function getGlobalStorage() {
if (typeof browser !== "undefined" && browser.storage && browser.storage.sync) {
return browser.storage.sync;
}
if (typeof chrome !== "undefined" && chrome.storage && chrome.storage.sync) {
return chrome.storage.sync;
}
return null;
}
PSL.getAssetUrl = function (path) {
try {
var rt = global.chrome && global.chrome.runtime;
if (rt && typeof rt.getURL === "function") {
return rt.getURL(path);
}
} catch (e) {}
try {
var browserRuntime = global.browser && global.browser.runtime;
if (browserRuntime && typeof browserRuntime.getURL === "function") {
return browserRuntime.getURL(path);
}
} catch (e) {}
return null;
};
PSL.getStorageBridge = function () {
return getGlobalStorage();
};
})(globalThis);
// ---- code/extension/content/runtime-ownership.js ----
(function (PSL) {
// S1/S2 (documents/plan/2026-06-26-content-runtime-singleton.md): the current
// content runtime claims ownership of the document by stamping its instanceId
// on documentElement. When two runtimes coexist (extension reload / background
// restart leaving two generations), the last to claim wins; the others become
// non-owners and their controllers are swept by owner-based reconcile (S2).
//
// Safety net only: with S0 in place a second instanceId is not minted in the
// first place, so in the single-runtime case the owner marker equals the
// current instanceId and ownership is a no-op relative to the pre-S2 behavior.
//
// Gated for fix-attribution testing via localStorage (default on):
// PSL_FIX_S1 === "0" -> do not claim ownership.
// PSL_FIX_S2 === "0" -> reconcile ignores the marker (pre-S2 behavior).
function flagDisabled(name) {
try {
return !!(
typeof localStorage !== "undefined" &&
localStorage.getItem(name) === "0"
);
} catch (e) {
return false;
}
}
function ownerElement(doc) {
var d = doc || (typeof document !== "undefined" ? document : null);
return d ? d.documentElement || null : null;
}
PSL.claimRuntimeOwnership = function (doc) {
if (flagDisabled("PSL_FIX_S1")) {
return;
}
var el = ownerElement(doc);
var existing = el && el.dataset ? el.dataset.pslRuntimeOwner : null;
if (el && el.dataset && !existing) {
// First-writer-wins: a second content-script world that shares this
// document must NOT steal ownership, or the owner marker would flip and
// both worlds' panels would survive. The first runtime to claim owns the
// document; later runtimes stay non-owners and do not attach panels.
el.dataset.pslRuntimeOwner = PSL.instanceId;
}
};
// The instanceId that currently owns the document. With S2 enabled this is the
// owner marker, falling back to the current instanceId when unset
// (single-runtime / back-compat). With S2 disabled it is always the current
// instanceId — the pre-S2 behavior.
PSL.getRuntimeOwner = function (doc) {
if (flagDisabled("PSL_FIX_S2")) {
return PSL.instanceId;
}
var el = ownerElement(doc);
var marker = el && el.dataset ? el.dataset.pslRuntimeOwner : null;
return marker || PSL.instanceId;
};
PSL.isRuntimeOwner = function (doc) {
return PSL.getRuntimeOwner(doc) === PSL.instanceId;
};
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/defaults.js ----
(function (PSL) {
PSL.defaultKeyBindings = [
{ action: "slower", key: 83, value: 0.5, force: false, predefined: true },
{ action: "faster", key: 68, value: 0.5, force: false, predefined: true },
{ action: "reset", key: 82, value: 1, force: false, predefined: true },
{ action: "mark-loop-range", key: 65, value: 0, force: false, predefined: true },
{ action: "clear-loop", key: 69, value: 0, force: false, predefined: true }
];
PSL.defaultSettings = {
lastSpeed: 1.0,
enabled: true,
speeds: {},
domainSpeeds: {},
displayKeyCode: 86,
audioBoolean: false,
speedControlsEnabled: true,
loopControlsEnabled: true,
speedStep: 0.5,
preferredSpeed: 1.8,
reverseFrameRate: 1,
speedScope: "tab",
keepSpeedSites: "",
controllerOpacity: 0.96,
keyBindings: PSL.defaultKeyBindings,
blacklist: "www.instagram.com\ntwitter.com\nvine.co\nimgur.com\nteams.microsoft.com",
defaultLogLevel: 4,
logLevel: 1
};
PSL.SPEED_SCOPES = ["tab", "domain", "video"];
// Round any stored/legacy value to a supported scope. Unknown/invalid values
// fall back to the default "tab" (see plan: speedScope single source of truth).
PSL.normalizeSpeedScope = function (value) {
return PSL.SPEED_SCOPES.indexOf(value) >= 0 ? value : "tab";
};
PSL.cloneDefaultSettings = function () {
var settings = Object.assign({}, PSL.defaultSettings);
settings.speeds = {};
settings.domainSpeeds = {};
settings.keyBindings = PSL.defaultKeyBindings.map(function (binding) {
return Object.assign({}, binding);
});
return settings;
};
PSL.settings = PSL.cloneDefaultSettings();
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/logger.js ----
(function (PSL) {
PSL.log = function (message, level) {
var verbosity = PSL.settings.logLevel;
if (typeof level === "undefined") {
level = PSL.settings.defaultLogLevel;
}
if (verbosity >= level) {
if (level === 2) {
console.log("ERROR:" + message);
} else if (level === 3) {
console.log("WARNING:" + message);
} else if (level === 4) {
console.log("INFO:" + message);
} else if (level === 5) {
console.log("DEBUG:" + message);
} else if (level === 6) {
console.log("DEBUG (VERBOSE):" + message);
console.trace();
}
}
};
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/unavailable-content-settings-storage.js ----
(function (PSL) {
function logUnavailable(operation) {
if (typeof PSL.log === "function") {
PSL.log("Content settings storage unavailable during " + operation, 3);
}
}
PSL.createUnavailableContentSettingsStorage = function () {
return {
get: function (defaults, callback) {
logUnavailable("get");
callback(null, defaults || {});
},
set: function (_values, callback) {
logUnavailable("set");
if (callback) {
callback();
}
},
remove: function (_keys, callback) {
logUnavailable("remove");
if (callback) {
callback();
}
}
};
};
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/content-settings-storage-backend.js ----
(function (PSL) {
// Callback error contract (all platforms conform):
// get(defaults, callback) -> callback(error, result)
// success: callback(null, result); failure: callback(error, defaults)
// set(values, callback) -> callback(error)
// success: callback(); failure: callback(error)
// remove(keys, callback) -> callback(error)
// A write/read failure is never reported as success. Callers can distinguish
// a genuine failure from "no stored value" by inspecting the error argument.
function hasBrowserStorage() {
return (
typeof browser !== "undefined" &&
browser.storage &&
browser.storage.sync
);
}
function hasChromeStorage() {
return (
typeof chrome !== "undefined" &&
chrome.storage &&
chrome.storage.sync
);
}
function hasGMStorage() {
return typeof GM_getValue === "function" && typeof GM_setValue === "function";
}
function parseGMValue(rawValue, fallback) {
if (typeof rawValue === "undefined" || rawValue === null) {
return fallback;
}
if (typeof rawValue === "string") {
try {
return JSON.parse(rawValue);
} catch (error) {
return rawValue;
}
}
return rawValue;
}
var platform;
if (hasBrowserStorage()) {
platform = {
get: function (defaults, callback) {
// null/undefined defaults => read the entire store (get(null)). Coercing
// to {} would ask the backend for "no keys" and come back empty, which is
// how a domain speed set on video 1 was lost on the next read.
var query = defaults == null ? null : defaults;
browser.storage.sync.get(query).then(
function (result) {
callback(null, result);
},
function (error) {
callback(error, defaults || {});
}
);
},
set: function (values, callback) {
browser.storage.sync.set(values).then(
function () {
if (callback) {
callback();
}
},
function (error) {
if (callback) {
callback(error);
}
}
);
},
remove: function (keys, callback) {
browser.storage.sync.remove(keys).then(
function () {
if (callback) {
callback();
}
},
function (error) {
if (callback) {
callback(error);
}
}
);
}
};
} else if (hasChromeStorage()) {
platform = {
get: function (defaults, callback) {
// See the browser branch: null/undefined defaults => full read (get(null)).
var query = defaults == null ? null : defaults;
chrome.storage.sync.get(query, function (result) {
var error = chrome.runtime && chrome.runtime.lastError;
if (error) {
callback(error, defaults || {});
} else {
callback(null, result);
}
});
},
set: function (values, callback) {
chrome.storage.sync.set(values, function () {
var error = chrome.runtime && chrome.runtime.lastError;
if (callback) {
callback(error || undefined);
}
});
},
remove: function (keys, callback) {
chrome.storage.sync.remove(keys, function () {
var error = chrome.runtime && chrome.runtime.lastError;
if (callback) {
callback(error || undefined);
}
});
}
};
} else if (hasGMStorage()) {
platform = {
get: function (defaults, callback) {
var result = Object.assign({}, defaults || {});
try {
// null/undefined defaults => full read: enumerate every stored key
// (matches storage.sync.get(null)). With explicit defaults, read only
// those keys, falling back to the supplied default per key.
if (defaults == null && typeof GM_listValues === "function") {
GM_listValues().forEach(function (key) {
result[key] = parseGMValue(GM_getValue(key, null), undefined);
});
} else {
Object.keys(result).forEach(function (key) {
result[key] = parseGMValue(GM_getValue(key, null), result[key]);
});
}
callback(null, result);
} catch (error) {
callback(error, defaults || {});
}
},
set: function (values, callback) {
try {
Object.keys(values).forEach(function (key) {
if (values[key] === undefined) {
if (typeof GM_deleteValue === "function") {
GM_deleteValue(key);
}
} else {
GM_setValue(key, JSON.stringify(values[key]));
}
});
if (callback) {
callback();
}
} catch (error) {
if (callback) {
callback(error);
}
}
},
remove: function (keys, callback) {
if (!Array.isArray(keys)) {
keys = [keys];
}
try {
if (typeof GM_deleteValue === "function") {
keys.forEach(function (key) {
GM_deleteValue(key);
});
}
if (callback) {
callback();
}
} catch (error) {
if (callback) {
callback(error);
}
}
}
};
} else {
platform = PSL.createUnavailableContentSettingsStorage();
}
PSL.storagePlatform = platform;
PSL.getStorage = function (defaults, callback) {
if (typeof callback !== "function") {
return platform;
}
platform.get(defaults, callback);
};
PSL.setStorage = function (values, callback) {
platform.set(values, callback);
};
PSL.removeStorage = function (keys, callback) {
platform.remove(keys, callback);
};
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/settings-migration.js ----
(function (PSL) {
PSL.hasStoredSetting = function (storage, key) {
return Object.prototype.hasOwnProperty.call(storage || {}, key);
};
PSL.hasLegacyKeyBindingSettings = function (storage) {
return [
"speedStep",
"resetKeyCode",
"slowerKeyCode",
"fasterKeyCode",
"rewindKeyCode",
"advanceKeyCode",
"fastKeyCode"
].some(function (key) {
return PSL.hasStoredSetting(storage, key);
});
};
PSL.buildLegacyKeyBindings = function (storage) {
return [
{
action: "slower",
key: Number(storage.slowerKeyCode) || 83,
value: Number(storage.speedStep) || 0.5,
force: false,
predefined: true
},
{
action: "faster",
key: Number(storage.fasterKeyCode) || 68,
value: Number(storage.speedStep) || 0.5,
force: false,
predefined: true
},
{
action: "rewind",
key: Number(storage.rewindKeyCode) || 90,
value: Number(storage.rewindTime) || 10,
force: false,
predefined: true
},
{
action: "advance",
key: Number(storage.advanceKeyCode) || 88,
value: Number(storage.advanceTime) || 10,
force: false,
predefined: true
},
{
action: "reset",
key: Number(storage.resetKeyCode) || 82,
value: 1.0,
force: false,
predefined: true
},
{
action: "fast",
key: Number(storage.fastKeyCode) || 71,
value: Number(storage.fastSpeed) || 1.8,
force: false,
predefined: true
}
];
};
PSL.ensureDefaultBinding = function (settings, action) {
if (settings.keyBindings.some(function (binding) { return binding.action === action; })) {
return false;
}
var defaultBinding = PSL.defaultKeyBindings.find(function (binding) {
return binding.action === action;
});
if (!defaultBinding) {
return false;
}
settings.keyBindings.push(Object.assign({}, defaultBinding));
return true;
};
PSL.ensureDefaultBindingValue = function (settings, action) {
var binding = settings.keyBindings.find(function (item) {
return item.action === action;
});
var defaultBinding = PSL.defaultKeyBindings.find(function (item) {
return item.action === action;
});
if (!binding || !defaultBinding) {
return false;
}
var migrated = false;
if (typeof binding.key === "undefined") {
binding.key = defaultBinding.key;
migrated = true;
}
if (
(action === "slower" || action === "faster") &&
(isNaN(Number(binding.value)) || Number(binding.value) < 0.01)
) {
binding.value = defaultBinding.value;
migrated = true;
}
if (
(action === "reset" || action === "fast") &&
(isNaN(Number(binding.value)) || Number(binding.value) <= 0)
) {
binding.value = defaultBinding.value;
migrated = true;
}
return migrated;
};
PSL.migrateDefaultSpeedStep = function (settings) {
var migrated = false;
settings.keyBindings.forEach(function (binding) {
if (
(binding.action === "slower" || binding.action === "faster") &&
binding.predefined !== false &&
Number(binding.value) === 0.1
) {
binding.value = 0.5;
migrated = true;
}
});
return migrated;
};
PSL.migrateSpeedStep = function (storage, settings) {
var speedStep = Number(storage.speedStep);
if (
PSL.hasStoredSetting(storage, "speedStep") &&
!isNaN(speedStep) &&
speedStep >= 0.01 &&
speedStep <= 15.93
) {
return { value: speedStep, migrated: false };
}
var binding = settings.keyBindings.find(function (item) {
return item.action === "slower" || item.action === "faster";
});
speedStep = binding ? Number(binding.value) : NaN;
if (!isNaN(speedStep) && speedStep >= 0.01 && speedStep <= 15.93) {
return { value: speedStep, migrated: true };
}
return { value: 0.5, migrated: true };
};
PSL.migratePreferredSpeed = function (storage, settings) {
var preferredSpeed = Number(storage.preferredSpeed);
var storedBindings = Array.isArray(storage.keyBindings)
? storage.keyBindings
: [];
var binding;
if (
PSL.hasStoredSetting(storage, "preferredSpeed") &&
!isNaN(preferredSpeed) &&
preferredSpeed >= 0.07 &&
preferredSpeed <= 16
) {
return { value: preferredSpeed, migrated: false };
}
binding = storedBindings.find(function (item) {
return item.action === "fast";
}) || storedBindings.find(function (item) {
return item.action === "reset";
}) || settings.keyBindings.find(function (item) {
return item.action === "fast";
});
preferredSpeed = binding ? Number(binding.value) : NaN;
if (!isNaN(preferredSpeed) && preferredSpeed >= 0.07 && preferredSpeed <= 16) {
return { value: preferredSpeed, migrated: true };
}
return { value: 1.8, migrated: true };
};
PSL.migrateReverseFrameRate = function (storage) {
var reverseFrameRate = Number(storage.reverseFrameRate);
if (
PSL.hasStoredSetting(storage, "reverseFrameRate") &&
!isNaN(reverseFrameRate) &&
reverseFrameRate >= 0.1 &&
reverseFrameRate <= 15
) {
return { value: reverseFrameRate, migrated: false };
}
return { value: 1, migrated: PSL.hasStoredSetting(storage, "reverseFrameRate") };
};
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/storage.js ----
(function (PSL) {
PSL.getKeyBinding = function (action, what) {
if (!what) {
what = "value";
}
try {
return PSL.settings.keyBindings.find(function (item) {
return item.action === action;
})[what];
} catch (e) {
return false;
}
};
PSL.setKeyBinding = function (action, value) {
var binding = PSL.settings.keyBindings.find(function (item) {
return item.action === action;
});
if (binding) {
binding.value = value;
}
};
PSL.loadSettings = function (callback) {
PSL.getStorage(null, function (error, rawStorage) {
if (error) {
PSL.log("Failed to read settings from storage; using defaults: " + error, 3);
}
rawStorage = rawStorage || {};
var storage = Object.assign(PSL.cloneDefaultSettings(), rawStorage);
var settings = PSL.cloneDefaultSettings();
var migrated = false;
var speedStep;
var preferredSpeed;
var reverseFrameRate;
if (Array.isArray(rawStorage.keyBindings)) {
settings.keyBindings = rawStorage.keyBindings.slice();
} else if (PSL.hasLegacyKeyBindingSettings(rawStorage)) {
settings.keyBindings = PSL.buildLegacyKeyBindings(rawStorage);
settings.version = "0.5.3";
migrated = true;
}
["slower", "faster", "reset", "mark-loop-range", "clear-loop"].forEach(function (action) {
migrated = PSL.ensureDefaultBinding(settings, action) || migrated;
migrated = PSL.ensureDefaultBindingValue(settings, action) || migrated;
});
migrated = PSL.migrateDefaultSpeedStep(settings) || migrated;
speedStep = PSL.migrateSpeedStep(rawStorage, settings);
settings.speedStep = speedStep.value;
migrated = speedStep.migrated || migrated;
preferredSpeed = PSL.migratePreferredSpeed(rawStorage, settings);
settings.preferredSpeed = preferredSpeed.value;
migrated = preferredSpeed.migrated || migrated;
reverseFrameRate = PSL.migrateReverseFrameRate(rawStorage, settings);
settings.reverseFrameRate = reverseFrameRate.value;
migrated = reverseFrameRate.migrated || migrated;
settings.keyBindings = settings.keyBindings.filter(function (binding) {
return PSL.defaultKeyBindings.some(function (defaultBinding) {
return defaultBinding.action === binding.action;
});
});
settings.lastSpeed = Number(storage.lastSpeed) || 1.0;
settings.displayKeyCode = Number(storage.displayKeyCode) || 86;
settings.audioBoolean = Boolean(storage.audioBoolean);
settings.enabled =
storage.enabled === undefined
? PSL.defaultSettings.enabled
: Boolean(storage.enabled);
settings.speedControlsEnabled = storage.speedControlsEnabled !== false;
settings.loopControlsEnabled = storage.loopControlsEnabled !== false;
settings.speedScope = PSL.normalizeSpeedScope(storage.speedScope);
settings.keepSpeedSites = String(storage.keepSpeedSites || "");
settings.controllerOpacity = Number(storage.controllerOpacity);
if (isNaN(settings.controllerOpacity)) {
settings.controllerOpacity = PSL.defaultSettings.controllerOpacity;
}
settings.blacklist = String(storage.blacklist);
settings.logLevel = Number(storage.logLevel) || PSL.defaultSettings.logLevel;
settings.defaultLogLevel =
Number(storage.defaultLogLevel) || PSL.defaultSettings.defaultLogLevel;
settings.speeds = storage.speeds || {};
settings.domainSpeeds = storage.domainSpeeds || {};
PSL.settings = settings;
if (PSL.updateKeepSpeedSiteMatchers) {
PSL.updateKeepSpeedSiteMatchers();
}
if (migrated) {
PSL.setStorage({
keyBindings: PSL.settings.keyBindings,
version: PSL.settings.version,
displayKeyCode: PSL.settings.displayKeyCode,
audioBoolean: PSL.settings.audioBoolean,
speedControlsEnabled: PSL.settings.speedControlsEnabled,
loopControlsEnabled: PSL.settings.loopControlsEnabled,
speedStep: PSL.settings.speedStep,
preferredSpeed: PSL.settings.preferredSpeed,
reverseFrameRate: PSL.settings.reverseFrameRate,
keepSpeedSites: PSL.settings.keepSpeedSites,
enabled: PSL.settings.enabled,
controllerOpacity: PSL.settings.controllerOpacity,
blacklist: PSL.settings.blacklist.replace(PSL.regStrip, "")
}, function (error) {
if (error) {
PSL.log("Failed to persist migrated settings: " + error, 3);
}
});
}
callback();
});
};
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/feature-gates.js ----
(function (PSL) {
var SPEED_ACTIONS = ["slower", "faster", "reset"];
var LOOP_ACTIONS = ["mark-loop-range", "clear-loop"];
function numberInRange(value, fallback, min, max) {
var number = Number(value);
if (isNaN(number) || number < min || number > max) {
return fallback;
}
return number;
}
PSL.isSpeedAction = function (action) {
return SPEED_ACTIONS.indexOf(action) !== -1;
};
PSL.isLoopAction = function (action) {
return LOOP_ACTIONS.indexOf(action) !== -1;
};
PSL.isActionEnabled = function (action) {
if (PSL.isSpeedAction(action)) {
return PSL.settings.speedControlsEnabled !== false;
}
if (PSL.isLoopAction(action)) {
return PSL.settings.loopControlsEnabled !== false;
}
return false;
};
PSL.getSpeedStep = function () {
return numberInRange(PSL.settings.speedStep, 0.5, 0.01, 15.93);
};
PSL.getPreferredSpeed = function () {
return numberInRange(PSL.settings.preferredSpeed, 1.8, 0.07, 16);
};
PSL.getReverseFrameRate = function () {
return numberInRange(PSL.settings.reverseFrameRate, 1, 0.1, 15);
};
PSL.shouldAttachController = function () {
return (
PSL.settings.speedControlsEnabled !== false ||
PSL.settings.loopControlsEnabled !== false
);
};
// Whether a media element should receive an on-video controller. Mirrors the
// approach proven by other speed extensions: gate on visibility, not on a size
// threshold. A video that is detached, CSS-invisible
// (display:none/visibility:hidden/opacity:0), or rendered at zero size gets no
// controller. This keeps panels off the zero-size transformed <video> layers
// that sites (e.g. YouTube) keep alongside the real player and that otherwise
// produce a second, duplicate panel. Audio has no rendered box and stays
// eligible. Layout APIs are probed defensively so non-browser test
// environments fall back to offset metrics.
PSL.isAttachableMediaTarget = function (media) {
var dkey = PSL.getMediaKey && media ? PSL.getMediaKey(media) : "?";
if (!media || media.isConnected === false) {
return false;
}
if (media.nodeName === "AUDIO") {
return true;
}
// A <video> with no media source (no currentSrc/src/srcObject) is not a real
// player. YouTube keeps placeholder/secondary <video> elements beside the real
// player; they are visible and non-zero-size (so the size gate below does not
// catch them) but control nothing. Attaching to one produces the observed
// second, dead panel next to the working player. It becomes attachable again
// on a later rescan once a source loads, so the real player is not lost.
if (!(media.currentSrc || media.src || media.srcObject)) {
return false;
}
try {
if (typeof getComputedStyle === "function") {
var style = getComputedStyle(media);
if (
style &&
(style.display === "none" ||
style.visibility === "hidden" ||
style.opacity === "0")
) {
return false;
}
}
} catch (e) {}
var width;
var height;
var measured = false;
try {
if (typeof media.getBoundingClientRect === "function") {
var rect = media.getBoundingClientRect();
if (rect) {
width = rect.width;
height = rect.height;
measured = true;
}
}
} catch (e) {}
if (!measured) {
width = media.offsetWidth;
height = media.offsetHeight;
}
var ok = Boolean(width) && Boolean(height);
return ok;
};
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/blacklist.js ----
(function (PSL) {
PSL.escapeStringRegExp = function (str) {
var matchOperatorsRe = /[|\\{}()[\]^$+*?.]/g;
return str.replace(matchOperatorsRe, "\\$&");
};
PSL.isBlacklisted = function (url) {
var targetUrl = typeof url === "string" ? url : location.href;
var blacklisted = false;
PSL.settings.blacklist.split("\n").forEach(function (match) {
var regexp;
match = match.replace(PSL.regStrip, "");
if (match.length === 0) {
return;
}
if (match.startsWith("/")) {
try {
regexp = new RegExp(match.replace(/^\/|\/$/g, ""));
} catch (err) {
return;
}
} else {
regexp = new RegExp(PSL.escapeStringRegExp(match));
}
if (regexp.test(targetUrl)) {
blacklisted = true;
}
});
return blacklisted;
};
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/media-registry.js ----
(function (PSL) {
PSL.getMediaKey = function (media) {
if (media.currentSrc || media.src) {
return media.currentSrc || media.src;
}
if (!media._pslMediaId) {
media._pslMediaId = "media-" + PSL.state.nextMediaId++;
}
return media._pslMediaId;
};
PSL.registerMedia = function (media) {
if (PSL.state.mediaElements.indexOf(media) === -1) {
PSL.state.mediaElements.push(media);
}
};
PSL.unregisterMedia = function (media) {
var idx = PSL.state.mediaElements.indexOf(media);
if (idx !== -1) {
PSL.state.mediaElements.splice(idx, 1);
}
};
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/media-element-query.js ----
(function (PSL) {
PSL.findMediaElements = function (root) {
var media = [];
function collect(node) {
if (!node || !node.querySelectorAll) {
return;
}
node.querySelectorAll("video,audio").forEach(function (element) {
if (
element.nodeName === "VIDEO" ||
(element.nodeName === "AUDIO" && PSL.settings.audioBoolean)
) {
media.push(element);
}
});
node.querySelectorAll("*").forEach(function (element) {
if (element.shadowRoot) {
collect(element.shadowRoot);
}
});
}
collect(root);
return media;
};
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/speed-hierarchy.js ----
(function (PSL) {
function isValidSpeed(value) {
return typeof value === "number" && Number.isFinite(value);
}
function clampSpeed(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function resolveCandidate(input, scope, key) {
if (!input || !isValidSpeed(input[key])) {
return null;
}
return { speed: input[key], scope: scope };
}
PSL.getEffectiveSpeedFromHierarchy = function (input) {
var candidate =
resolveCandidate(input, "temporary", "temporarySpeed") ||
resolveCandidate(input, "tab", "tabSpeed") ||
resolveCandidate(input, "site", "siteSpeed") ||
resolveCandidate(input, "global", "globalSpeed") ||
{ speed: 1, scope: "global" };
var min = isValidSpeed(input && input.minSpeed) ? input.minSpeed : -16;
var max = isValidSpeed(input && input.maxSpeed) ? input.maxSpeed : 16;
return {
speed: clampSpeed(candidate.speed, min, max),
scope: candidate.scope
};
};
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/speed-state.js ----
(function (PSL) {
function getRecords() {
if (!PSL.state) {
PSL.state = {};
}
if (!PSL.state.mediaSpeedRecords) {
PSL.state.mediaSpeedRecords = new Map();
}
return PSL.state.mediaSpeedRecords;
}
function getMediaRecords() {
if (!PSL.state) {
PSL.state = {};
}
if (!PSL.state.mediaSpeedElementRecords) {
PSL.state.mediaSpeedElementRecords = new WeakMap();
}
return PSL.state.mediaSpeedElementRecords;
}
function isFiniteNumber(value) {
return typeof value === "number" && Number.isFinite(value);
}
function normalizeSpeed(speed) {
// Dead in shipping (clampSpeed always loads), but retained: the test harness
// loads speed-state in partial bundles without speed-actions, and the
// fallback is a correct reimplementation. See Plan B P4 (guards kept).
if (PSL.clampSpeed) {
return Number(PSL.clampSpeed(speed, -16, 16).toFixed(2));
}
return Number(Math.min(Math.max(speed, -16), 16).toFixed(2));
}
function getMediaId(video) {
if (video && PSL.getMediaKey) {
return PSL.getMediaKey(video);
}
// getMediaKey (media-registry.js) is always loaded before this runs; this
// branch is unreachable in shipped builds. No static shared key: an
// unidentifiable media falls through to falsy so it is never folded onto a
// shared record (linkRecordId/ensureRecordForMedia skip falsy ids).
return video && (video.currentSrc || video.src || video._pslMediaId);
}
function getInitialSpeed(video) {
return isFiniteNumber(video && video.playbackRate) ? video.playbackRate : 1;
}
function ensureRecordById(mediaId, initialSpeed, scope) {
var key = String(mediaId || "");
var records = getRecords();
var record = records.get(key);
if (!record) {
record = {
speed: normalizeSpeed(isFiniteNumber(initialSpeed) ? initialSpeed : 1),
scope: scope || "global"
};
records.set(key, record);
}
return record;
}
function linkRecordId(mediaId, record) {
var key = String(mediaId || "");
if (key) {
getRecords().set(key, record);
}
}
function ensureRecordForMedia(video, initialSpeed, scope) {
var mediaRecords = getMediaRecords();
var mediaId = getMediaId(video);
var record = video ? mediaRecords.get(video) : null;
if (!record && mediaId) {
record = getRecords().get(String(mediaId));
}
if (!record) {
record = {
speed: normalizeSpeed(isFiniteNumber(initialSpeed) ? initialSpeed : 1),
scope: scope || "global"
};
}
if (video) {
mediaRecords.set(video, record);
}
linkRecordId(mediaId, record);
return record;
}
PSL.setMediaSpeedById = function (mediaId, speed, scope) {
var record = ensureRecordById(mediaId, speed, scope);
record.speed = normalizeSpeed(speed);
record.scope = scope || record.scope || "temporary";
return record.speed;
};
// The page-world authority reads its target speed through this id-keyed path
// (media-authority.js targetSpeed getter). It MUST resolve the selected scope
// layer (the single source of truth) exactly like the video-keyed getMediaSpeed
// — not return the per-media record raw. Returning the raw record let a stale /
// orphaned record (e.g. 2.5 left behind by a blob-URL swap) override the active
// tab layer, so the authority kept restoring the old value (the ping-pong bug).
// A synthetic per-media state (<=0: zero pause / reverse) still wins, mirroring
// getMediaSpeed.
PSL.getMediaSpeedById = function (mediaId) {
var record = ensureRecordById(mediaId, 1, "global");
if (record && isFiniteNumber(record.speed) && record.speed <= 0) {
return record.speed;
}
if (!PSL.getEffectiveSpeedFromHierarchy) {
return record.speed;
}
var scope = getSelectedScope();
var layerSpeed = getSelectedLayerSpeed(null, scope, record);
return PSL.getEffectiveSpeedFromHierarchy({
temporarySpeed: scope === "video" ? layerSpeed : null,
tabSpeed: scope === "tab" ? layerSpeed : null,
siteSpeed: scope === "domain" ? layerSpeed : null,
globalSpeed: 1,
minSpeed: -16,
maxSpeed: 16
}).speed;
};
PSL.getMediaSpeedRecordById = function (mediaId) {
return ensureRecordById(mediaId, 1, "global");
};
PSL.hasMediaSpeed = function (video) {
return Boolean(
(video && getMediaRecords().has(video)) ||
getRecords().has(String(getMediaId(video) || ""))
);
};
PSL.setMediaSpeed = function (video, speed, scope) {
var record = ensureRecordForMedia(video, speed, scope);
record.speed = normalizeSpeed(speed);
record.scope = scope || record.scope || "temporary";
linkRecordId(getMediaId(video), record);
return record.speed;
};
function findRecord(video) {
var record = video && getMediaRecords().get(video);
if (!record) {
record = getRecords().get(String(getMediaId(video) || ""));
if (record && video) {
getMediaRecords().set(video, record);
}
}
if (record) {
linkRecordId(getMediaId(video), record);
}
return record || null;
}
// Resolve a value to a supported scope. Prefers PSL.normalizeSpeedScope
// (defaults.js) but stays self-contained so it never silently falls back to
// "tab" just because defaults.js has not loaded yet (e.g. partial bundles).
PSL.resolveSpeedScope = function (scope) {
if (PSL.normalizeSpeedScope) {
return PSL.normalizeSpeedScope(scope);
}
return scope === "tab" || scope === "domain" || scope === "video"
? scope
: "tab";
};
function getSelectedScope() {
return PSL.resolveSpeedScope(PSL.settings && PSL.settings.speedScope);
}
// The single source of truth is the value of the selected scope layer. Returns
// null when that layer holds no value (which resolves to global=1 downstream).
// video=per-media record, tab=PSL.state.tabSpeed, domain=saved domain speed.
function getSelectedLayerSpeed(video, scope, record) {
if (scope === "tab") {
return PSL.state && isFiniteNumber(PSL.state.tabSpeed)
? PSL.state.tabSpeed
: null;
}
if (scope === "domain") {
var saved = PSL.getSavedDomainSpeed ? Number(PSL.getSavedDomainSpeed(video)) : NaN;
return isFiniteNumber(saved) ? saved : null;
}
return record && isFiniteNumber(record.speed) ? record.speed : null;
}
// Effective speed = resolve the selected layer through the speed hierarchy.
// No cross-layer fallback: only the selected layer is populated, others null,
// final fallback global=1. Synthetic per-media states (zero pause / reverse)
// are actuated on a specific element and bypass resolution in every scope.
PSL.getMediaSpeed = function (video) {
var record = findRecord(video);
if (record && isFiniteNumber(record.speed) && record.speed <= 0) {
return record.speed;
}
// Resolver absent in partial test bundles: fall back to legacy per-media.
if (!PSL.getEffectiveSpeedFromHierarchy) {
return record ? record.speed : normalizeSpeed(getInitialSpeed(video));
}
var scope = getSelectedScope();
var layerSpeed = getSelectedLayerSpeed(video, scope, record);
// video mode, brand-new element with no record: adopt its native rate.
if (scope === "video" && layerSpeed === null && !record) {
return normalizeSpeed(getInitialSpeed(video));
}
return PSL.getEffectiveSpeedFromHierarchy({
temporarySpeed: scope === "video" ? layerSpeed : null,
tabSpeed: scope === "tab" ? layerSpeed : null,
siteSpeed: scope === "domain" ? layerSpeed : null,
globalSpeed: 1,
minSpeed: -16,
maxSpeed: 16
}).speed;
};
// The maintained value of the selected scope layer (the single source), or
// null when that layer holds nothing yet. Unlike getMediaSpeed it does NOT
// fall back to global=1, so callers can tell "maintained speed" from "nothing
// to maintain" (used by the apply hook to avoid stomping a native rate).
PSL.getMaintainedSpeed = function (video) {
var record = findRecord(video);
// A synthetic per-media state (zero pause / reverse) owns this element and
// must not be overridden by the selected layer's value — mirror getMediaSpeed.
if (record && isFiniteNumber(record.speed) && record.speed <= 0) {
return record.speed;
}
return getSelectedLayerSpeed(video, getSelectedScope(), record);
};
PSL.getMediaSpeedScope = function (video) {
var record = video && getMediaRecords().get(video);
if (!record) {
record = getRecords().get(String(getMediaId(video) || ""));
}
return record ? record.scope : "global";
};
PSL.getMediaSpeedMode = function (video) {
var speed = PSL.getMediaSpeed(video);
if (speed < 0) {
return "reverse";
}
if (speed === 0) {
return "zero";
}
return "native";
};
PSL.clearMediaSpeed = function (video) {
if (video) {
getMediaRecords().delete(video);
}
getRecords().delete(String(getMediaId(video) || ""));
};
PSL.observeNativePlaybackRate = function (video, rate) {
var speed = Number(rate);
if (!isFiniteNumber(speed)) {
return { action: "ignore" };
}
if (!PSL.hasMediaSpeed(video)) {
PSL.setMediaSpeed(video, speed, "observed");
return { action: "adopt", speed: speed };
}
if (Math.abs(PSL.getMediaSpeed(video) - speed) < 0.001) {
return { action: "confirm", speed: speed };
}
return { action: "restore", speed: PSL.getMediaSpeed(video), observedSpeed: speed };
};
PSL.getEffectivePlaybackRate = function (video) {
return PSL.getMediaSpeed(video);
};
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/media-source-lifecycle.js ----
(function (PSL) {
function getSourceKey(media) {
if (!media) {
return "";
}
return media.currentSrc || media.src || "";
}
function resetSourceScopedState(state) {
if (!state) {
return null;
}
state.loopStart = 0;
state.loopEnd = 0;
state.loopEnabled = false;
state.syntheticMode = "none";
return state;
}
function resetLoopStateForSourceChange(media, previousSourceKey) {
var currentSourceKey = getSourceKey(media);
if (previousSourceKey) {
PSL.state.startTimes[previousSourceKey] = 0;
PSL.state.endTimes[previousSourceKey] = 0;
if (PSL.state.pendingLoopPoints) {
delete PSL.state.pendingLoopPoints[previousSourceKey];
}
PSL.state.loopsEnabled[previousSourceKey] = false;
}
if (currentSourceKey) {
PSL.state.startTimes[currentSourceKey] = 0;
PSL.state.endTimes[currentSourceKey] = 0;
if (PSL.state.pendingLoopPoints) {
delete PSL.state.pendingLoopPoints[currentSourceKey];
}
PSL.state.loopsEnabled[currentSourceKey] = false;
}
if (media && PSL.disableLoop) {
PSL.disableLoop(media);
}
if (media && media._pslControllerHandle) {
if (media._pslControllerHandle.clearLoopRangeDisplay) {
media._pslControllerHandle.clearLoopRangeDisplay();
}
if (PSL.updateLoopRangeDisplay) {
PSL.updateLoopRangeDisplay(media);
}
}
}
function detectSourceChange(state, nextSourceKey) {
if (!state || !nextSourceKey) {
return false;
}
if (!state.currentSrc) {
state.currentSrc = nextSourceKey;
return false;
}
if (state.currentSrc === nextSourceKey) {
return false;
}
state.previousSrc = state.currentSrc;
state.currentSrc = nextSourceKey;
resetSourceScopedState(state);
return true;
}
PSL.getMediaSourceKey = getSourceKey;
PSL.detectMediaSourceChange = detectSourceChange;
PSL.resetLoopStateForSourceChange = resetLoopStateForSourceChange;
PSL.resetMediaSourceScopedState = resetSourceScopedState;
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/reverse-playback-engine.js ----
(function (PSL, global) {
function isValidLoop(loopStart, loopEnd) {
return (
typeof loopStart === "number" &&
typeof loopEnd === "number" &&
Number.isFinite(loopStart) &&
Number.isFinite(loopEnd) &&
loopEnd > loopStart
);
}
function createReversePlaybackEngine(options) {
var running = new WeakMap();
var setTimer = options && options.setInterval ? options.setInterval : global.setInterval.bind(global);
var clearTimer = options && options.clearInterval ? options.clearInterval : global.clearInterval.bind(global);
var now = options && options.now ? options.now : Date.now;
var defaultIntervalMs = options && options.intervalMs ? options.intervalMs : 100;
function stop(video) {
var state = video && running.get(video);
if (!state) {
return false;
}
clearTimer(state.timerId);
running.delete(video);
return true;
}
function tick(video) {
var state = running.get(video);
if (!state) {
return false;
}
if (video.isConnected === false || video._pslReversing === false) {
stop(video);
if (state.onStop) {
state.onStop(video);
}
return false;
}
if (!video.paused && typeof video.pause === "function") {
video.pause();
}
var currentNow = now();
var elapsedSeconds = Math.max(0, (currentNow - state.lastTick) / 1000);
state.lastTick = currentNow;
var stepSeconds =
typeof state.stepSeconds === "number" && Number.isFinite(state.stepSeconds)
? state.stepSeconds
: Math.abs(state.speed) * elapsedSeconds;
var nextTime = video.currentTime - stepSeconds;
if (isValidLoop(state.loopStart, state.loopEnd) && nextTime <= state.loopStart) {
video.currentTime = state.loopEnd;
return true;
}
if (nextTime <= 0) {
video.currentTime = 0;
stop(video);
if (state.onEnd) {
state.onEnd(video);
}
return true;
}
video.currentTime = nextTime;
return true;
}
function start(video, speed, startOptions) {
if (!video || !(speed < 0)) {
if (video) {
stop(video);
}
return false;
}
stop(video);
if (typeof video.pause === "function") {
video.pause();
}
var state = {
speed: speed,
loopStart: startOptions && startOptions.loopStart,
loopEnd: startOptions && startOptions.loopEnd,
onEnd: startOptions && startOptions.onEnd,
onStop: startOptions && startOptions.onStop,
stepSeconds: startOptions && startOptions.stepSeconds,
intervalMs: startOptions && startOptions.intervalMs ? startOptions.intervalMs : defaultIntervalMs,
lastTick: now(),
timerId: null
};
state.timerId = setTimer(function () {
tick(video);
}, state.intervalMs);
running.set(video, state);
return true;
}
function isRunning(video) {
return Boolean(video && running.has(video));
}
return {
isRunning: isRunning,
start: start,
stop: stop,
tick: tick
};
}
PSL.createReversePlaybackEngine = createReversePlaybackEngine;
PSL.reversePlaybackEngine = PSL.reversePlaybackEngine || createReversePlaybackEngine();
})(globalThis.PlaySpeedLoop, globalThis);
// ---- code/extension/content/media-authority.js ----
(function (PSL) {
function attachSpeedAccessors(state) {
var fallbackSpeed = 1;
var fallbackScope = "global";
Object.defineProperty(state, "targetSpeed", {
configurable: true,
enumerable: true,
get: function () {
return PSL.getMediaSpeedById
? PSL.getMediaSpeedById(state.mediaId)
: fallbackSpeed;
},
set: function (speed) {
fallbackSpeed = speed;
if (PSL.setMediaSpeedById) {
PSL.setMediaSpeedById(state.mediaId, speed, state.speedScope);
}
}
});
Object.defineProperty(state, "speedScope", {
configurable: true,
enumerable: true,
get: function () {
return PSL.getMediaSpeedRecordById
? PSL.getMediaSpeedRecordById(state.mediaId).scope
: fallbackScope;
},
set: function (scope) {
fallbackScope = scope || "global";
if (PSL.getMediaSpeedRecordById) {
PSL.getMediaSpeedRecordById(state.mediaId).scope = fallbackScope;
}
}
});
return state;
}
function normalizeState(mediaId, currentSrc) {
// A brand-new media id (e.g. the new blob source after a YouTube navigation)
// is seeded to 1x/global here as a pure actuator default. The maintained
// speed is the content runtime's selected scope layer, which re-applies it to
// the new source via applyEffectiveSpeed — the authority no longer carries
// speed across the swap itself.
if (PSL.setMediaSpeedById) {
PSL.setMediaSpeedById(mediaId, 1, "global");
}
return attachSpeedAccessors({
mediaId: mediaId,
currentSrc: currentSrc || "",
previousSrc: "",
loopStart: 0,
loopEnd: 0,
loopEnabled: false,
syntheticMode: "none"
});
}
function createMediaAuthority(options) {
var states = new Map();
var sendCommand = options && typeof options.sendCommand === "function"
? options.sendCommand
: function () {};
function getScopeRank(scope) {
if (scope === "temporary") {
return 3;
}
if (scope === "tab") {
return 2;
}
if (scope === "site") {
return 1;
}
return 0;
}
function getState(mediaId) {
var state = states.get(mediaId) || null;
return state;
}
function registerMedia(mediaId, currentSrc) {
var state = states.get(mediaId);
if (!state) {
state = normalizeState(mediaId, currentSrc);
states.set(mediaId, state);
return state;
}
if (currentSrc) {
handleSourceChange(mediaId, currentSrc);
}
return state;
}
function applyTargetSpeed(mediaId, speed, scope, reason) {
var state = registerMedia(mediaId, "");
var nextScope = scope || "temporary";
if (getScopeRank(nextScope) < getScopeRank(state.speedScope)) {
return false;
}
state.speedScope = nextScope;
if (PSL.setMediaSpeedById) {
PSL.setMediaSpeedById(mediaId, speed, nextScope);
} else {
state.targetSpeed = speed;
}
if (speed > 0) {
state.syntheticMode = "none";
sendCommand({
type: "set-playback-rate",
mediaId: mediaId,
rate: speed,
reason: reason || "target-speed"
});
return true;
}
if (speed === 0) {
state.syntheticMode = "zero";
sendCommand({
type: "set-paused-at-zero",
mediaId: mediaId,
enabled: true
});
return true;
}
state.syntheticMode = "reverse";
return true;
}
function handleObservedPlaybackRate(mediaId, observedRate) {
var state = getState(mediaId);
if (!state || state.syntheticMode !== "none" || state.targetSpeed <= 0) {
return false;
}
if (Math.abs(observedRate - state.targetSpeed) < 0.001) {
return false;
}
sendCommand({
type: "set-playback-rate",
mediaId: mediaId,
rate: state.targetSpeed,
reason: "restore-observed-rate"
});
return true;
}
function handleSourceChange(mediaId, nextSrc) {
var state = getState(mediaId);
if (!state) {
registerMedia(mediaId, nextSrc);
return false;
}
var changed = PSL.detectMediaSourceChange(state, nextSrc);
return changed;
}
function detachMedia(mediaId) {
var existed = states.delete(mediaId);
if (existed) {
sendCommand({ type: "detach-media", mediaId: mediaId });
}
return existed;
}
return {
applyTargetSpeed: applyTargetSpeed,
detachMedia: detachMedia,
getState: getState,
handleObservedPlaybackRate: handleObservedPlaybackRate,
handleSourceChange: handleSourceChange,
registerMedia: registerMedia
};
}
PSL.createMediaAuthority = createMediaAuthority;
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/page-world-bridge.js ----
(function (PSL, global) {
var DISABLED_FLAG = "PSL_V2_PAGE_WORLD_DISABLED";
var hookPath = "extension/page-world/media-hook.js";
var mediaById = new Map();
function isProbeEnabled() {
var result;
try {
result = !global.localStorage || global.localStorage.getItem(DISABLED_FLAG) !== "1";
} catch (e) {
result = true;
}
return result;
}
function getEventTarget() {
var result = typeof global.addEventListener === "function" ? global : global.window;
return result;
}
function createCustomEvent(type, detail) {
var EventCtor = global.CustomEvent || (global.window && global.window.CustomEvent);
var encodedDetail = encodeBridgePayload(detail);
if (typeof EventCtor === "function") {
return new EventCtor(type, { detail: encodedDetail });
}
return { type: type, detail: encodedDetail };
}
function encodeBridgePayload(detail) {
return JSON.stringify(detail || {});
}
function decodeBridgePayload(detail) {
if (typeof detail === "string") {
try {
return JSON.parse(detail);
} catch (e) {
return null;
}
}
return detail || null;
}
function getMediaId(media) {
var mediaId = PSL.getMediaKey ? PSL.getMediaKey(media) : media.currentSrc || media.src;
return mediaId;
}
function appendScript(doc, channelId, src) {
var script = doc.createElement("script");
script.src = src;
script.async = false;
script.dataset.pslV2ProbeChannel = channelId;
script.onload = function () {
if (script.parentNode) {
script.parentNode.removeChild(script);
}
};
(doc.documentElement || doc.head || doc.body).appendChild(script);
}
function installProbe(doc) {
if (!doc || doc.__pslV2PageWorldProbeInstalled) {
return false;
}
var src = PSL.getAssetUrl(hookPath);
if (!src) {
return false;
}
var channelId = "psl-v2-probe-" + PSL.instanceId;
var fromPage = channelId + ":from-page";
var toPage = channelId + ":to-page";
var eventTarget = getEventTarget();
function sendCommand(command) {
if (eventTarget && typeof eventTarget.dispatchEvent === "function") {
eventTarget.dispatchEvent(createCustomEvent(toPage, command));
}
}
PSL.v2MediaAuthority = PSL.v2MediaAuthority || PSL.createMediaAuthority({
sendCommand: sendCommand
});
doc.__pslV2PageWorldProbeInstalled = true;
if (eventTarget && typeof eventTarget.addEventListener === "function") {
eventTarget.addEventListener(fromPage, function (event) {
var detail = decodeBridgePayload(event.detail);
PSL.pageWorldBridge.handlePageEvent(detail);
});
}
appendScript(doc, channelId, src);
return true;
}
function handlePageEvent(detail) {
var authority = PSL.v2MediaAuthority;
if (!authority || !detail || !detail.mediaId) {
return;
}
if (
detail.type === "media-seen" ||
detail.type === "loadedmetadata" ||
detail.type === "play" ||
detail.type === "pause" ||
detail.type === "load"
) {
authority.registerMedia(detail.mediaId, detail.currentSrc || "");
return;
}
if (detail.type === "sourcechange") {
// Register the new blob source with the authority. The authority no longer
// carries speed across the swap — the single source of truth is the
// selected scope layer (re-applied by the content runtime, not here).
authority.handleSourceChange(detail.mediaId, detail.currentSrc || "");
resetLoopForPageSourceChange(detail);
return;
}
if (
detail.type === "ratechange" ||
detail.type === "playbackRate-set" ||
detail.type === "defaultPlaybackRate-set"
) {
authority.registerMedia(detail.mediaId, detail.currentSrc || "");
if (detail.commandedPlaybackRate === true) {
return;
}
authority.handleObservedPlaybackRate(detail.mediaId, Number(detail.playbackRate));
}
}
function applySpeed(video, speed, scope, reason) {
var mediaId;
if (!PSL.v2MediaAuthority) {
return false;
}
mediaId = getMediaId(video);
mediaById.set(mediaId, video);
PSL.v2MediaAuthority.registerMedia(mediaId, PSL.getMediaSourceKey(video));
PSL.v2MediaAuthority.applyTargetSpeed(mediaId, speed, scope, reason);
return true;
}
function resetLoopForPageSourceChange(detail) {
var media = mediaById.get(detail.mediaId) || mediaById.get(detail.previousSrc);
if (media && PSL.resetLoopStateForSourceChange) {
PSL.resetLoopStateForSourceChange(media, detail.previousSrc);
}
if (media && detail.currentSrc) {
mediaById.set(detail.currentSrc, media);
}
}
PSL.pageWorldBridge = {
applySpeed: applySpeed,
handlePageEvent: handlePageEvent,
installProbe: installProbe,
isProbeEnabled: isProbeEnabled
};
if (isProbeEnabled()) {
installProbe(document);
}
})(globalThis.PlaySpeedLoop, globalThis);
// ---- code/extension/content/site-placement.js ----
(function (PSL) {
PSL.isAmazonVideoDocument = function (doc, host) {
host = host || (doc.location ? doc.location.hostname : location.hostname);
return (
/(^|\.)amazon\./.test(host) ||
/(^|\.)primevideo\.com$/.test(host) ||
Boolean(doc.getElementById("dv-web-player"))
);
};
function findAmazonVideoContainer(doc) {
var selectors = [
"#dv-web-player",
".dv-player-fullscreen",
".atvwebplayersdk-player-container",
".atvwebplayersdk-overlay-container",
".atvwebplayersdk-overlays-container",
".atvwebplayersdk-persistent-component-container",
".webPlayerSDKContainer"
];
for (var i = 0; i < selectors.length; i++) {
var container = doc.querySelector(selectors[i]);
if (container) {
return container;
}
}
return doc.body;
}
PSL.placeController = function (videoController, fragment) {
var doc = videoController.video.ownerDocument;
var host = doc.location ? doc.location.hostname : location.hostname;
var parent = videoController.parent;
var wrapper = videoController.wrapper;
switch (true) {
case PSL.isAmazonVideoDocument(doc, host):
wrapper.style.position = "fixed";
wrapper.style.zIndex = "2147483647";
wrapper.style.top = "16px";
wrapper.style.left = "16px";
wrapper.style.width = "280px";
wrapper.style.height = "90px";
wrapper.style.pointerEvents = "auto";
findAmazonVideoContainer(doc).appendChild(fragment);
break;
default:
parent.insertBefore(fragment, parent.firstChild);
}
};
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/overlay-critical-css.js ----
(function (PSL) {
PSL.getOverlayCriticalCss = function () {
return (
"#controller.panel-collapsed:not(.expanded) #speed-row," +
"#controller.panel-collapsed:not(.expanded) #loop-row," +
"#controller.panel-collapsed:not(.expanded) .collapse-control{" +
"display:none !important;" +
"}" +
"#controller.expanded #collapsed-summary," +
"#controller.expanded .expand-control{" +
"display:none !important;" +
"}"
);
};
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/overlay-template.js ----
(function (PSL) {
function appendTextElement(doc, parent, tagName, text, className, id) {
var element = doc.createElement(tagName);
if (id) {
element.id = id;
}
if (className) {
element.className = className;
}
element.textContent = text;
parent.appendChild(element);
return element;
}
function appendButton(doc, parent, action, text, className, id) {
var button = appendTextElement(doc, parent, "button", text, className, id);
if (action) {
button.dataset.action = action;
}
return button;
}
PSL.buildOverlayStyleElement = function (doc) {
var style = doc.createElement("style");
var overlayStyleUrl = PSL.getAssetUrl("extension/styles/overlay-shadow.css");
style.textContent =
(overlayStyleUrl ? '@import "' + overlayStyleUrl + '";' : "") +
PSL.getOverlayCriticalCss();
return style;
};
PSL.buildOverlayControllerContent = function (doc, options) {
var fragment = doc.createDocumentFragment();
var controller = doc.createElement("div");
var controllerClasses = [];
if (options.speedControlsEnabled) {
controllerClasses.push("speed-enabled");
}
if (options.loopControlsEnabled) {
controllerClasses.push("loop-enabled");
}
controller.id = "controller";
controllerClasses.push("panel-collapsed");
controller.className = controllerClasses.join(" ");
controller.style.top = options.top;
controller.style.left = options.left;
if (controller.style.setProperty) {
controller.style.setProperty("--psl-panel-background-opacity", String(options.opacity));
} else {
controller.style["--psl-panel-background-opacity"] = String(options.opacity);
}
if (options.speedControlsEnabled) {
var speedRow = doc.createElement("div");
speedRow.id = "speed-row";
speedRow.className = "control-row";
appendTextElement(doc, speedRow, "span", "Speed", "row-label");
appendButton(doc, speedRow, "slower", "-");
var speedExpandedSlot = doc.createElement("span");
speedExpandedSlot.dataset.pslSlot = "speed-expanded";
speedRow.appendChild(speedExpandedSlot);
appendButton(doc, speedRow, "faster", "+");
appendButton(doc, speedRow, "reset", "Reset", "reset-button");
controller.appendChild(speedRow);
}
if (options.loopControlsEnabled || options.speedControlsEnabled) {
var collapsedSummary = doc.createElement("div");
collapsedSummary.id = "collapsed-summary";
collapsedSummary.className = "body";
var speedCompactSlot = doc.createElement("span");
speedCompactSlot.dataset.pslSlot = "speed-compact";
collapsedSummary.appendChild(speedCompactSlot);
if (options.loopControlsEnabled) {
var loopSummarySlot = doc.createElement("span");
loopSummarySlot.dataset.pslSlot = "loop-summary";
loopSummarySlot.className = "loop-summary";
collapsedSummary.appendChild(loopSummarySlot);
}
controller.appendChild(collapsedSummary);
}
if (options.loopControlsEnabled) {
var loopRow = doc.createElement("div");
loopRow.id = "loop-row";
loopRow.className = "control-row";
appendTextElement(doc, loopRow, "span", "Loop", "row-label");
appendTextElement(doc, loopRow, "span", "--:--", "time-value empty", "start-indicator");
appendTextElement(doc, loopRow, "span", "--:--", "time-value empty", "end-indicator");
appendButton(doc, loopRow, "clear-loop", "Reset", "reset-button");
controller.appendChild(loopRow);
}
appendButton(
doc,
controller,
null,
"",
"panel-toggle chevron collapse-control",
"collapse-button"
).setAttribute("aria-label", "Collapse controller");
appendButton(
doc,
controller,
null,
"",
"panel-toggle toggle expand-control",
"expand-button"
).setAttribute("aria-label", "Expand controller");
fragment.appendChild(PSL.buildOverlayStyleElement(doc));
fragment.appendChild(controller);
return fragment;
};
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/speed-indicator-view.js ----
(function (PSL) {
PSL.formatSpeedDisplayValue = function (speed) {
return speed.toFixed(2);
};
PSL.createSpeedView = function (options) {
var video = options.video;
var doc = options.doc;
var compactSlot = options.compactSlot || options.mountSlot;
var expandedSlot = options.expandedSlot || compactSlot;
var root = doc.createElement("span");
root.id = "speed-indicator";
root.className = "speed-value";
root.dataset.pslOwner = "speed-view";
function moveTo(slot) {
if (slot && root.parentNode !== slot) {
slot.appendChild(root);
}
}
function render() {
var speed = PSL.getMediaSpeed ? PSL.getMediaSpeed(video) : video.playbackRate;
root.textContent = PSL.formatSpeedDisplayValue(speed);
}
return {
mount: function () {
moveTo(compactSlot);
},
render: render,
showCompact: function () {
moveTo(compactSlot);
},
showExpanded: function () {
moveTo(expandedSlot);
},
destroy: function () {
root.remove();
}
};
};
PSL.renderSpeedView = function (video) {
if (!video._pslControllerHandle || !video._pslControllerHandle.renderSpeedView) {
return;
}
video._pslControllerHandle.renderSpeedView();
};
PSL.updateSpeedIndicator = function (video) {
PSL.renderSpeedView(video);
};
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/loop-actions.js ----
(function (PSL) {
function getLoopKey(video) {
return PSL.getMediaKey(video);
}
function hasNumber(value) {
return typeof value === "number" && !isNaN(value);
}
function getEffectiveRate(video) {
if (PSL.getEffectivePlaybackRate) {
return PSL.getEffectivePlaybackRate(video);
}
return video.playbackRate;
}
function normalizeEndpoint(video, time) {
var point = Number(time);
if (isNaN(point) || point < 0) {
return NaN;
}
if (video.duration && point >= video.duration) {
return video.duration - 0.05;
}
return point;
}
function formatSummary(left, right, pending, enabled) {
if (hasNumber(pending)) {
return {
text: PSL.convertSecToMin(pending) + " / __:__",
left: PSL.convertSecToMin(pending),
right: "__:__",
rightBlank: true
};
}
if (enabled && hasNumber(left) && hasNumber(right) && right > left) {
return {
text: PSL.convertSecToMin(left) + " / " + PSL.convertSecToMin(right),
left: PSL.convertSecToMin(left),
right: PSL.convertSecToMin(right),
rightBlank: false
};
}
return {
text: "",
left: "",
right: "",
rightBlank: false
};
}
PSL.convertSecToMin = function (timeInSecs) {
var tempDate = new Date(null);
tempDate.setSeconds(Math.round(timeInSecs));
return timeInSecs >= 3600
? tempDate.toISOString().substring(11, 19)
: tempDate.toISOString().substring(14, 19);
};
PSL.getLoopSummary = function (video) {
var src = getLoopKey(video);
var left = PSL.state.startTimes[src];
var right = PSL.state.endTimes[src];
var pending = PSL.state.pendingLoopPoints[src];
return formatSummary(left, right, pending, PSL.state.loopsEnabled[src]);
};
PSL.renderLoopSummary = function (video) {
var summary = PSL.getLoopSummary(video);
return summary.text;
};
PSL.updateCollapsedLoopSummary = function (video) {
return PSL.renderLoopSummary(video);
};
PSL.updateLoopRangeDisplay = function (video) {
var handle = video._pslControllerHandle;
if (!handle || !handle.renderLoopRange) {
return;
}
handle.renderLoopRange();
};
PSL.markLoopRange = function (video, time) {
var src = getLoopKey(video);
var point = normalizeEndpoint(video, time);
var pending = PSL.state.pendingLoopPoints[src];
var left;
var right;
if (isNaN(point)) {
return;
}
if (!hasNumber(pending) || PSL.state.loopsEnabled[src]) {
PSL.disableLoop(video);
PSL.state.pendingLoopPoints[src] = point;
PSL.state.startTimes[src] = point;
PSL.state.endTimes[src] = 0;
PSL.updateLoopRangeDisplay(video);
return;
}
left = Math.min(pending, point);
right = Math.max(pending, point);
if (right <= left) {
PSL.updateLoopRangeDisplay(video);
return;
}
PSL.state.startTimes[src] = left;
PSL.state.endTimes[src] = right;
delete PSL.state.pendingLoopPoints[src];
PSL.enableLoop(video);
PSL.updateLoopRangeDisplay(video);
};
PSL.setLoopRange = function (video, left, right) {
var src = getLoopKey(video);
var normalizedLeft = normalizeEndpoint(video, Math.min(left, right));
var normalizedRight = normalizeEndpoint(video, Math.max(left, right));
if (isNaN(normalizedLeft) || isNaN(normalizedRight) || normalizedRight <= normalizedLeft) {
PSL.clearLoop(video);
return;
}
PSL.state.startTimes[src] = normalizedLeft;
PSL.state.endTimes[src] = normalizedRight;
delete PSL.state.pendingLoopPoints[src];
PSL.enableLoop(video);
PSL.updateLoopRangeDisplay(video);
};
PSL.enableLoop = function (video) {
var src = getLoopKey(video);
var left = PSL.state.startTimes[src];
var right = PSL.state.endTimes[src];
if (!hasNumber(left) || !hasNumber(right) || right <= left) {
PSL.state.loopsEnabled[src] = false;
PSL.updateLoopRangeDisplay(video);
return;
}
PSL.state.loopsEnabled[src] = true;
var handle = video._pslControllerHandle;
if (!handle || !handle.setLoopHandler) {
PSL.updateLoopRangeDisplay(video);
return;
}
handle.setLoopHandler(function () {
var currentSrc = getLoopKey(video);
var currentLeft = PSL.state.startTimes[currentSrc];
var currentRight = PSL.state.endTimes[currentSrc];
var rate = getEffectiveRate(video);
if (!PSL.state.loopsEnabled[currentSrc]) {
if (video._pslControllerHandle && video._pslControllerHandle.clearLoopHandler) {
video._pslControllerHandle.clearLoopHandler();
}
return;
}
if (rate < 0) {
if (video.currentTime <= currentLeft) {
video.currentTime = currentRight;
}
return;
}
if (video.currentTime >= currentRight || video.currentTime < currentLeft) {
video.currentTime = currentLeft;
}
});
PSL.updateLoopRangeDisplay(video);
};
PSL.disableLoop = function (video) {
var src = getLoopKey(video);
var handle = video._pslControllerHandle;
PSL.state.loopsEnabled[src] = false;
if (handle && handle.clearLoopHandler) {
handle.clearLoopHandler();
}
PSL.updateLoopRangeDisplay(video);
};
PSL.clearLoop = function (video) {
var src = getLoopKey(video);
PSL.disableLoop(video);
PSL.state.startTimes[src] = 0;
PSL.state.endTimes[src] = 0;
delete PSL.state.pendingLoopPoints[src];
PSL.updateLoopRangeDisplay(video);
};
PSL.updateLoopToggleDisplay = function (video) {
PSL.updateLoopRangeDisplay(video);
};
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/loop-view.js ----
(function (PSL) {
function hasNumber(value) {
return typeof value === "number" && !isNaN(value);
}
function setIndicator(indicator, text, empty) {
if (!indicator) {
return;
}
indicator.textContent = text;
if (indicator.classList) {
indicator.classList.toggle("empty", Boolean(empty));
}
}
function appendSummaryPart(doc, parent, text, className) {
var span = doc.createElement("span");
span.className = className;
span.textContent = text;
parent.appendChild(span);
}
PSL.createLoopView = function (options) {
var video = options.video;
var startSlot = options.startSlot;
var endSlot = options.endSlot;
var summarySlot = options.summarySlot;
function renderSummary() {
var summary = PSL.getLoopSummary ? PSL.getLoopSummary(video) : { text: "" };
var doc;
if (!summarySlot) {
return summary.text;
}
if (!summarySlot.ownerDocument || !summarySlot.replaceChildren) {
summarySlot.textContent = summary.text;
return summary.text;
}
doc = summarySlot.ownerDocument;
summarySlot.replaceChildren();
if (summary.left || summary.right) {
appendSummaryPart(doc, summarySlot, summary.left, "summary-time");
appendSummaryPart(doc, summarySlot, " / ", "summary-separator");
appendSummaryPart(
doc,
summarySlot,
summary.right,
summary.rightBlank ? "summary-time blank" : "summary-time"
);
}
return summary.text;
}
function clearRange() {
setIndicator(startSlot, "--:--", true);
setIndicator(endSlot, "--:--", true);
if (summarySlot) {
if (summarySlot.replaceChildren) {
summarySlot.replaceChildren();
} else {
summarySlot.textContent = "";
}
}
}
return {
renderRange: function () {
var src = PSL.getMediaKey(video);
var left = PSL.state.startTimes[src];
var right = PSL.state.endTimes[src];
var pending = PSL.state.pendingLoopPoints[src];
if (hasNumber(pending)) {
setIndicator(startSlot, PSL.convertSecToMin(pending), false);
setIndicator(endSlot, "--:--", true);
} else if (PSL.state.loopsEnabled[src] && hasNumber(left) && hasNumber(right) && right > left) {
setIndicator(startSlot, PSL.convertSecToMin(left), false);
setIndicator(endSlot, PSL.convertSecToMin(right), false);
} else {
setIndicator(startSlot, "--:--", true);
setIndicator(endSlot, "--:--", true);
}
renderSummary();
},
clearRange: clearRange,
renderSummary: renderSummary,
destroy: clearRange
};
};
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/media-overlay-controller.js ----
(function (PSL) {
function hasMediaSource(media) {
return Boolean(media.currentSrc || media.src || media.srcObject);
}
function createControllerHandle(controller) {
return {
isCurrentInstance: function (instanceId) {
return controller._pslInstanceId === instanceId;
},
getParent: function () {
return controller.parent;
},
matchesControllerElement: function (element) {
return Boolean(element && controller.div === element);
},
remove: function () {
controller.remove();
},
setPanelExpanded: function (expanded) {
controller.setPanelExpanded(expanded);
},
renderSpeedView: function () {
controller.renderSpeedView();
},
renderLoopRange: function () {
controller.renderLoopRange();
},
clearLoopRangeDisplay: function () {
controller.clearLoopRangeDisplay();
},
setLoopHandler: function (handler) {
controller.setLoopHandler(handler);
},
clearLoopHandler: function () {
controller.clearLoopHandler();
},
showController: function () {
controller.showController();
},
blinkController: function () {
controller.blinkController();
},
updateSourceVisibility: function () {
controller.updateSourceVisibility();
},
maintainSpeed: function (trigger) {
controller.maintainSpeed(trigger || "handle");
}
};
}
PSL.VideoController = function (target, parent) {
var controller = this;
if (target._pslControllerHandle) {
return target._pslControllerHandle;
}
PSL.registerMedia(target);
this._pslInstanceId = PSL.instanceId;
this.video = target;
this.parent = target.parentElement || parent;
this._loopHandler = null;
this.updateSourceVisibility = function () {
if (!controller.div) {
return;
}
controller.div.classList.toggle("mpc-nosource", !hasMediaSource(target));
};
// On attach and every source swap, apply the maintained speed (the selected
// scope layer: tab / domain / video) to the new source exactly once. This is
// the single source of truth — no fallback chain. New videos resolve the
// selected layer; reverse carries too (see speed-apply.js).
this.maintainSpeed = function (trigger) {
if (PSL.applyEffectiveSpeed) {
PSL.applyEffectiveSpeed(target);
}
};
PSL.state.startTimes[PSL.getMediaKey(target)] = 0;
target.addEventListener(
"loadedmetadata",
(this.handleLoadedMetadata = function () {
controller.updateSourceVisibility();
controller.maintainSpeed("loadedmetadata");
})
);
PSL.state.loopsEnabled[PSL.getMediaKey(target)] = false;
this.div = this.initializeControls();
target._pslControllerHandle = createControllerHandle(this);
this.maintainSpeed("attach");
if (PSL.updateLoopRangeDisplay) {
PSL.updateLoopRangeDisplay(this.video);
}
this.handleSourceVisibilityUpdate = function (event) {
controller.updateSourceVisibility();
controller.maintainSpeed((event && event.type) || "sourceVisibility");
};
target.addEventListener("loadstart", this.handleSourceVisibilityUpdate);
target.addEventListener("loadeddata", this.handleSourceVisibilityUpdate);
target.addEventListener("canplay", this.handleSourceVisibilityUpdate);
target.addEventListener("emptied", this.handleSourceVisibilityUpdate);
this.sourceObserver = new MutationObserver(
function (mutations) {
mutations.forEach(function (mutation) {
if (
mutation.type === "attributes" &&
(mutation.attributeName === "src" || mutation.attributeName === "currentSrc")
) {
PSL.log("mutation of A/V element", 5);
if (target._pslControllerHandle) {
controller.updateSourceVisibility();
controller.maintainSpeed("srcMutation:" + mutation.attributeName);
}
}
});
}
);
this.sourceObserver.observe(target, {
attributeFilter: ["src", "currentSrc"]
});
};
PSL.VideoController.prototype.remove = function () {
if (this.destroySpeedView) {
this.destroySpeedView();
}
this.div.remove();
this.video.removeEventListener("loadedmetadata", this.handleLoadedMetadata);
this.video.removeEventListener("loadstart", this.handleSourceVisibilityUpdate);
this.video.removeEventListener("loadeddata", this.handleSourceVisibilityUpdate);
this.video.removeEventListener("canplay", this.handleSourceVisibilityUpdate);
this.video.removeEventListener("emptied", this.handleSourceVisibilityUpdate);
if (this._loopHandler) {
this.video.removeEventListener("timeupdate", this._loopHandler);
}
if (this.sourceObserver) {
this.sourceObserver.disconnect();
}
if (PSL.stopSyntheticSpeed) {
PSL.stopSyntheticSpeed(this.video);
}
delete this.video._pslControllerHandle;
PSL.unregisterMedia(this.video);
};
PSL.VideoController.prototype.initializeControls = function () {
PSL.log("initializeControls Begin", 5);
var doc = this.video.ownerDocument;
var top = Math.max(this.video.offsetTop, 0) + "px";
var left = Math.max(this.video.offsetLeft, 0) + "px";
var wrapper = doc.createElement("div");
wrapper.classList.add("mpc-controller");
wrapper._pslInstanceId = PSL.instanceId;
wrapper._pslMediaElement = this.video;
// Page-world-readable mirror of the bound media/instance (the expandos above
// are invisible across the content/page boundary). Lets a page console probe
// tell whether two .mpc-controller wrappers share a media element and a
// content instance when diagnosing duplicate panels.
try {
wrapper.dataset.pslMedia = String(PSL.getMediaKey ? PSL.getMediaKey(this.video) : "");
wrapper.dataset.pslInstance = String(PSL.instanceId);
wrapper.dataset.pslFrame = window.self === window.top ? "top" : "iframe";
} catch (e) {}
if (!hasMediaSource(this.video)) {
wrapper.classList.add("mpc-nosource");
}
var shadow = wrapper.attachShadow({ mode: "closed" });
shadow.appendChild(PSL.buildOverlayControllerContent(doc, {
top: top,
left: left,
opacity: PSL.settings.controllerOpacity,
speedControlsEnabled: PSL.settings.speedControlsEnabled !== false,
loopControlsEnabled: PSL.settings.loopControlsEnabled !== false
}));
wrapper._shadow = shadow;
shadow.querySelectorAll("button").forEach(function (button) {
button.addEventListener(
"click",
function (e) {
var eventTarget = e.target || button;
var action = eventTarget.dataset.action;
if (!action) {
return;
}
PSL.runAction(action, PSL.getKeyBinding(action), e);
e.stopPropagation();
},
true
);
});
var controllerElement = shadow.querySelector("#controller");
var collapseButton = shadow.querySelector("#collapse-button");
var expandButton = shadow.querySelector("#expand-button");
var speedExpandedSlot = shadow.querySelector('[data-psl-slot="speed-expanded"]');
var speedCompactSlot = shadow.querySelector('[data-psl-slot="speed-compact"]');
var loopSummarySlot = shadow.querySelector('[data-psl-slot="loop-summary"]');
var startSlot = shadow.querySelector("#start-indicator");
var endSlot = shadow.querySelector("#end-indicator");
var speedView = null;
var loopView = null;
var setPanelExpanded = function (expanded) {
controllerElement.classList.toggle("expanded", Boolean(expanded));
controllerElement.classList.toggle("panel-collapsed", !expanded);
if (!speedView) {
return;
}
if (expanded) {
speedView.showExpanded();
} else {
speedView.showCompact();
}
};
if (PSL.createSpeedView && speedCompactSlot) {
speedView = PSL.createSpeedView({
video: this.video,
doc: doc,
compactSlot: speedCompactSlot,
expandedSlot: speedExpandedSlot
});
speedView.mount();
speedView.render();
}
if (PSL.createLoopView) {
loopView = PSL.createLoopView({
video: this.video,
startSlot: startSlot,
endSlot: endSlot,
summarySlot: loopSummarySlot
});
}
this.renderSpeedView = function () {
if (speedView) {
speedView.render();
}
};
this.destroySpeedView = function () {
if (speedView) {
speedView.destroy();
speedView = null;
}
};
this.renderLoopRange = function () {
if (loopView) {
loopView.renderRange();
}
};
this.clearLoopRangeDisplay = function () {
if (loopView) {
loopView.clearRange();
}
};
this.setLoopHandler = function (handler) {
this.clearLoopHandler();
if (handler) {
this._loopHandler = handler;
this.video.addEventListener("timeupdate", handler);
}
};
this.clearLoopHandler = function () {
if (this._loopHandler) {
this.video.removeEventListener("timeupdate", this._loopHandler);
this._loopHandler = null;
}
};
this.showController = function () {
wrapper.classList.add("mpc-show");
wrapper.classList.remove("mpc-hidden");
};
this.blinkController = function () {
wrapper.classList.add("mpc-show");
wrapper.classList.remove("mpc-hidden");
};
if (collapseButton) {
collapseButton.addEventListener("click", function (e) {
setPanelExpanded(false);
e.stopPropagation();
}, true);
}
if (expandButton) {
expandButton.addEventListener("click", function (e) {
setPanelExpanded(true);
e.stopPropagation();
}, true);
}
controllerElement.addEventListener("mouseenter", function (e) { return e.stopPropagation(); }, false);
controllerElement.addEventListener("mouseleave", function (e) { return e.stopPropagation(); }, false);
controllerElement.addEventListener("click", function (e) { return e.stopPropagation(); }, false);
controllerElement.addEventListener("mousedown", function (e) { return e.stopPropagation(); }, false);
this.setPanelExpanded = setPanelExpanded;
var fragment = doc.createDocumentFragment();
fragment.appendChild(wrapper);
this.div = wrapper;
PSL.placeController({
video: this.video,
parent: this.parent,
wrapper: wrapper
}, fragment);
return wrapper;
};
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/controller-attachment-lifecycle.js ----
(function (PSL) {
function isCurrentController(media) {
try {
return (
media._pslControllerHandle &&
media._pslControllerHandle.isCurrentInstance &&
media._pslControllerHandle.isCurrentInstance(PSL.instanceId)
);
} catch (e) {
return false;
}
}
function isControllerAttachedToCurrentParent(media) {
try {
return (
media._pslControllerHandle &&
media._pslControllerHandle.getParent &&
media._pslControllerHandle.getParent() === media.parentElement
);
} catch (e) {
return false;
}
}
function removeOrphanControllerElements(media) {
var doc = media && media.ownerDocument;
if (!doc || !doc.querySelectorAll) {
return;
}
doc.querySelectorAll(".mpc-controller").forEach(function (controller) {
if (
controller._pslInstanceId === PSL.instanceId &&
controller._pslMediaElement === media &&
(!media._pslControllerHandle ||
!media._pslControllerHandle.matchesControllerElement ||
!media._pslControllerHandle.matchesControllerElement(controller))
) {
controller.remove();
}
});
}
PSL.removeControllerFromMedia = function (media) {
if (!media._pslControllerHandle) {
removeOrphanControllerElements(media);
return;
}
try {
if (typeof media._pslControllerHandle.remove === "function") {
media._pslControllerHandle.remove();
return;
}
} catch (e) {
// Fall through and remove the visible controller element directly.
}
delete media._pslControllerHandle;
PSL.unregisterMedia(media);
removeOrphanControllerElements(media);
};
PSL.attachControllerToMedia = function (media) {
var mediaDoc = media && media.ownerDocument;
removeOrphanControllerElements(media);
if (!PSL.shouldAttachController()) {
return;
}
// Do not attach to invisible/zero-size media (e.g. a site's zero-size
// transformed <video> layer). Existing controllers are left untouched; this
// only suppresses new attachment. Guarded so partial bundles without
// feature-gates keep working.
if (PSL.isAttachableMediaTarget && !PSL.isAttachableMediaTarget(media)) {
return;
}
if (isCurrentController(media)) {
if (!isControllerAttachedToCurrentParent(media)) {
PSL.removeControllerFromMedia(media);
new PSL.VideoController(media);
return;
}
// The element already has this runtime's controller attached to its
// current parent. YouTube reuses the SAME <video> element across
// recommendation / SPA navigations (only currentSrc changes), and a
// stale-controller sweep during the navigation churn can unregister the
// element from state.mediaElements while leaving _pslControllerHandle
// intact. Re-register (idempotent) so keyboard runAction — which iterates
// state.mediaElements and skips handle-less entries — keeps reaching the
// reused player after navigation. Without this, s/d silently stops working
// on every video opened after the first.
PSL.registerMedia(media);
if (media._pslControllerHandle && media._pslControllerHandle.maintainSpeed) {
media._pslControllerHandle.maintainSpeed("rescan");
}
return;
}
PSL.removeControllerFromMedia(media);
if (!media._pslControllerHandle) {
new PSL.VideoController(media);
}
};
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/speed-domain-store.js ----
(function (PSL) {
function getSpeedDomain(video) {
var doc = (video && video.ownerDocument) || document;
var host = "";
try {
host = doc.location && doc.location.hostname;
} catch (e) {}
return String(host || "").replace(/^www\./, "");
}
// A speed worth persisting as a domain (site) speed: any non-zero finite speed
// in the supported range. Negative (reverse) speeds are stored too so reverse
// is maintained across navigation in domain mode; zero (pause) is a momentary
// per-media state and is never stored.
PSL.isStorableDomainSpeed = function (speed) {
return (
typeof speed === "number" &&
Number.isFinite(speed) &&
speed !== 0 &&
speed >= -16 &&
speed <= 16
);
};
PSL.saveDomainSpeed = function (video, speed) {
var domain = getSpeedDomain(video);
var domainSpeeds;
if (!domain || !PSL.isStorableDomainSpeed(speed)) {
return;
}
domainSpeeds = Object.assign({}, PSL.settings.domainSpeeds || {});
domainSpeeds[domain] = speed;
PSL.settings.domainSpeeds = domainSpeeds;
PSL.settings.lastSpeed = speed;
PSL.setStorage(
{
lastSpeed: speed,
domainSpeeds: domainSpeeds
},
function (error) {
if (error) {
PSL.log("Failed to save domain speed for " + domain + ": " + error, 3);
return;
}
PSL.log("Domain speed setting saved for " + domain + ": " + speed, 5);
}
);
};
PSL.getSavedDomainSpeed = function (video) {
var domain = getSpeedDomain(video);
return domain && PSL.settings.domainSpeeds
? Number(PSL.settings.domainSpeeds[domain])
: NaN;
};
PSL.shouldRestoreSavedDomainSpeed = function (video) {
return (
(!PSL.hasMediaSpeed || !PSL.hasMediaSpeed(video)) &&
PSL.isStorableDomainSpeed(PSL.getSavedDomainSpeed(video))
);
};
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/keep-speed-site-matcher.js ----
(function (PSL) {
function normalizeKeepSpeedPattern(pattern) {
pattern = String(pattern || "").replace(PSL.regStrip, "");
if (!pattern) {
return "";
}
try {
if (/^https?:\/\//i.test(pattern)) {
return new URL(pattern).hostname;
}
} catch (e) {}
return pattern.replace(/^www\./, "");
}
PSL.buildKeepSpeedMatcher = function (pattern) {
var normalized = normalizeKeepSpeedPattern(pattern);
var regex;
if (!normalized) {
return null;
}
if (normalized.startsWith("/")) {
try {
regex = new RegExp(normalized.slice(1, -1));
} catch (e) {
return null;
}
return function (host) {
return regex.test(host);
};
}
return function (host) {
return host === normalized || host.endsWith("." + normalized);
};
};
PSL.buildKeepSpeedMatchers = function (patterns) {
return String(patterns || "")
.split("\n")
.map(PSL.buildKeepSpeedMatcher)
.filter(Boolean);
};
PSL.updateKeepSpeedSiteMatchers = function () {
PSL.keepSpeedSiteMatchers = PSL.buildKeepSpeedMatchers(
PSL.settings.keepSpeedSites
);
};
PSL.isKeepSpeedSite = function (video) {
var doc = video.ownerDocument || document;
var host = "";
try {
host = (doc.location && doc.location.hostname || "").replace(/^www\./, "");
} catch (e) {}
return (PSL.keepSpeedSiteMatchers || []).some(function (matches) {
return matches(host);
});
};
PSL.updateKeepSpeedSiteMatchers();
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/keep-speed-site-enforcement.js ----
(function (PSL) {
var KEEP_SPEED_ENFORCEMENT_INTERVAL_MS = 50;
function isPositivePlaybackRate(speed) {
return typeof speed === "number" && !isNaN(speed) && speed > 0 && speed <= 16;
}
PSL.stopPersistentSpeedEnforcement = function (video) {
if (video._pslSpeedEnforcer) {
clearInterval(video._pslSpeedEnforcer);
video._pslSpeedEnforcer = null;
}
};
PSL.startPersistentSpeedEnforcement = function (video) {
PSL.stopPersistentSpeedEnforcement(video);
// Persistent 50ms forcing runs ONLY on user-registered keep-speed sites
// (e.g. niconico) that actively reset playbackRate. Everywhere else (YouTube
// is never a keep-speed site by default) speed is held by set-once: the
// direct assignment in setSpeed, the temporary 3s enforcement window, and the
// page-world authority's event-driven restore. Starting an interval per video
// here previously spawned one 50ms loop for every positive-rate media —
// including each YouTube thumbnail preview — that accumulated and never
// stopped.
if (!PSL.isKeepSpeedSite(video)) {
return;
}
video._pslSpeedEnforcer = setInterval(function () {
var target = PSL.getMediaSpeed(video);
if (
!video.isConnected ||
!isPositivePlaybackRate(target) ||
video._pslPausedForZeroSpeed ||
video._pslReversing
) {
PSL.stopPersistentSpeedEnforcement(video);
return;
}
// The 50ms keep-speed enforcer is a content-world writer that bypasses the
// page-world setter guard. Trace every actual write (each one fires a
// ratechange a site comment renderer can resync on); leave no-op ticks
// untraced to avoid 20/sec flooding.
if (Math.abs(video.playbackRate - target) > 0.001) {
video.playbackRate = target;
}
PSL.updateSpeedIndicator(video);
}, KEEP_SPEED_ENFORCEMENT_INTERVAL_MS);
};
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/temporary-playback-rate-enforcement.js ----
(function (PSL) {
PSL.shouldTemporarilyEnforceSpeed = function (video) {
return (
PSL.hasMediaSpeed &&
PSL.hasMediaSpeed(video) &&
video._pslEnforceSpeedUntil &&
Date.now() < video._pslEnforceSpeedUntil
);
};
PSL.enforceSpeedSoon = function (video) {
if (!PSL.shouldTemporarilyEnforceSpeed(video)) {
return;
}
setTimeout(function () {
if (!PSL.shouldTemporarilyEnforceSpeed(video) || !video.isConnected) {
return;
}
var speed = PSL.getMediaSpeed(video);
if (speed > 0 && Math.abs(video.playbackRate - speed) > 0.001) {
// 3s temporary-window content-world write (bypasses the page-world
// setter guard). Trace it so post-change rate churn is attributable.
video.playbackRate = speed;
}
}, 0);
};
PSL.expectPlaybackRateChange = function (video, speed) {
video._pslExpectedPlaybackRate = speed;
};
PSL.consumeExpectedPlaybackRateChange = function (video) {
var expected = video._pslExpectedPlaybackRate;
if (
typeof expected === "number" &&
Math.abs(video.playbackRate - expected) < 0.001
) {
video._pslExpectedPlaybackRate = undefined;
return true;
}
return false;
};
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/synthetic-speed-state.js ----
(function (PSL) {
function rememberPauseStateBeforeSyntheticSpeed(video) {
if (
!PSL.isSyntheticPausedSpeed(video) &&
typeof video._pslWasPausedBeforeSyntheticSpeed === "undefined"
) {
video._pslWasPausedBeforeSyntheticSpeed = video.paused;
}
}
function getReverseLoopBoundary(video) {
var mediaKey = PSL.getMediaKey(video);
var start = PSL.state.startTimes[mediaKey];
var end = PSL.state.endTimes[mediaKey];
if (
PSL.state.loopsEnabled[mediaKey] &&
typeof start === "number" &&
typeof end === "number" &&
end > start
) {
return { start: start, end: end };
}
return null;
}
PSL.isSyntheticPausedSpeed = function (video) {
return video._pslPausedForZeroSpeed || video._pslReversing;
};
PSL.stopReversePlayback = function (video) {
if (PSL.reversePlaybackEngine) {
PSL.reversePlaybackEngine.stop(video);
}
video._pslReversing = false;
};
PSL.clearSyntheticSpeedState = function (video) {
PSL.stopReversePlayback(video);
PSL.stopPersistentSpeedEnforcement(video);
video._pslPausedForZeroSpeed = false;
video._pslWasPausedBeforeSyntheticSpeed = undefined;
};
PSL.resumeAfterSyntheticSpeedIfNeeded = function (video, wasSyntheticSpeed) {
var shouldResume =
wasSyntheticSpeed && video._pslWasPausedBeforeSyntheticSpeed === false;
PSL.clearSyntheticSpeedState(video);
if (shouldResume) {
var playPromise = video.play();
if (playPromise && playPromise.catch) {
playPromise.catch(function () {});
}
}
};
PSL.pauseAtZeroSpeed = function (video) {
PSL.stopReversePlayback(video);
PSL.stopPersistentSpeedEnforcement(video);
rememberPauseStateBeforeSyntheticSpeed(video);
video._pslPausedForZeroSpeed = true;
video._pslEnforceSpeedUntil = 0;
PSL.updateSpeedIndicator(video);
video.pause();
};
PSL.startReversePlayback = function (video, speed) {
var frameRate = PSL.getReverseFrameRate();
var intervalMs = 1000 / frameRate;
var stepSeconds = Math.abs(speed) / frameRate;
var boundary = getReverseLoopBoundary(video);
PSL.stopReversePlayback(video);
rememberPauseStateBeforeSyntheticSpeed(video);
if (!PSL.reversePlaybackEngine) {
video._pslPausedForZeroSpeed = false;
video._pslReversing = false;
video._pslEnforceSpeedUntil = 0;
return;
}
video._pslPausedForZeroSpeed = false;
video._pslReversing = true;
video._pslEnforceSpeedUntil = 0;
PSL.updateSpeedIndicator(video);
video.pause();
PSL.reversePlaybackEngine.start(video, speed, {
intervalMs: intervalMs,
stepSeconds: stepSeconds,
loopStart: boundary && boundary.start,
loopEnd: boundary && boundary.end,
onEnd: function () {
PSL.setSpeed(video, 0);
},
onStop: function () {
video._pslReversing = false;
}
});
};
PSL.getEffectivePlaybackRate = function (video) {
return PSL.getMediaSpeed ? PSL.getMediaSpeed(video) : video.playbackRate;
};
PSL.stopSyntheticSpeed = function (video) {
PSL.clearSyntheticSpeedState(video);
};
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/speed-actions.js ----
(function (PSL) {
PSL.clampSpeed = function (speed, min, max) {
var result = Math.min(Math.max(speed, min), max);
return result;
};
PSL.getSpeedAdjustmentBase = function (video) {
var result;
if (PSL.getEffectivePlaybackRate) {
result = PSL.getEffectivePlaybackRate(video);
} else {
result = video.playbackRate;
}
return result;
};
// Single source for the relative-step math (base ± delta, 1.0-crossing snap,
// clamp/round). Shared by adjustSpeed's relative branch and the dispatcher's
// multi-element aggregation, so both compute an identical target. Pass `base`
// to reuse an already-read adjustment base and avoid a redundant read.
PSL.computeRelativeTarget = function (video, delta, base) {
var currentSpeed = typeof base === "number" ? base : PSL.getSpeedAdjustmentBase(video);
var targetSpeed = currentSpeed + delta;
if (
(currentSpeed > 1.0 && targetSpeed < 1.0) ||
(currentSpeed < 1.0 && targetSpeed > 1.0)
) {
targetSpeed = 1.0;
}
return Number(PSL.clampSpeed(targetSpeed, -16, 16).toFixed(2));
};
PSL.adjustSpeed = function (video, value, options) {
var relative;
var currentSpeed;
var targetSpeed;
options = options || {};
relative = options.relative === true;
if (typeof value !== "number" || isNaN(value)) {
return;
}
if (relative) {
currentSpeed = PSL.getSpeedAdjustmentBase(video);
targetSpeed = PSL.computeRelativeTarget(video, value, currentSpeed);
} else {
targetSpeed = Number(PSL.clampSpeed(value, -16, 16).toFixed(2));
}
PSL.setSpeed(video, targetSpeed);
};
PSL.setSpeed = function (video, speed) {
if (PSL.settings.speedControlsEnabled === false) {
return;
}
PSL.log("setSpeed started: " + speed, 5);
var speedValue = speed.toFixed(2);
var speedNumber = Number(speedValue);
// Write the chosen non-zero speed to the selected scope layer — the single
// source of truth that resolves the effective speed for every media. tab
// survives SPA navigation in the same runtime; domain persists to storage
// (below); video uses the per-media record written here. A negative
// (reverse) speed is maintained too, so reverse carries across navigation;
// the reverse engine clamps at currentTime 0 so it never rewinds past the
// start. Zero (pause) is a momentary per-video state and never defines the
// maintained layer.
var selectedScope = PSL.resolveSpeedScope
? PSL.resolveSpeedScope(PSL.settings.speedScope)
: "tab";
if (speedNumber !== 0) {
if (selectedScope === "tab" && PSL.state) {
PSL.state.tabSpeed = speedNumber;
} else if (selectedScope === "domain" && !video._pslApplyingStoredDomainSpeed) {
// Persist here (not on the positive path below) so reverse speeds —
// whose branch returns early — are stored and maintained too.
PSL.saveDomainSpeed(video, speedNumber);
}
}
var wasSyntheticSpeed = PSL.isSyntheticPausedSpeed(video);
var recordScope = video._pslApplyingStoredDomainSpeed ? "site" : "temporary";
PSL.setMediaSpeed(video, speedNumber, recordScope);
if (speedNumber <= 0) {
video._pslEnforceSpeedUntil = 0;
video._pslExpectedPlaybackRate = undefined;
PSL.stopPersistentSpeedEnforcement(video);
}
if (
PSL.pageWorldBridge &&
PSL.pageWorldBridge.isProbeEnabled &&
PSL.pageWorldBridge.isProbeEnabled() &&
PSL.pageWorldBridge.applySpeed
) {
PSL.pageWorldBridge.applySpeed(video, speedNumber, recordScope, "set-speed");
PSL.log("setSpeed sent to v2 media authority: " + speedNumber, 5);
}
if (speedNumber === 0) {
PSL.pauseAtZeroSpeed(video);
PSL.log("setSpeed paused media at zero speed", 5);
return;
}
if (speedNumber < 0) {
PSL.startReversePlayback(video, speedNumber);
PSL.settings.lastSpeed = speedNumber;
PSL.log("setSpeed started reverse media at " + speedNumber, 5);
return;
}
video._pslEnforceSpeedUntil = Date.now() + 3000;
try {
PSL.expectPlaybackRateChange(video, speedNumber);
video.playbackRate = speedNumber;
} catch (e) {
video._pslExpectedPlaybackRate = undefined;
throw e;
}
PSL.updateSpeedIndicator(video);
PSL.resumeAfterSyntheticSpeedIfNeeded(video, wasSyntheticSpeed);
PSL.enforceSpeedSoon(video);
PSL.startPersistentSpeedEnforcement(video);
PSL.log("setSpeed finished: " + speed, 5);
};
PSL.resetSpeed = function (video, target) {
if (Math.abs(PSL.getEffectivePlaybackRate(video) - target) < 0.001) {
PSL.log("Resetting playback speed to 1.0", 4);
PSL.setSpeed(video, 1.0);
} else {
PSL.log('Toggling playback speed to "preferred" speed', 4);
PSL.setSpeed(video, target);
}
};
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/speed-apply.js ----
(function (PSL) {
// Single application point for the maintained speed. Called on media attach and
// on every source-change event. Reads the selected scope layer (the single
// source of truth) and applies it to the element ONCE, so the user's speed —
// including reverse — carries across SPA navigation and blob source swaps.
// Replaces the former reassert / applyStoredDomainSpeed / applyLastSessionSpeed
// / page-world carry-over fallback chain.
PSL.applyEffectiveSpeed = function (video) {
if (!video) {
return false;
}
if (PSL.settings && PSL.settings.speedControlsEnabled === false) {
return false;
}
var speed = PSL.getMaintainedSpeed ? PSL.getMaintainedSpeed(video) : null;
if (typeof speed !== "number" || !Number.isFinite(speed) || speed === 0) {
// No maintained value for the selected scope (or a momentary pause): leave
// the new source at its native rate instead of stomping it.
return false;
}
// Already at the maintained speed — avoid restarting the enforcement window
// or the reverse engine on each redundant source event (loadstart/
// loadeddata/canplay/emptied all fire maintainSpeed).
if (speed > 0 && Math.abs((video.playbackRate || 0) - speed) < 0.001) {
return false;
}
if (speed < 0 && video._pslReversing) {
return false;
}
PSL.setSpeed(video, speed);
return true;
};
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/speed-domain-storage-listener.js ----
(function (PSL) {
if (PSL.__domainSpeedStorageListenerInstalled) {
return;
}
PSL.__domainSpeedStorageListenerInstalled = true;
try {
var storage =
typeof browser !== "undefined" && browser.storage
? browser.storage
: typeof chrome !== "undefined" && chrome.storage
? chrome.storage
: null;
if (!storage || !storage.onChanged) {
return;
}
storage.onChanged.addListener(function (changes, areaName) {
if (areaName && areaName !== "sync") {
return;
}
if (!changes || !changes.domainSpeeds) {
return;
}
PSL.settings.domainSpeeds = changes.domainSpeeds.newValue || {};
});
} catch (e) {}
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/playback-rate-change-listener.js ----
(function (PSL) {
function updateSpeedFromEvent(video) {
if (!video._pslControllerHandle || PSL.settings.speedControlsEnabled === false) {
return;
}
if (video._pslReversing) {
PSL.updateSpeedIndicator(video);
return;
}
if (video._pslPausedForZeroSpeed) {
PSL.updateSpeedIndicator(video);
return;
}
if (
PSL.getMediaSpeedScope &&
PSL.getMediaSpeedScope(video) === "temporary"
) {
PSL.updateSpeedIndicator(video);
return;
}
var speed = Number(video.playbackRate.toFixed(2));
var observation = PSL.observeNativePlaybackRate
? PSL.observeNativePlaybackRate(video, speed)
: { action: "adopt", speed: speed };
PSL.log("Playback rate changed to " + speed, 4);
PSL.updateSpeedIndicator(video);
if (video._pslControllerHandle && video._pslControllerHandle.blinkController) {
video._pslControllerHandle.blinkController();
}
}
PSL.setupRateChangeListener = function (doc) {
if (doc._pslRateChangeListenerAttached === PSL.instanceId) {
return;
}
if (doc._pslRateChangeHandler) {
doc.removeEventListener("ratechange", doc._pslRateChangeHandler, true);
}
doc._pslRateChangeHandler = function (event) {
var video = event.target;
if (PSL.consumeExpectedPlaybackRateChange(video)) {
PSL.log("Ignoring playback-rate change caused by setSpeed", 4);
event.stopImmediatePropagation();
return;
}
if (
PSL.shouldTemporarilyEnforceSpeed(video) &&
Math.abs(video.playbackRate - PSL.getMediaSpeed(video)) > 0.001
) {
PSL.log("Reapplying target playback speed", 4);
event.stopImmediatePropagation();
PSL.enforceSpeedSoon(video);
return;
}
updateSpeedFromEvent(video);
};
doc._pslRateChangeListenerAttached = PSL.instanceId;
doc.addEventListener("ratechange", doc._pslRateChangeHandler, true);
};
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/action-dispatcher.js ----
(function (PSL) {
var PUBLIC_ACTIONS = [
"slower",
"faster",
"reset",
"mark-loop-range",
"clear-loop"
];
PSL.runAction = function (action, value, e) {
PSL.log("runAction Begin", 5);
if (PUBLIC_ACTIONS.indexOf(action) === -1) {
PSL.log("Unknown public action ignored: " + action, 4);
return;
}
if (!PSL.isActionEnabled(action)) {
PSL.log("Action disabled by settings: " + action, 4);
return;
}
if (PSL.actions && typeof PSL.actions[action] === "function") {
PSL.actions[action](value, e);
return;
}
var targetController = e ? e.target.getRootNode().host : null;
// tab/domain scope share one layer base, so a per-element relative add chains
// into ±step×N when several media co-reside (the +1.0 bug). Compute the
// relative target ONCE from the first surviving element and apply it as an
// absolute set to every survivor, so the net change is ±step regardless of N.
// video scope keeps per-element relative (each has its own record).
var selectedScope = PSL.resolveSpeedScope
? PSL.resolveSpeedScope(PSL.settings.speedScope)
: "tab";
var aggregateRelative = selectedScope === "tab" || selectedScope === "domain";
var aggregateTarget = null;
var aggregateTargetComputed = false;
PSL.state.mediaElements.forEach(function (video) {
var handle = video._pslControllerHandle;
if (!handle) {
return;
}
if (e && (!handle.matchesControllerElement || !handle.matchesControllerElement(targetController))) {
return;
}
if (handle.showController) {
handle.showController();
}
if (video.classList.contains("mpc-cancelled")) {
return;
}
if (action === "faster" || action === "slower") {
var step = PSL.getSpeedStep();
var delta = action === "faster" ? step : -step;
if (aggregateRelative) {
// Compute the shared relative target once, then absolute-set every
// survivor so N co-resident elements can't chain into ±step×N.
if (!aggregateTargetComputed) {
aggregateTarget = PSL.computeRelativeTarget(video, delta);
aggregateTargetComputed = true;
}
PSL.adjustSpeed(video, aggregateTarget, { relative: false });
} else {
PSL.adjustSpeed(video, delta, { relative: true });
}
} else if (action === "reset") {
PSL.resetSpeed(video, 1.0);
} else if (action === "mark-loop-range") {
PSL.markLoopRange(video, video.currentTime);
} else if (action === "clear-loop") {
PSL.clearLoop(video);
}
});
PSL.log("runAction End", 5);
};
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/keyboard-shortcuts.js ----
(function (PSL) {
function getKeyboardEventKeyCode(event) {
var result;
if (event.keyCode) {
result = event.keyCode;
} else if (event.key && event.key.length === 1) {
result = event.key.toUpperCase().charCodeAt(0);
} else {
result = 0;
}
return result;
}
PSL.attachKeyboardShortcuts = function (docs) {
docs.forEach(function (doc) {
if (doc._pslKeyboardShortcutsAttached === PSL.instanceId) {
return;
}
if (doc._pslKeyboardShortcutsHandler) {
doc.removeEventListener(
"keydown",
doc._pslKeyboardShortcutsHandler,
true
);
}
doc._pslKeyboardShortcutsHandler = function (event) {
var keyCode = getKeyboardEventKeyCode(event);
var item;
PSL.log("Processing keydown event: " + keyCode, 6);
if (
!event.getModifierState ||
event.getModifierState("Alt") ||
event.getModifierState("Control") ||
event.getModifierState("Fn") ||
event.getModifierState("Meta") ||
event.getModifierState("Hyper") ||
event.getModifierState("OS")
) {
PSL.log("Keydown event ignored due to active modifier: " + keyCode, 5);
return;
}
if (
event.target.nodeName === "INPUT" ||
event.target.nodeName === "TEXTAREA" ||
event.target.isContentEditable
) {
return false;
}
if (!PSL.state.mediaElements.length) {
if (PSL.attachControlsToExistingMedia) {
PSL.attachControlsToExistingMedia(doc);
}
if (!PSL.state.mediaElements.length) {
return false;
}
}
item = PSL.settings.keyBindings.find(function (item) {
return item.key === keyCode;
});
if (item) {
PSL.runAction(item.action, item.value);
if (item.force === "true") {
event.preventDefault();
event.stopPropagation();
}
}
return false;
};
doc._pslKeyboardShortcutsAttached = PSL.instanceId;
doc.addEventListener("keydown", doc._pslKeyboardShortcutsHandler, true);
});
};
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/remove-stale-media-controls.js ----
(function (PSL) {
// Read a controller's owning instanceId in a cross-world-safe way. Two
// content-script worlds can share one document; a .mpc-controller created by
// the other world exposes its id via the shared DOM dataset (dataset.pslInstance)
// but NOT via the _pslInstanceId JS expando (expandos are per-world). Prefer
// the dataset, fall back to the expando for same-world / older controllers.
function readControllerInstance(controller) {
try {
if (controller.dataset && controller.dataset.pslInstance) {
return controller.dataset.pslInstance;
}
} catch (e) {}
try {
return controller._pslInstanceId;
} catch (e) {}
return undefined;
}
PSL.removeStaleControllerElements = function (doc) {
doc.querySelectorAll(".mpc-controller").forEach(function (controller) {
if (readControllerInstance(controller) !== PSL.instanceId) {
controller.remove();
}
});
};
// Remove controllers whose bound media element is no longer in the live DOM.
// SPA navigation (e.g. YouTube in a single tab) can swap out the player
// <video>; the old controller is otherwise only cleaned by the debounced
// MutationObserver, so a rescan can attach a new controller while the orphan
// is still visible (duplicate panels). Sweeping detached-media controllers at
// rescan time removes the orphan immediately instead of after the debounce.
PSL.removeDetachedControllerElements = function (doc) {
doc.querySelectorAll(".mpc-controller").forEach(function (controller) {
var media;
try {
media = controller._pslMediaElement;
} catch (e) {}
if (media && media.isConnected === false) {
controller.remove();
try {
delete media._pslControllerHandle;
} catch (e) {}
if (PSL.unregisterMedia) {
PSL.unregisterMedia(media);
}
}
});
};
// Normalize controllers so that each live media element owns at most one
// visible .mpc-controller. This generalizes the orphan/stale/detached sweeps
// into one pass run at rescan time:
// 1. live-set membership: remove controllers whose bound media is no longer
// reachable via findMediaElements (covers detached === isConnected:false
// AND media that left the scanned document/shadow tree but still reports
// connected, which the detached sweep cannot catch).
// 2. stale instance: remove controllers from a previous content injection
// (_pslInstanceId !== PSL.instanceId), even on still-live media.
// 3. dedupe: among controllers left on the same media, keep the one the
// media's current handle owns (matchesControllerElement); remove the rest.
// Removal uses the same raw element .remove() as the existing sweeps; the
// discarded duplicate wrappers have no back-reference to a VideoController, so
// a full handle teardown is neither reachable nor attempted here (same
// limitation as removeStale/removeDetached). The speed-view span is a child of
// the wrapper and is removed with it.
PSL.reconcileControllers = function (doc) {
doc = doc || document;
var live = new Set(PSL.findMediaElements(doc));
function readMedia(controller) {
try {
return controller._pslMediaElement;
} catch (e) {
return undefined;
}
}
function readInstance(controller) {
return readControllerInstance(controller);
}
function drop(controller, media, reason, releaseMedia) {
controller.remove();
if (releaseMedia && media) {
try {
delete media._pslControllerHandle;
} catch (e) {}
if (PSL.unregisterMedia) {
PSL.unregisterMedia(media);
}
}
}
// Pass 1: drop live-non-members and stale-instance controllers; collect the
// rest grouped by their bound media for the dedupe pass.
var byMedia = new Map();
doc.querySelectorAll(".mpc-controller").forEach(function (controller) {
var media = readMedia(controller);
if (!media || !live.has(media)) {
drop(controller, media, "not-live", true);
return;
}
if (readInstance(controller) !== PSL.instanceId) {
drop(controller, media, "stale-instance", false);
return;
}
var bucket = byMedia.get(media);
if (bucket) {
bucket.push(controller);
} else {
byMedia.set(media, [controller]);
}
});
// Pass 2: dedupe survivors per media, preferring the handle's own element.
byMedia.forEach(function (controllers, media) {
if (controllers.length < 2) {
return;
}
var handle = media._pslControllerHandle;
var keep = null;
if (handle && handle.matchesControllerElement) {
for (var i = 0; i < controllers.length; i++) {
if (handle.matchesControllerElement(controllers[i])) {
keep = controllers[i];
break;
}
}
}
if (!keep) {
keep = controllers[0];
}
controllers.forEach(function (controller) {
if (controller !== keep) {
drop(controller, media, "duplicate", false);
}
});
});
};
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/attach-controls-to-existing-media.js ----
(function (PSL) {
function attachControlsToExistingMedia(doc) {
PSL.findMediaElements(doc).forEach(PSL.attachControllerToMedia);
}
PSL.attachControlsToExistingMedia = attachControlsToExistingMedia;
PSL.rescanMedia = function (doc) {
doc = doc || document;
// Normalize controllers before re-attaching: one .mpc-controller per live
// media. This synchronously removes swapped-out (detached), stale-instance,
// and duplicate panels so they cannot coexist with the controller a rescan
// is about to (re)attach (the debounced MutationObserver would otherwise
// clean detached ones only ~1s later, and never the connected duplicates).
if (PSL.reconcileControllers) {
PSL.reconcileControllers(doc);
} else if (PSL.removeDetachedControllerElements) {
PSL.removeDetachedControllerElements(doc);
}
// Re-cover same-origin iframes that may have loaded since the last scan: a
// lazy about:blank -> player iframe is not caught by the one-shot cover in
// initializeNow. Idempotent (see coverMediaInsideFrames). This is the
// top-only equivalent of each frame's own DOMContentLoaded re-injection.
if (PSL.coverMediaInsideFrames) {
PSL.coverMediaInsideFrames(doc);
}
if (PSL.loadSettings) {
PSL.loadSettings(function () {
if (PSL.settings.enabled) {
attachControlsToExistingMedia(doc);
}
});
return;
}
attachControlsToExistingMedia(doc);
};
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/sync-controls-with-media-dom.js ----
(function (PSL) {
function syncControlsForMediaNode(doc, node, added) {
if (!added && doc.body.contains(node)) {
return;
}
if (
node.nodeName === "VIDEO" ||
(node.nodeName === "AUDIO" && PSL.settings.audioBoolean)
) {
if (added) {
PSL.attachControllerToMedia(node);
} else if (node._pslControllerHandle) {
PSL.removeControllerFromMedia(node);
}
} else if (added && node.shadowRoot) {
PSL.findMediaElements(node.shadowRoot).forEach(PSL.attachControllerToMedia);
} else if (node.children !== undefined) {
for (var i = 0; i < node.children.length; i++) {
syncControlsForMediaNode(doc, node.children[i], added);
}
}
}
PSL.installMediaDomControlSync = function (doc) {
var observer = new MutationObserver(function (mutations) {
PSL.requestIdle(
function () {
mutations.forEach(function (mutation) {
if (mutation.type !== "childList") {
return;
}
mutation.addedNodes.forEach(function (node) {
if (typeof node !== "function") {
syncControlsForMediaNode(doc, node, true);
}
});
mutation.removedNodes.forEach(function (node) {
if (typeof node !== "function") {
syncControlsForMediaNode(doc, node, false);
}
});
});
},
{ timeout: 1000 }
);
});
observer.observe(doc, {
childList: true,
subtree: true
});
doc._pslMediaObserver = observer;
};
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/refresh-controls-after-same-page-navigation.js ----
(function (PSL) {
var samePageRefreshDelays = [0, 100, 500, 1500];
function refreshSamePageMediaControls(doc, reason) {
if (PSL.pageWorldBridge && PSL.pageWorldBridge.installProbe) {
PSL.pageWorldBridge.installProbe(doc);
}
samePageRefreshDelays.forEach(function (delay) {
setTimeout(function () {
PSL.rescanMedia(doc);
}, delay);
});
}
PSL.installSamePageMediaControlRefresh = function (doc) {
if (doc !== window.document || window._pslUrlChangeSpeedRefreshInstalled) {
return;
}
window._pslUrlChangeSpeedRefreshInstalled = PSL.instanceId;
[
"pageshow",
"popstate",
"hashchange",
"yt-navigate-finish",
"yt-page-data-updated"
].forEach(function (eventName) {
window.addEventListener(eventName, function () {
refreshSamePageMediaControls(doc, eventName);
});
});
["pushState", "replaceState"].forEach(function (methodName) {
var original = window.history && window.history[methodName];
if (typeof original !== "function" || original._pslWrapped) {
return;
}
window.history[methodName] = function () {
var result = original.apply(this, arguments);
refreshSamePageMediaControls(doc, methodName);
return result;
};
window.history[methodName]._pslWrapped = true;
});
};
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/cover-media-inside-frames.js ----
(function (PSL) {
function inIframe() {
try {
return window.self !== window.top;
} catch (e) {
return true;
}
}
function injectFrameStylesheet(doc) {
var assetUrl;
var link;
if (doc === window.document) {
return;
}
assetUrl = PSL.getAssetUrl("extension/styles/content.css");
if (!assetUrl) {
return;
}
link = doc.createElement("link");
link.href = assetUrl;
link.type = "text/css";
link.rel = "stylesheet";
doc.head.appendChild(link);
}
PSL.getMediaControlKeyboardDocuments = function (doc) {
var docs = [doc];
try {
if (inIframe()) {
docs.push(window.top.document);
}
} catch (e) {}
return docs;
};
PSL.coverMediaInsideFrames = function (doc) {
injectFrameStylesheet(doc);
// Top-only injection: the background injects the content bundle into the MAIN
// frame only, so same-origin iframes have no runtime of their own. The top
// runtime descends into each accessible iframe's contentDocument and
// initializes it in-place — same JS world, same PSL.instanceId — so exactly
// one panel and one keyboard handler set exist per logical player. Reaching
// into a cross-origin iframe's contentDocument throws; those are skipped
// (cross-origin players are out of scope — same-origin only). Re-entry is
// idempotent: the _pslInitializedInstanceId guard in initializeNow no-ops an
// already-initialized document, and we skip re-arming initializeWhenReady on
// a document this runtime has already initialized.
var frames = doc.getElementsByTagName("iframe");
for (var i = 0; i < frames.length; i++) {
try {
var frameDoc = frames[i].contentDocument;
if (frameDoc && frameDoc._pslInitializedInstanceId !== PSL.instanceId) {
PSL.initializeWhenReady(frameDoc);
}
} catch (e) {
// Cross-origin iframe: contentDocument is not accessible. Out of scope.
}
}
};
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/start-media-control-session.js ----
(function (PSL) {
PSL.initializeWhenReady = function (doc) {
PSL.log("Begin initializeWhenReady", 5);
if (PSL.isBlacklisted()) {
return;
}
function initializeWhenBodyExists(attempt) {
if (!doc) {
return;
}
if (doc.body) {
PSL.initializeNow(doc);
return;
}
if (attempt < 20) {
setTimeout(function () {
initializeWhenBodyExists(attempt + 1);
}, 50);
}
}
if (doc === window.document) {
window.addEventListener(
"load",
function () {
PSL.initializeNow(window.document);
PSL.rescanMedia(window.document);
},
{ once: true }
);
}
initializeWhenBodyExists(0);
if (doc && doc.addEventListener) {
doc.addEventListener(
"DOMContentLoaded",
function () {
PSL.initializeNow(doc);
PSL.rescanMedia(doc);
},
{ once: true }
);
}
if (doc === window.document && doc.addEventListener) {
doc.addEventListener("visibilitychange", function () {
if (!doc.visibilityState || doc.visibilityState === "visible") {
PSL.rescanMedia(doc);
}
});
}
PSL.log("End initializeWhenReady", 5);
};
PSL.initializeNow = function (doc) {
PSL.log("Begin initializeNow", 5);
if (!PSL.settings.enabled) {
return;
}
if (!doc.body || doc._pslInitializedInstanceId === PSL.instanceId) {
return;
}
try {
if (doc._pslMediaObserver) {
doc._pslMediaObserver.disconnect();
}
} catch (e) {
// Stale observers from a previous temporary add-on load can be ignored.
}
try {
PSL.setupRateChangeListener(doc);
} catch (e) {
// no operation
}
doc._pslInitializedInstanceId = PSL.instanceId;
doc.body.classList.add("mpc-initialized");
PSL.log("initializeNow: mpc-initialized added to document body", 5);
PSL.attachKeyboardShortcuts(PSL.getMediaControlKeyboardDocuments(doc));
PSL.installMediaDomControlSync(doc);
PSL.removeStaleControllerElements(doc);
PSL.rescanMedia(doc);
if (doc === window.document) {
PSL.installSamePageMediaControlRefresh(doc);
}
PSL.coverMediaInsideFrames(doc);
PSL.log("End initializeNow", 5);
};
})(globalThis.PlaySpeedLoop);
// ---- code/extension/content/init.js ----
(function (PSL) {
if (PSL.__injectionInitialized === PSL.injectionSessionId) {
return;
}
PSL.__injectionInitialized = PSL.injectionSessionId;
try {
PSL.loadSettings(function () {
try {
PSL.initializeWhenReady(document);
} catch (error) {
console.error("Speed & Loop failed to initialize", error);
}
});
} catch (error) {
console.error("Speed & Loop failed to load settings", error);
PSL.initializeWhenReady(document);
}
})(globalThis.PlaySpeedLoop);
})();