Control HTML5 video speed and AB loop actions.
Version au
// ==UserScript==
// @name Speed & Loop (userscript)
// @name:ja Speed & Loop(ユーザースクリプト)
// @namespace https://github.com/grad13/Speed-and-Loop
// @version 1.0.12
// @description Control HTML5 video speed and AB loop actions.
// @description:ja Control HTML5 video speed and AB loop actions.
// @author speed-and-loop
// @license MIT
// @homepageURL https://github.com/grad13/Speed-and-Loop
// @supportURL https://github.com/grad13/Speed-and-Loop/issues
// @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-hidden {\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: hidden;\n transition: opacity 0.25s cubic-bezier(0.4, 0, 0.2, 1);\n opacity: 0;\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");
// ---- src/extension/content/namespace.js ----
(function (global) {
var PSL = global.PlaySpeedLoop || {};
PSL.regStrip = /^[\r\t\f\v ]+|[\r\t\f\v ]+$/gm;
PSL.instanceId =
"psl-" + Date.now() + "-" + Math.random().toString(36).slice(2);
PSL.state = {
mediaElements: [],
startTimes: {},
endTimes: {},
loopsEnabled: {},
coolDown: false,
controllerTimer: null,
nextMediaId: 1,
instanceId: PSL.instanceId
};
PSL.requestIdle =
global.requestIdleCallback ||
function (callback) {
return global.setTimeout(function () {
callback({ didTimeout: false, timeRemaining: function () { return 0; } });
}, 0);
};
global.PlaySpeedLoop = PSL;
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);
// ---- src/extension/content/defaults.js ----
(function (PSL) {
PSL.defaultKeyBindings = [
// Compatibility action: kept until the shortcut/settings surface is reduced intentionally.
{ action: "display", key: 86, value: 0, force: false, predefined: true },
// MVP speed actions.
{ action: "slower", key: 83, value: 0.5, force: false, predefined: true },
{ action: "faster", key: 68, value: 0.5, force: false, predefined: true },
// Legacy/custom actions: dispatch remains available for existing settings.
{ action: "rewind", key: 90, value: 10, force: false, predefined: true },
{ action: "advance", key: 88, value: 10, force: false, predefined: true },
// MVP speed reset/preferred-speed actions.
{ action: "reset", key: 82, value: 1, force: false, predefined: true },
{ action: "fast", key: 71, value: 1.8, force: false, predefined: true },
// MVP loop actions.
{ action: "set-start", key: 0, value: 0, force: false, predefined: true },
{ action: "set-end", key: 0, value: 0, force: false, predefined: true },
{ action: "toggle-loop", key: 0, value: 0, force: false, predefined: true }
];
PSL.defaultSettings = {
lastSpeed: 1.0,
enabled: true,
speeds: {},
displayKeyCode: 86,
audioBoolean: false,
speedControlsEnabled: true,
loopControlsEnabled: true,
speedStep: 0.5,
preferredSpeed: 1.8,
reverseFrameRate: 1,
keepSpeedSites: "",
startHidden: false,
controllerOpacity: 0.3,
keyBindings: PSL.defaultKeyBindings,
blacklist: "www.instagram.com\ntwitter.com\nvine.co\nimgur.com\nteams.microsoft.com",
defaultLogLevel: 4,
logLevel: 1
};
PSL.cloneDefaultSettings = function () {
var settings = Object.assign({}, PSL.defaultSettings);
settings.speeds = {};
settings.keyBindings = PSL.defaultKeyBindings.map(function (binding) {
return Object.assign({}, binding);
});
return settings;
};
PSL.settings = PSL.cloneDefaultSettings();
})(globalThis.PlaySpeedLoop);
// ---- src/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);
// ---- src/extension/content/storage-api.js ----
(function (PSL) {
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) {
defaults = defaults || {};
browser.storage.sync.get(defaults).then(
callback,
function () {
callback(defaults);
}
);
},
set: function (values, callback) {
browser.storage.sync.set(values).then(function () {
if (callback) {
callback();
}
});
}
};
} else if (hasChromeStorage()) {
platform = {
get: function (defaults, callback) {
chrome.storage.sync.get(defaults || {}, callback);
},
set: function (values, callback) {
chrome.storage.sync.set(values, callback);
}
};
} else if (hasGMStorage()) {
platform = {
get: function (defaults, callback) {
var result = Object.assign({}, defaults || {});
var error;
var keys = Object.keys(result);
try {
keys.forEach(function (key) {
var stored = GM_getValue(key, null);
result[key] = parseGMValue(stored, result[key]);
});
callback(result);
return;
} catch (errorInner) {
error = errorInner;
}
if (error) {
callback(defaults || {});
}
},
set: function (values, callback) {
var error;
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();
}
return;
} catch (errorInner) {
error = errorInner;
}
if (callback && error) {
callback();
}
}
};
} else {
platform = {
get: function (defaults, callback) {
callback(defaults || {});
},
set: function (_values, callback) {
if (callback) {
callback();
}
}
};
}
PSL.storagePlatform = platform;
PSL.getStorage = function (defaults, callback) {
platform.get(defaults, callback);
};
PSL.setStorage = function (values, callback) {
platform.set(values, callback);
};
})(globalThis.PlaySpeedLoop);
// ---- src/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);
// ---- src/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 (rawStorage) {
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;
}
[
"display",
"slower",
"faster",
"reset",
"fast",
"set-start",
"set-end",
"toggle-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.lastSpeed = Number(storage.lastSpeed) || 1.0;
settings.displayKeyCode = Number(storage.displayKeyCode) || 86;
settings.audioBoolean = Boolean(storage.audioBoolean);
settings.enabled = Boolean(storage.enabled);
settings.speedControlsEnabled = storage.speedControlsEnabled !== false;
settings.loopControlsEnabled = storage.loopControlsEnabled !== false;
settings.keepSpeedSites = String(storage.keepSpeedSites || "");
settings.startHidden = Boolean(storage.startHidden);
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 || {};
PSL.settings = settings;
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,
startHidden: PSL.settings.startHidden,
enabled: PSL.settings.enabled,
controllerOpacity: PSL.settings.controllerOpacity,
blacklist: PSL.settings.blacklist.replace(PSL.regStrip, "")
});
}
callback();
});
};
})(globalThis.PlaySpeedLoop);
// ---- src/extension/content/feature-gates.js ----
(function (PSL) {
var SPEED_ACTIONS = ["slower", "faster", "reset", "fast"];
var LOOP_ACTIONS = ["set-start", "set-end", "toggle-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 true;
};
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
);
};
})(globalThis.PlaySpeedLoop);
// ---- src/extension/content/blacklist.js ----
(function (PSL) {
PSL.escapeStringRegExp = function (str) {
var matchOperatorsRe = /[|\\{}()[\]^$+*?.]/g;
return str.replace(matchOperatorsRe, "\\$&");
};
PSL.isBlacklisted = function () {
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);
} catch (err) {
return;
}
} else {
regexp = new RegExp(PSL.escapeStringRegExp(match));
}
if (regexp.test(location.href)) {
blacklisted = true;
}
});
return blacklisted;
};
})(globalThis.PlaySpeedLoop);
// ---- src/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);
// ---- src/extension/content/site-placement.js ----
(function (PSL) {
function getAncestor(node, levels) {
var current = node;
for (var i = 0; i < levels; i++) {
if (!current || !current.parentElement) {
return null;
}
current = current.parentElement;
}
return current;
}
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.div;
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;
case host === "www.reddit.com":
case /hbogo\./.test(host):
parent.parentElement.insertBefore(fragment, parent);
break;
case host === "www.facebook.com":
var facebookContainer = getAncestor(parent, 7);
if (facebookContainer) {
facebookContainer.insertBefore(fragment, facebookContainer.firstChild);
} else {
parent.insertBefore(fragment, parent.firstChild);
}
break;
case host === "tv.apple.com":
var scrim = parent.getRootNode().querySelector(".scrim");
if (scrim) {
scrim.prepend(fragment);
} else {
parent.insertBefore(fragment, parent.firstChild);
}
break;
default:
parent.insertBefore(fragment, parent.firstChild);
}
};
})(globalThis.PlaySpeedLoop);
// ---- src/extension/content/overlay-critical-css.js ----
(function (PSL) {
PSL.getOverlayCriticalCss = function () {
return (
"#controller:not(.expanded) #loop-row," +
"#controller:not(.expanded) #speed-row .row-label," +
"#controller:not(.expanded) #speed-row button{" +
"display:none !important;" +
"}" +
"#controller.expanded #loop-summary{" +
"display:none !important;" +
"}" +
"#controller.expanded #loop-row{" +
"display:flex !important;" +
"}" +
"#controller.expanded #speed-row .row-label," +
"#controller.expanded #speed-row button{" +
"display:inline-block !important;" +
"}"
);
};
})(globalThis.PlaySpeedLoop);
(function (PSL) {
var criticalCss = PSL.getOverlayCriticalCss;
var overlayCss = "* {\n line-height: 1.2;\n font-family: sans-serif;\n font-size: 12px;\n}\n\n#controller {\n position: absolute;\n top: 0;\n left: 0;\n\n display: grid;\n gap: 5px;\n padding: 6px;\n margin: 10px 10px 10px 15px;\n\n background: black;\n color: white;\n border-radius: 5px;\n cursor: default;\n z-index: 9999999;\n}\n\n#controller:hover {\n opacity: 0.7;\n}\n\n.control-row {\n display: flex;\n align-items: center;\n gap: 5px;\n white-space: nowrap;\n}\n\n#loop-row,\n#speed-row .row-label,\n#speed-row button {\n display: none;\n}\n\n#controller.expanded #loop-summary,\n#controller.dragging #loop-summary {\n display: none;\n}\n\n#controller.expanded #loop-row,\n#controller.dragging #loop-row {\n display: flex;\n}\n\n#controller.expanded #speed-row .row-label,\n#controller.expanded #speed-row button,\n#controller.dragging #speed-row .row-label,\n#controller.dragging #speed-row button {\n display: inline-block;\n}\n\n.row-label {\n min-width: 35px;\n font-size: 11px;\n opacity: 0.7;\n}\n\n.speed-value {\n display: inline-block;\n min-width: 36px;\n text-align: center;\n font-weight: bold;\n padding: 1px 2px;\n}\n\n.time-button {\n min-width: 82px;\n text-align: left;\n}\n\n.loop-toggle {\n min-width: 38px;\n text-align: center;\n}\n\n.loop-summary-value {\n display: inline-block;\n min-width: 58px;\n text-align: center;\n font-weight: bold;\n padding: 1px 2px;\n}\n\n/* Dragging */\n.draggable {\n cursor: -webkit-grab;\n cursor: -moz-grab;\n}\n\n.draggable:active {\n cursor: -webkit-grabbing;\n cursor: -moz-grabbing;\n}\n\n#controller.dragging {\n cursor: -webkit-grabbing;\n cursor: -moz-grabbing;\n opacity: 0.7;\n}\n\n/* Buttons */\nbutton {\n cursor: pointer;\n color: black;\n background: white;\n font-weight: bold;\n border-radius: 4px;\n padding: 3px 6px;\n font-size: 12px;\n line-height: 14px;\n border: 1px solid white;\n font-family:\n system-ui,\n -apple-system,\n BlinkMacSystemFont,\n \"Segoe UI\",\n sans-serif;\n}\n\nbutton:focus {\n outline: 0;\n}\n\nbutton:hover {\n opacity: 1;\n}\n\nbutton:active {\n background: #ccc;\n}\n";
PSL.getOverlayCriticalCss = function () {
return overlayCss + (criticalCss ? criticalCss() : '');
};
})(globalThis.PlaySpeedLoop);
// ---- src/extension/content/overlay-template.js ----
(function (PSL) {
PSL.formatSpeedValue = function (playbackRate) {
return playbackRate.toFixed(2);
};
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);
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";
controller.className = controllerClasses.join(" ");
controller.style.top = options.top;
controller.style.left = options.left;
controller.style.opacity = 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", "\u2212");
var speedIndicator = appendTextElement(
doc,
speedRow,
"span",
options.speed,
"draggable speed-value",
"speed-indicator"
);
speedIndicator.dataset.action = "drag";
appendButton(doc, speedRow, "faster", "+");
appendButton(doc, speedRow, "reset", "Reset", "reset-button");
controller.appendChild(speedRow);
}
if (options.loopControlsEnabled) {
if (!options.speedControlsEnabled) {
var loopSummary = doc.createElement("div");
loopSummary.id = "loop-summary";
loopSummary.className = "control-row";
appendTextElement(
doc,
loopSummary,
"span",
options.loopStatus,
"loop-summary-value",
"loop-summary-indicator"
);
controller.appendChild(loopSummary);
}
var loopRow = doc.createElement("div");
loopRow.id = "loop-row";
loopRow.className = "control-row";
appendTextElement(doc, loopRow, "span", "Loop", "row-label");
appendButton(
doc,
loopRow,
"set-start",
"Start --:--",
"time-button",
"start-indicator"
);
appendButton(
doc,
loopRow,
"set-end",
"End --:--",
"time-button",
"end-indicator"
);
appendButton(
doc,
loopRow,
"toggle-loop",
"OFF",
"loop-toggle",
"toggle-indicator"
);
controller.appendChild(loopRow);
}
fragment.appendChild(PSL.buildOverlayStyleElement(doc));
fragment.appendChild(controller);
return fragment;
};
})(globalThis.PlaySpeedLoop);
// ---- src/extension/content/overlay-controller.js ----
(function (PSL) {
function hasMediaSource(media) {
return Boolean(media.currentSrc || media.src || media.srcObject);
}
PSL.VideoController = function (target, parent) {
if (target._mpc) {
return target._mpc;
}
PSL.registerMedia(target);
target._mpc = this;
this._pslInstanceId = PSL.instanceId;
this.video = target;
this.parent = target.parentElement || parent;
this.updateSourceVisibility = function () {
if (!target._mpc || !target._mpc.div) {
return;
}
target._mpc.div.classList.toggle("mpc-nosource", !hasMediaSource(target));
};
PSL.state.startTimes[PSL.getMediaKey(target)] = 0;
target.addEventListener(
"loadedmetadata",
(this.handleLoadedMetadata = function () {
PSL.state.endTimes[PSL.getMediaKey(target)] = target.duration;
target._mpc.updateSourceVisibility();
})
);
if (target.duration) {
PSL.state.endTimes[PSL.getMediaKey(target)] = target.duration;
}
PSL.state.loopsEnabled[PSL.getMediaKey(target)] = false;
this.div = this.initializeControls();
this.handleSourceVisibilityUpdate = this.updateSourceVisibility.bind(this);
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._mpc) {
target._mpc.updateSourceVisibility();
}
}
});
}
);
this.sourceObserver.observe(target, {
attributeFilter: ["src", "currentSrc"]
});
};
PSL.VideoController.prototype.remove = function () {
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._mpc;
PSL.unregisterMedia(this.video);
};
PSL.VideoController.prototype.initializeControls = function () {
PSL.log("initializeControls Begin", 5);
var doc = this.video.ownerDocument;
var speed = PSL.formatSpeedValue(this.video.playbackRate);
var mediaKey = PSL.getMediaKey(this.video);
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;
if (!hasMediaSource(this.video)) {
wrapper.classList.add("mpc-nosource");
}
if (PSL.settings.startHidden) {
wrapper.classList.add("mpc-hidden");
}
var shadow = wrapper.attachShadow({ mode: "closed" });
shadow.appendChild(PSL.buildOverlayControllerContent(doc, {
top: top,
left: left,
opacity: PSL.settings.controllerOpacity,
speed: speed,
speedControlsEnabled: PSL.settings.speedControlsEnabled !== false,
loopControlsEnabled: PSL.settings.loopControlsEnabled !== false,
loopStatus: PSL.state.loopsEnabled[mediaKey] ? "Loop ON" : "Loop OFF"
}));
wrapper._shadow = shadow;
var draggable = shadow.querySelector(".draggable");
if (draggable) {
draggable.addEventListener(
"mousedown",
function (e) {
PSL.runAction(e.target.dataset.action, false, e);
e.stopPropagation();
},
true
);
}
shadow.querySelectorAll("button").forEach(function (button) {
button.addEventListener(
"click",
function (e) {
PSL.runAction(e.target.dataset.action, PSL.getKeyBinding(e.target.dataset.action), e);
e.stopPropagation();
},
true
);
});
var controllerElement = shadow.querySelector("#controller");
controllerElement.addEventListener("mouseenter", function () {
controllerElement.classList.add("expanded");
});
controllerElement.addEventListener("mouseleave", function () {
if (!controllerElement.classList.contains("dragging")) {
controllerElement.classList.remove("expanded");
}
});
controllerElement.addEventListener("click", function (e) { return e.stopPropagation(); }, false);
controllerElement.addEventListener("mousedown", function (e) { return e.stopPropagation(); }, false);
this.speedIndicator = shadow.querySelector("#speed-indicator");
this.startIndicator = shadow.querySelector("#start-indicator");
this.endIndicator = shadow.querySelector("#end-indicator");
this.toggleIndicator = shadow.querySelector("#toggle-indicator");
this.loopSummaryIndicator = shadow.querySelector("#loop-summary-indicator");
var fragment = doc.createDocumentFragment();
fragment.appendChild(wrapper);
this.div = wrapper;
PSL.placeController(this, fragment);
return wrapper;
};
PSL.handleDrag = function (video, e) {
var controller = video._mpc.div;
var shadowController = controller._shadow.querySelector("#controller");
var parentElement = controller.parentElement;
while (
parentElement.parentNode &&
parentElement.parentNode.offsetHeight === parentElement.offsetHeight &&
parentElement.parentNode.offsetWidth === parentElement.offsetWidth
) {
parentElement = parentElement.parentNode;
}
video.classList.add("mpc-dragging");
shadowController.classList.add("dragging");
shadowController.classList.add("expanded");
var initialMouseXY = [e.clientX, e.clientY];
var initialControllerXY = [
parseInt(shadowController.style.left),
parseInt(shadowController.style.top)
];
var startDragging = function (e) {
var style = shadowController.style;
var dx = e.clientX - initialMouseXY[0];
var dy = e.clientY - initialMouseXY[1];
style.left = initialControllerXY[0] + dx + "px";
style.top = initialControllerXY[1] + dy + "px";
};
var stopDragging = function () {
parentElement.removeEventListener("mousemove", startDragging);
parentElement.removeEventListener("mouseup", stopDragging);
parentElement.removeEventListener("mouseleave", stopDragging);
shadowController.classList.remove("dragging");
shadowController.classList.remove("expanded");
video.classList.remove("mpc-dragging");
};
parentElement.addEventListener("mouseup", stopDragging);
parentElement.addEventListener("mouseleave", stopDragging);
parentElement.addEventListener("mousemove", startDragging);
};
PSL.showController = function (controller) {
PSL.log("Showing controller", 4);
controller.classList.add("mpc-show");
if (PSL.state.controllerTimer) {
clearTimeout(PSL.state.controllerTimer);
}
PSL.state.controllerTimer = setTimeout(function () {
controller.classList.remove("mpc-show");
PSL.state.controllerTimer = false;
PSL.log("Hiding controller", 5);
}, 2000);
};
})(globalThis.PlaySpeedLoop);
// ---- src/extension/content/speed-actions.js ----
(function (PSL) {
function refreshCoolDown() {
PSL.log("Begin refreshCoolDown", 5);
if (PSL.state.coolDown) {
clearTimeout(PSL.state.coolDown);
}
PSL.state.coolDown = setTimeout(function () {
PSL.state.coolDown = false;
}, 1000);
PSL.log("End refreshCoolDown", 5);
}
function shouldEnforceSpeed(video) {
return (
video._pslTargetPlaybackRate !== undefined &&
video._pslEnforceSpeedUntil &&
Date.now() < video._pslEnforceSpeedUntil
);
}
function enforceSpeedSoon(video) {
if (!shouldEnforceSpeed(video)) {
return;
}
setTimeout(function () {
if (!shouldEnforceSpeed(video) || !video.isConnected) {
return;
}
if (Math.abs(video.playbackRate - video._pslTargetPlaybackRate) > 0.001) {
video.playbackRate = video._pslTargetPlaybackRate;
}
}, 0);
}
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\./, "");
}
function isKeepSpeedSite(video) {
var doc = video.ownerDocument || document;
var host = (doc.location && doc.location.hostname || "").replace(/^www\./, "");
var sites = String(PSL.settings.keepSpeedSites || "")
.split("\n")
.map(normalizeKeepSpeedPattern)
.filter(Boolean);
return sites.some(function (site) {
if (site.startsWith("/")) {
try {
return new RegExp(site.slice(1, -1)).test(host);
} catch (e) {
return false;
}
}
return host === site || host.endsWith("." + site);
});
}
function stopPersistentSpeedEnforcement(video) {
if (video._pslSpeedEnforcer) {
clearInterval(video._pslSpeedEnforcer);
video._pslSpeedEnforcer = null;
}
}
function startPersistentSpeedEnforcement(video) {
stopPersistentSpeedEnforcement(video);
if (!isKeepSpeedSite(video)) {
return;
}
video._pslSpeedEnforcer = setInterval(function () {
var target = video._pslTargetPlaybackRate;
if (
!video.isConnected ||
typeof target !== "number" ||
target <= 0 ||
video._pslPausedForZeroSpeed ||
video._pslReversing
) {
stopPersistentSpeedEnforcement(video);
return;
}
if (Math.abs(video.playbackRate - target) > 0.001) {
video.playbackRate = target;
updateSpeedIndicator(video, target);
}
}, 50);
}
function updateSpeedIndicator(video, speed) {
if (video._mpc && video._mpc.speedIndicator) {
video._mpc.speedIndicator.textContent = speed.toFixed(2);
}
}
function isSyntheticPausedSpeed(video) {
return video._pslPausedForZeroSpeed || video._pslReversing;
}
function rememberPauseStateBeforeSyntheticSpeed(video) {
if (
!isSyntheticPausedSpeed(video) &&
typeof video._pslWasPausedBeforeSyntheticSpeed === "undefined"
) {
video._pslWasPausedBeforeSyntheticSpeed = video.paused;
}
}
function stopReverse(video) {
if (video._pslReverseTimer) {
clearInterval(video._pslReverseTimer);
video._pslReverseTimer = null;
}
video._pslReversing = false;
}
function clearSyntheticSpeedState(video) {
stopReverse(video);
stopPersistentSpeedEnforcement(video);
video._pslPausedForZeroSpeed = false;
video._pslWasPausedBeforeSyntheticSpeed = undefined;
}
function resumeIfNeeded(video, wasSyntheticSpeed) {
var shouldResume =
wasSyntheticSpeed && video._pslWasPausedBeforeSyntheticSpeed === false;
clearSyntheticSpeedState(video);
if (shouldResume) {
var playPromise = video.play();
if (playPromise && playPromise.catch) {
playPromise.catch(function () {});
}
}
}
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;
}
function startReverse(video, speed) {
var frameRate = PSL.getReverseFrameRate();
var intervalMs = 1000 / frameRate;
var stepSeconds = Math.abs(speed) / frameRate;
stopReverse(video);
rememberPauseStateBeforeSyntheticSpeed(video);
video._pslPausedForZeroSpeed = false;
video._pslReversing = true;
video._pslEnforceSpeedUntil = 0;
updateSpeedIndicator(video, speed);
video.pause();
video._pslReverseTimer = setInterval(function () {
var boundary;
var nextTime;
if (!video.isConnected || !video._pslReversing) {
stopReverse(video);
return;
}
if (!video.paused) {
video.pause();
}
boundary = getReverseLoopBoundary(video);
nextTime = video.currentTime - stepSeconds;
if (boundary && nextTime <= boundary.start) {
video.currentTime = boundary.end;
return;
}
if (nextTime <= 0) {
video.currentTime = 0;
PSL.setSpeed(video, 0);
return;
}
video.currentTime = nextTime;
}, intervalMs);
}
function updateSpeedFromEvent(video) {
if (!video._mpc || PSL.settings.speedControlsEnabled === false) {
return;
}
if (video._pslReversing) {
updateSpeedIndicator(video, video._pslTargetPlaybackRate);
return;
}
if (video._pslPausedForZeroSpeed) {
updateSpeedIndicator(video, 0);
return;
}
var src = video.currentSrc;
var speed = Number(video.playbackRate.toFixed(2));
PSL.log("Playback rate changed to " + speed, 4);
updateSpeedIndicator(video, speed);
PSL.settings.speeds[src] = speed;
PSL.settings.lastSpeed = speed;
PSL.setStorage({ lastSpeed: speed }, function () {
PSL.log("Speed setting saved: " + speed, 5);
});
PSL.runAction("blink", null, null);
}
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.state.coolDown) {
PSL.log("Speed event propagation blocked", 4);
event.stopImmediatePropagation();
}
if (
shouldEnforceSpeed(video) &&
Math.abs(video.playbackRate - video._pslTargetPlaybackRate) > 0.001
) {
PSL.log("Reapplying target playback speed", 4);
event.stopImmediatePropagation();
enforceSpeedSoon(video);
return;
}
updateSpeedFromEvent(video);
};
doc._pslRateChangeListenerAttached = PSL.instanceId;
doc.addEventListener("ratechange", doc._pslRateChangeHandler, true);
};
PSL.getEffectivePlaybackRate = function (video) {
if (video._pslReversing && typeof video._pslTargetPlaybackRate === "number") {
return video._pslTargetPlaybackRate;
}
if (video._pslPausedForZeroSpeed) {
return 0;
}
if (typeof video._pslTargetPlaybackRate === "number") {
return video._pslTargetPlaybackRate;
}
return video.playbackRate;
};
PSL.stopSyntheticSpeed = function (video) {
clearSyntheticSpeedState(video);
};
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);
var wasSyntheticSpeed = isSyntheticPausedSpeed(video);
video._pslTargetPlaybackRate = speedNumber;
if (speedNumber === 0) {
stopReverse(video);
stopPersistentSpeedEnforcement(video);
rememberPauseStateBeforeSyntheticSpeed(video);
video._pslPausedForZeroSpeed = true;
video._pslEnforceSpeedUntil = 0;
updateSpeedIndicator(video, 0);
video.pause();
refreshCoolDown();
PSL.log("setSpeed paused media at zero speed", 5);
return;
}
if (speedNumber < 0) {
stopPersistentSpeedEnforcement(video);
startReverse(video, speedNumber);
PSL.settings.lastSpeed = speedNumber;
refreshCoolDown();
PSL.log("setSpeed started reverse media at " + speedNumber, 5);
return;
}
video._pslEnforceSpeedUntil =
Date.now() +
(PSL.isAmazonVideoDocument && PSL.isAmazonVideoDocument(video.ownerDocument)
? 15000
: 3000);
video.playbackRate = speedNumber;
updateSpeedIndicator(video, speedNumber);
resumeIfNeeded(video, wasSyntheticSpeed);
PSL.settings.lastSpeed = speed;
refreshCoolDown();
enforceSpeedSoon(video);
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);
// ---- src/extension/content/loop-actions.js ----
(function (PSL) {
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.setLoopStart = function (video, loopStart) {
var src = PSL.getMediaKey(video);
if (isNaN(loopStart) || (video.duration && loopStart === video.duration)) {
PSL.resetLoopStart(video);
return;
}
loopStart = Number(loopStart);
PSL.state.startTimes[src] = loopStart;
if (video._mpc && video._mpc.startIndicator) {
video._mpc.startIndicator.textContent =
"Start " + PSL.convertSecToMin(loopStart);
}
if (src in PSL.state.endTimes && loopStart >= PSL.state.endTimes[src]) {
PSL.resetLoopEnd(video);
}
};
PSL.setLoopEnd = function (video, loopEnd) {
var src = PSL.getMediaKey(video);
if (isNaN(loopEnd) || loopEnd === 0) {
PSL.resetLoopEnd(video);
return;
}
loopEnd = Number(loopEnd);
PSL.state.endTimes[src] = loopEnd >= video.duration ? video.duration - 0.05 : loopEnd;
if (video._mpc && video._mpc.endIndicator) {
video._mpc.endIndicator.textContent =
"End " + PSL.convertSecToMin(PSL.state.endTimes[src]);
}
if (src in PSL.state.startTimes && loopEnd <= PSL.state.startTimes[src]) {
PSL.resetLoopStart(video);
}
};
PSL.resetLoopStart = function (video) {
var src = PSL.getMediaKey(video);
PSL.state.startTimes[src] = 0;
if (video._mpc) {
if (video._mpc.startIndicator) {
video._mpc.startIndicator.textContent = "Start --:--";
}
PSL.updateLoopToggleDisplay(video, false);
}
PSL.state.loopsEnabled[src] = false;
};
PSL.resetLoopEnd = function (video) {
var src = PSL.getMediaKey(video);
delete PSL.state.endTimes[src];
if (video._mpc) {
if (video._mpc.endIndicator) {
video._mpc.endIndicator.textContent = "End --:--";
}
PSL.updateLoopToggleDisplay(video, false);
}
PSL.state.loopsEnabled[src] = false;
};
PSL.toggleLoop = function (video) {
var src = PSL.getMediaKey(video);
if (PSL.state.loopsEnabled[src]) {
PSL.state.loopsEnabled[src] = false;
PSL.updateLoopToggleDisplay(video, false);
video.removeEventListener("timeupdate", video._mpc._loopHandler);
} else {
PSL.state.loopsEnabled[src] = true;
PSL.updateLoopToggleDisplay(video, true);
video._mpc._loopHandler = function () {
var currentSrc = PSL.getMediaKey(video);
if (!PSL.state.loopsEnabled[currentSrc]) {
video.removeEventListener("timeupdate", video._mpc._loopHandler);
return;
}
if (
video.currentTime >= PSL.state.endTimes[currentSrc] ||
video.currentTime < PSL.state.startTimes[currentSrc]
) {
video.currentTime = PSL.state.startTimes[currentSrc];
}
};
video.addEventListener("timeupdate", video._mpc._loopHandler);
}
};
PSL.updateLoopToggleDisplay = function (video, enabled) {
var text = enabled ? "ON" : "OFF";
if (!video._mpc) {
return;
}
if (video._mpc.toggleIndicator) {
video._mpc.toggleIndicator.textContent = text;
}
if (video._mpc.loopSummaryIndicator) {
video._mpc.loopSummaryIndicator.textContent = "Loop " + text;
}
};
PSL.disableLoop = function (video) {
var src = PSL.getMediaKey(video);
PSL.state.loopsEnabled[src] = false;
if (video._mpc && video._mpc._loopHandler) {
video.removeEventListener("timeupdate", video._mpc._loopHandler);
video._mpc._loopHandler = null;
}
PSL.updateLoopToggleDisplay(video, false);
};
})(globalThis.PlaySpeedLoop);
// ---- src/extension/content/action-dispatcher.js ----
(function (PSL) {
PSL.runAction = function (action, value, e) {
PSL.log("runAction Begin", 5);
if (!PSL.isActionEnabled(action)) {
PSL.log("Action disabled by settings: " + action, 4);
return;
}
var targetController = e ? e.target.getRootNode().host : null;
PSL.state.mediaElements.forEach(function (video) {
if (!video._mpc) {
return;
}
var controller = video._mpc.div;
if (e && targetController !== controller) {
return;
}
PSL.showController(controller);
if (video.classList.contains("mpc-cancelled")) {
return;
}
// Legacy/custom actions stay dispatchable until stored settings and options UI are reduced intentionally.
if (action === "rewind") {
video.currentTime -= value;
} else if (action === "advance") {
video.currentTime += value;
} else if (action === "faster") {
var currentSpeed = PSL.getEffectivePlaybackRate
? PSL.getEffectivePlaybackRate(video)
: video.playbackRate;
var baseSpeed = currentSpeed === 0 ? 0.0 : currentSpeed;
PSL.setSpeed(
video,
Math.min(baseSpeed + PSL.getSpeedStep(), 16)
);
} else if (action === "slower") {
var nextSpeed =
(PSL.getEffectivePlaybackRate
? PSL.getEffectivePlaybackRate(video)
: video.playbackRate) - PSL.getSpeedStep();
PSL.setSpeed(video, Math.max(nextSpeed, -16));
} else if (action === "reset") {
PSL.resetSpeed(video, PSL.getPreferredSpeed());
} else if (action === "display") {
controller.classList.add("mpc-manual");
controller.classList.toggle("mpc-hidden");
} else if (action === "blink") {
if (
controller.classList.contains("mpc-hidden") ||
controller.blinkTimeOut !== undefined
) {
clearTimeout(controller.blinkTimeOut);
controller.classList.remove("mpc-hidden");
controller.blinkTimeOut = setTimeout(function () {
controller.classList.add("mpc-hidden");
controller.blinkTimeOut = undefined;
}, value || 1000);
}
} else if (action === "set-start") {
PSL.setLoopStart(video, video.currentTime);
} else if (action === "set-end") {
PSL.setLoopEnd(video, video.currentTime);
} else if (action === "toggle-loop") {
PSL.toggleLoop(video);
} else if (action === "drag") {
PSL.handleDrag(video, e);
} else if (action === "fast") {
PSL.resetSpeed(video, PSL.getPreferredSpeed());
} else if (action === "pause") {
video.paused ? video.play() : video.pause();
} else if (action === "muted") {
video.muted = video.muted !== true;
} else if (action === "mark") {
video._mpc.mark = video.currentTime;
} else if (action === "jump") {
if (video._mpc.mark && typeof video._mpc.mark === "number") {
video.currentTime = video._mpc.mark;
}
}
});
PSL.log("runAction End", 5);
};
})(globalThis.PlaySpeedLoop);
// ---- src/extension/content/keyboard-shortcuts.js ----
(function (PSL) {
function getKeyboardEventKeyCode(event) {
if (event.keyCode) {
return event.keyCode;
}
if (event.key && event.key.length === 1) {
return event.key.toUpperCase().charCodeAt(0);
}
return 0;
}
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);
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) {
return false;
}
var 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);
// ---- src/extension/content/media-observer.js ----
(function (PSL) {
function inIframe() {
try {
return window.self !== window.top;
} catch (e) {
return true;
}
}
function getShadow(parent) {
var result = [];
function getChild(parent) {
if (parent.firstElementChild) {
var child = parent.firstElementChild;
do {
result.push(child);
getChild(child);
if (child.shadowRoot) {
result.push(getShadow(child.shadowRoot));
}
child = child.nextElementSibling;
} while (child);
}
}
getChild(parent);
return result.flat(Infinity);
}
function collectMediaElements(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;
}
function isCurrentController(media) {
try {
return media._mpc && media._mpc._pslInstanceId === PSL.instanceId;
} catch (e) {
return false;
}
}
function removeController(media) {
if (!media._mpc) {
return;
}
try {
if (typeof media._mpc.remove === "function") {
media._mpc.remove();
return;
}
} catch (e) {
// Fall through and remove the visible controller element directly.
}
try {
if (media._mpc.div) {
media._mpc.div.remove();
}
} catch (e) {}
delete media._mpc;
PSL.unregisterMedia(media);
}
function attachController(media) {
if (!PSL.shouldAttachController()) {
return;
}
if (isCurrentController(media)) {
return;
}
removeController(media);
if (!media._mpc) {
new PSL.VideoController(media);
}
}
function removeStaleControllerElements(doc) {
doc.querySelectorAll(".mpc-controller").forEach(function (controller) {
var instanceId;
try {
instanceId = controller._pslInstanceId;
} catch (e) {}
if (instanceId !== PSL.instanceId) {
controller.remove();
}
});
}
function checkForMedia(doc, node, parent, added) {
if (!added && doc.body.contains(node)) {
return;
}
if (
node.nodeName === "VIDEO" ||
(node.nodeName === "AUDIO" && PSL.settings.audioBoolean)
) {
if (added) {
attachController(node);
} else if (node._mpc) {
removeController(node);
}
} else if (added && node.shadowRoot) {
collectMediaElements(node.shadowRoot).forEach(attachController);
} else if (node.children !== undefined) {
for (var i = 0; i < node.children.length; i++) {
var child = node.children[i];
checkForMedia(doc, child, child.parentNode || parent, added);
}
}
}
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);
},
{ once: true }
);
}
initializeWhenBodyExists(0);
if (doc && doc.addEventListener) {
doc.addEventListener(
"DOMContentLoaded",
function () {
PSL.initializeNow(doc);
},
{ once: true }
);
}
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);
if (doc !== window.document) {
var assetUrl = PSL.getAssetUrl("extension/styles/content.css");
if (assetUrl) {
var link = doc.createElement("link");
link.href = assetUrl;
link.type = "text/css";
link.rel = "stylesheet";
doc.head.appendChild(link);
}
}
var docs = [doc];
try {
if (inIframe()) {
docs.push(window.top.document);
}
} catch (e) {}
PSL.attachKeyboardShortcuts(docs);
var observer = new MutationObserver(function (mutations) {
PSL.requestIdle(
function () {
mutations.forEach(function (mutation) {
switch (mutation.type) {
case "childList":
mutation.addedNodes.forEach(function (node) {
if (typeof node === "function") {
return;
}
checkForMedia(doc, node, node.parentNode || mutation.target, true);
});
mutation.removedNodes.forEach(function (node) {
if (typeof node === "function") {
return;
}
checkForMedia(doc, node, node.parentNode || mutation.target, false);
});
break;
case "attributes":
if (
mutation.target.attributes["aria-hidden"] &&
mutation.target.attributes["aria-hidden"].value === "false"
) {
var node = getShadow(doc.body).filter(function (x) {
return x.tagName === "VIDEO";
})[0];
if (node) {
if (node._mpc) {
node._mpc.remove();
}
checkForMedia(doc, node, node.parentNode || mutation.target, true);
}
}
break;
}
});
},
{ timeout: 1000 }
);
});
observer.observe(doc, {
attributeFilter: ["aria-hidden"],
childList: true,
subtree: true
});
doc._pslMediaObserver = observer;
removeStaleControllerElements(doc);
collectMediaElements(doc).forEach(attachController);
if (PSL.isAmazonVideoDocument && PSL.isAmazonVideoDocument(doc)) {
var scanCount = 0;
var scanTimer = setInterval(function () {
scanCount++;
collectMediaElements(doc).forEach(attachController);
if (scanCount >= 30) {
clearInterval(scanTimer);
}
}, 1000);
}
Array.prototype.forEach.call(doc.getElementsByTagName("iframe"), function (frame) {
try {
PSL.initializeWhenReady(frame.contentDocument);
} catch (e) {}
});
PSL.log("End initializeNow", 5);
};
})(globalThis.PlaySpeedLoop);
// ---- src/extension/content/init.js ----
(function (PSL) {
if (PSL.__injectionInitialized) {
return;
}
PSL.__injectionInitialized = true;
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);
})();