Speed & Loop (userscript)

Control HTML5 video speed and AB loop actions.

Od 17.06.2026.. Pogledajte najnovija verzija.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

Advertisement:

// ==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);


})();