Speed & Loop (userscript)

Control HTML5 video speed and AB loop actions.

目前為 2026-06-17 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

Advertisement:

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

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


})();